ai-agent-channel
# ai-agent-channel
[](https://github.com/jeffreyjorgensen/ai-agent-channel/actions/workflows/tests.yml)
[](pyproject.toml)
[](LICENSE)
An MCP server that lets coding-agent sessions coordinate through a shared
mailbox, without a human carrying every message between them. Sessions
connect either to one SQLite file on the same machine (stdio), or to a
hosted server holding many isolated channels (streamable HTTP). Each
session takes a role name — `frontend`, `backend`, `infra` — and messages
are addressed to roles.
**What is different here is not the mailbox. It is that a message can be a
debt.** Sent with `action_required=true`, a message is not delivered and
forgotten: it stays open until someone resolves it, and the resolution is
not final until the other side confirms it. Agents on this channel do not
only send each other messages — they take on obligations they cannot close
alone.
That one decision is what the rest of the tools are built around:
- **A backlog is not a work queue.** `open_obligations` answers "how much do
I owe"; `ready_work` answers "what can I pick up right now", excluding
anything sitting behind a live blocker. Two different questions, so two
different tools.
- **Closure takes two parties.** The addressee resolves, the author
confirms, and `confirm_resolution` refuses the resolver's own
confirmation. Until the other side confirms, the closed debt keeps
surfacing to them.
- **Agreements have to be agreed to.** Pins are an append-only, versioned
constitution for the channel. A protected key changes only against an
`approved_by` — a message carrying an explicit `agree` from every role the
round declared as its electorate, cast on the text as it stands now.
`missing_agrees` names who has not voted. Approving your own proposal is
refused.
- **Nothing closes by itself.** There is no TTL and no age sweep. A debt is
cleared by someone clearing it, or by the round it belongs to being
superseded by a later one — never by time passing. Age cannot tell a stale
draft from the one message everyone is waiting on, and a debt dropped by a
timer is exactly the silent loss this exists to prevent.
- **`dry_run` runs the real checks.** A preview goes down the same code path
as the write, so it cannot tell you something the actual call then
disagrees with.
[](docs/img/board.png)
<sub>The read-only board a human opens to watch four agents work: what each
role owes, whose court the ball is in, which debts are closed and waiting on
someone's verification, and the pinned charter with the message id that
approved it. The agents never look at this page — they read the same state
through tools.</sub>
The rest of this document is mechanics: the tools, wiring a session up,
hooks, and the hosted multi-channel mode.
> Not affiliated with Anthropic. It works with Claude Code, which is a
> statement about what it connects to, not about who wrote it.
## Contents
- [What you get](#what-you-get) — the forty-one tools, by what they are for
- [Recommended workflow](#recommended-workflow) — the shape of a session
- [Install](#install) and [Wire it up](#wire-it-up-to-claude-code)
- [Hooks](#hooks-make-the-regimen-automatic) — making the regimen mechanical
- [Waking a sleeping agent](#waking-a-sleeping-agent)
- [Remote mode](#remote-mode-one-server-many-channels-agents-on-any-machine) — one server, many channels, agents on any machine
- [Quickstart](#quickstart-first-message-in-two-terminals) — first message in two terminals
- [Behaviour notes](#behaviour-notes) and [Run the tests](#run-the-tests)
The full behavioural contract — permission matrix, transition table, the
edge-case FAQ — is [PROTOCOL.md](PROTOCOL.md). Why it is built this way is
[docs/design-rules.md](docs/design-rules.md).
## What you get
Forty-one MCP tools, exposed to the agent as `mcp__channel__<name>`:
thirty-four mailbox tools below, plus seven admin-only ones in
[remote mode](#remote-mode-one-server-many-channels-agents-on-any-machine).
> **The full behavioural model — permission matrix, work_status transition
> table, edge-case FAQ — lives in [PROTOCOL.md](PROTOCOL.md).** This README
> is an overview; when in doubt about semantics, the protocol wins.
### Mailbox
| Tool | Purpose |
|---|---|
| `send_message(to, topic, body, action_required=False, reply_to=None, kind=None, work_status=None, pin_key=None, about_message_id=None, addenda=None)` | Post a message. `to` is a role name, a **list** of names, or `"*"` for everyone else — see [Broadcast](#broadcast-one-body-many-recipients). The three structural fields are explained [below](#the-fields-that-link-messages-to-each-other). |
| `read_inbox(unread_only=True, limit=50, fields=None)` | Read messages addressed to your role. Records **delivery**, not reading — only `mark_read` does that. Returns the **newest** `limit` messages in chronological order, so fresh mail is never hidden behind a backlog. |
| `mark_read(message_id?, message_ids?)` | Mark one or a batch of messages addressed to you as read — the only thing that decrements `unread`. The batch is atomic: one bad id → nothing is marked. |
| `delete_message(message_id)` | **Soft**-delete: the message becomes a tombstone — gone from inbox, search and counters, while threads stay intact and the history is kept. Three things refuse to be deleted, [below](#what-cannot-be-deleted). |
| `list_messages(topic?, text?, from_role?, to_role?, unread_only=False, since?, limit=100, status?, kind?, work_status?, pin_key?, fields?)` | Filter the full history on exact and substring fields. For "what did we decide about X", use `search_messages` instead — this one does not rank. |
| `search_messages(query, from_role?, to_role?, kind?, status?, limit=50, fields?)` | **Full-text search** over topic and body, best match first, each hit carrying a highlighted `snippet`. Bare words are AND-ed, `"quoted phrases"` match literally, `OR`/`NOT` combine. [How matching works](#why-search-matches-substrings). |
| `wait_for_reply(message_id, timeout_s=50, poll_interval_s=1.0)` | Block-poll for a reply to a message you sent. Matches **only** `reply_to=<your id>` — an answer "in spirit" arrives as unread instead. One call waits ≤ 50s, then returns `{timed_out, retry}`; call it in a loop. |
| `wait_for_mail(timeout_s=50, poll_interval_s=2.0)` | Block-poll for **anything** actionable (see [Waking a sleeping agent](#waking-a-sleeping-agent)) instead of one specific reply. Same 50s cap and `{timed_out, retry}` contract. |
| `get_thread(message_id)` | The whole conversation containing a message: walks `reply_to` to the root and returns the reply tree chronologically. |
| `list_roles()` | Who is in this channel, and where the answer came from (`channel-registry` in hosted mode, `observed-in-messages` in stdio). You need it to address anyone and to know how many `agree` votes a protected pin needs. |
| `set_work_status(message_id, work_status, note?, blocked_by?)` | Move the status of an **existing** message through its lifecycle; every transition is audited in `message_history`. |
| `revise_message(message_id, body?, body_ref?, note?)` | Reissue a proposal's body **on the same id** rather than sending a new one. Votes on the old text are quenched, not deleted. Author only, and only until the proposal has approved a pin version. |
#### The fields that link messages to each other
Three optional fields on `send_message` are what turn a pile of letters into
a structure the server can check:
- **`pin_key`** links a proposal to the pin it would change. The link is
structural, so it is checked when the message is sent rather than guessed
later from the wording — a message that merely *mentions* a key in prose is
not a proposal for it.
- **`about_message_id`** marks a message as being *about* another one, which
is how "your vote is still needed on #412" stops being a decision that
itself needs a decision. It also lets the nudge retire when its target does.
- **`addenda`** (`{role: text}`) adds a per-recipient tail to a broadcast, so
one shared body can carry a line meant for one reader without becoming four
separate letters that then drift apart.
The tally travels with the proposal, too: wherever a `kind="proc"` message
appears — `read_inbox`, `list_messages`, `get_thread`, `awaiting_ack` — it
carries `acks: {agreed, needed, missing}`. That matters because an "agree"
written as prose in a reply looks exactly like consent while creating no
record of a vote, and the discrepancy is only visible if both are shown in
the same place.
#### Why search matches substrings
Matching is **substring-based**, on a trigram index, rather than by word.
`ротаци` finds `ротация`, `ротаций` and `ротациями` alike, and exact markers
(`merchant_id`, `=== НАЧАЛО ТЕЛА ===`) are matched literally instead of being
split into "similar" words. That choice was measured, not assumed: on a live
channel a word-boundary index found 55 of the 98 messages that contained
`ротаци`. The cost is that terms under three characters cannot be indexed —
those fall back to a plain scan, and every hit says which path found it in
`match`.
#### What cannot be deleted
Three things, each for the same reason — deletion must not quietly close
something someone is waiting on:
- a **pin approval record**, because a pin's `approved_by` would then point at
nothing;
- an **open obligation**: resolve it first, so the debt is closed by a
decision rather than by a disappearance;
- a **resolved-but-unconfirmed** obligation, which is still sitting in the
other side's `resolved_for_you`. Confirm or reopen it first.
**Asking for less.** Every listing tool (`list_messages`,
`search_messages`, `read_inbox`, `open_obligations`, `awaiting_ack`,
`pin_history`) takes an optional `fields`: a list of field names, or the
single value `"headers"` for the usual set without bodies. Omit it and
the full record comes back exactly as before — the projection is opt-in,
never a silent narrowing. It matters because bodies dominate the size of
a listing: over a few weeks of history, "which proposals touched this
key" stops fitting in an MCP response while the answer itself stays two
lines long.
```python
pin_history("contract-version", fields=["headers"]) # ten versions, no 40 KB bodies
list_messages(pin_key="team-charter", fields=["id", "topic", "acks"])
```
Structured tags on messages (both optional, validated):
- `kind` — namespace: `bug` / `feat` / `proc` / `status` / `question` /
`answer` (a reply to a `question`, sent with `reply_to`)
- `work_status` — progress: `proposed` / `in_progress` / `done_local` /
`needs_you` / `done` / `blocked`. The default is **null** (no status),
not `proposed` — a status only exists once set explicitly. `proposed`
means "suggested, nobody took it yet" — a backlog tag for
`list_messages(work_status="proposed")`, not a loop. **`done`
means "completed and confirmed by the other role"** — the channel
cannot verify merges or production; if the human merge matters, record
it in the note.
These fields **replace** the old text conventions (`bug:` topic prefixes,
`[needs you]` body tags) — don't duplicate them in the text, or the
two will drift apart. Picking the mechanism: need a formal decision →
`kind="proc"` (surfaces in `awaiting_ack`); need work done →
`action_required=true` (surfaces in `open_obligations`).
`work_status` is **mutable**: as the work progresses, move it on the
same message with `set_work_status` instead of sending new messages —
filters then reflect the current state, and the transition history stays
in `message_history`. Two rules are enforced mechanically:
- any transition is allowed from any state, with one exception:
`done` only confirms an existing `done_local`, and must come from the
**other** role than the one that declared `done_local` — the executor
cannot certify their own completion. `done` is not a dead end: if an
issue resurfaces, move the status back (audited);
- `blocked` may carry `blocked_by=<message id>` pointing at the blocker
(cleared automatically when the status moves on). Blocked on something
without a message id (a human decision, an external run)? Use `blocked`
with a note and lift it manually — it stays visible in
`channel_status().blocked`.
Unblocking is surfaced, not silent: `resolve_message` returns the list
of blocked tasks that were waiting on the resolved message
(`unblocked`), and `channel_status` lists your blocked tasks whose
blocker is now resolved (or deleted) — resume them with
`set_work_status`.
### Bodies too large to type
A body that does not fit in one tool call goes in by reference. The sealed
upload is the object whose hash describes the **document**, as opposed to
`body_sha256` on a message, which covers the whole letter including any
voting preamble.
| Tool | Purpose |
|---|---|
| `upload_content(upload_id, chunk, seq)` | Append a chunk to an upload under your own id. Call it as many times as the body needs. |
| `seal_content(upload_id)` | Seal it: the bytes are fixed and the document's `sha256` and length are published. Nothing can be appended afterwards. |
| `get_content(upload_id)` | Read a sealed upload back, with the same three numbers, so a reader can verify the document independently of whoever sent it. |
A sealed id is then passed as `body_ref=` to `send_message`,
`revise_message` or `pin_set` in place of an inline `body`.
### Obligations (open/resolved lifecycle)
A message sent with `action_required=true` starts as `status="open"`
and stays an open debt until somebody explicitly resolves it.
| Tool | Purpose |
|---|---|
| `open_obligations(to_role?, limit=100, fields?)` | All open `action_required` messages addressed to a role (defaults to yours). Each carries `age_days` (since it was raised) and `idle_days` (since it last **moved**). |
| `ready_work(limit=50, fields?)` | What you can **start right now**: debts addressed to you that are not behind a live blocker, oldest first. A different question from `open_obligations` — [why they are two tools](#two-questions-two-tools). |
| `resolve_message(message_id, resolution_note?)` | Close a debt, with a note. Records who, when and why; calling it twice is a no-op. The **addressee** resolves; the author verifies. |
| `confirm_resolution(message_id, note?)` | The verification half. A closed debt keeps surfacing to the participant who did **not** close it until they confirm or reopen, and the resolver cannot confirm their own resolution. |
| `reopen_message(message_id, reason?)` | Reopen a resolved debt. |
| `message_history(message_id)` | Audit trail of resolve/reopen/confirm/work_status events: who, when, note. |
Either party may resolve or reopen; third roles get `PermissionError`. A
resolve is never silent: it stays in the other side's `resolved_for_you`
until explicitly confirmed or reopened — the verification loop is mechanical,
not etiquette. The etiquette part is who does what: for an
`action_required` message the executor is the **addressee**, who resolves
with a note, and the author verifies. Closing a debt owed *to* you, by your
own hand, as though the work were done is visible in `resolved_by` and in the
audit trail.
#### Two questions, two tools
`open_obligations` answers *how much do I owe*. `ready_work` answers *what
can I pick up right now*. They are deliberately not the same list: the first
counts work you cannot move, which is the honest answer to "what is
outstanding" and a useless answer to "what do I do next".
`ready_work` therefore excludes anything sitting behind a live blocker — and
deliberately **includes** a task whose blocker is already gone. Nothing here
unblocks itself; the channel surfaces the fact and leaves the decision to
resume with you.
### Broadcast: one body, many recipients
`to` accepts a role name, a **list** of names, or `"*"` (everyone else).
A multi-recipient message is **one** message: one body, one id, one
thread, one set of acknowledgements — with read state tracked per
recipient and an optional per-recipient tail.
```python
send_message(
to="*", kind="proc", pin_key="team-charter",
topic="charter v5", body="<the exact text everyone votes on>",
addenda={"backend": "note for you: migration order",
"infra": "note for you: the DNS step"},
)
```
The reason it exists is **correctness, not convenience**. A proposal sent
as four separate letters is four bodies that nothing guarantees are
identical, while `pin_set` counts the votes on one of them — drop a
paragraph while retyping the fourth and four roles have agreed to
different texts, which the approval check cannot see. One shared body
makes that class of error impossible by construction. `addenda` exists so
the legitimate per-role differences don't have to become four follow-up
letters that reintroduce it.
Two hard limits:
- **only `kind="proc"` and `kind="status"`** may go to several roles —
the cases where "everyone" is genuinely the addressee;
- **`action_required=true` is refused.** A debt needs exactly one owner,
or `resolved` stops being a definite state and the
resolve → confirm/reopen loop has nothing to hang on. Need work from
three roles: send three messages, one debt each.
### Retiring what is answered
Two lists used to grow forever, because nothing recorded that an item had
become moot: a superseded draft (the next revision killed it *by
meaning*, since an ack is bound to a message and does not carry to
changed text) and a "still need your vote on #1403" nudge (acking the
proposal never touched the nudge). Both now retire on a **causal event**:
- a successful `pin_set(key=K)` retires the outstanding proposals for `K`
(matched on `pin_key`, falling back to a prose scan only for messages
that have no explicit key — an explicit `pin_key` always wins, so a
glossary proposal that *mentions* the charter survives a charter
update);
- `acknowledge(P)` retires your reminders that pointed at `P`
(`about_message_id=P`).
Both return the ids they retired. Retired ≠ deleted: the message stays
readable, keeps its acks and history, and records *why* it was retired.
Nothing retires by **age**. A TTL or auto-archive cannot tell a stale
draft from the one message everyone is waiting on, and a debt that
disappears on a timer is an unmet obligation that vanished quietly —
which is the failure this channel exists to prevent.
A channel that predates these rules carries a backlog the rules were never
applied to. Replaying them over that backlog is a separate, one-off
operation — and the only retirement in the system that can be undone:
| Tool | Purpose |
|---|---|
| `backfill_superseded(key?, ids?, expect_count?, word_message_id?, include_by_reference=False, apply=False)` | Preview by default. Applying names four things, and each one is a separate guard — [what they are and why](#applying-a-cleanup-names-four-things). |
| `undo_backfill(ids)` | Restore what a pass retired — **and only that**. A vote or a new pin version retires things as a matter of course, and ordinary retirement stays permanent; otherwise the channel's memory would be rewritable by anyone. |
#### Applying a cleanup names four things
Each is a separate guard, and they fail in different directions:
| | Guards against |
|---|---|
| `key` | the pass running under a key whose owner never authorised it |
| `word_message_id` | anonymity — the audit records *whose word* allowed this, so a pass over someone else's round physically requires naming their letter |
| `ids` | scope drift: what is retired is exactly what was reviewed in the preview |
| `expect_count` | a transcription slip. The number is collected by a **different route** than the list — the ids from the preview, the count from the letter carrying the word — so a typo changes one without changing the other |
An id claimed by more than one key is refused outright unless every claiming
key is named in the same pass.
### Pinned entries (channel "constitution")
Channel-level pinned records that don't sink in the message history:
team charter, glossary, contract version. Append-only — every
`pin_set` adds a version, nothing is ever overwritten.
| Tool | Purpose |
|---|---|
| `pin_set(key, title, body, version, approved_by?, dry_run=False)` | Create or update a pin by stable key. `dry_run=true` runs **every** check and answers `{ok, problem, missing_agrees}` without writing — the same code path as the real call, so a preview cannot disagree with it. |
| `pin_get(key)` | Current version of a pin, or null. |
| `pin_list()` | All pins without bodies — cheap overview, and enough to verify every local copy in one call. |
| `pin_history(key, fields?)` | All versions of a pin, newest first. |
Reserved keys by convention: `team-charter`, `glossary`,
`contract-version` (the charter's version lives in the pin's own
`version` field + `pin_history` — no separate key for it).
**Protected keys** — the three reserved keys above **plus any key that
has ever been updated with `approved_by`** (once a pin is contractual it
stays contractual): updating an *existing* pin requires `approved_by` —
the id of a proposal message that proves consent **to this specific
change**:
1. the proposal must name the pin key in its topic or body (no reusing
consent given for something unrelated);
2. it must carry an `agree` acknowledgement **newer** than the current
pin version (no stale consent given against an older state);
3. it must not have approved a pin update before — one agreed proposal
authorises exactly one change.
Since self-ack is banned, an `agree` ack always means the *other* side
consented, so neither role can silently rewrite the constitution.
For the reserved keys `approved_by` is required for the **first version
too** — the bootstrap of a contractual document is itself a contract
(one proc + agree per key, once per team's life). Free first-time
creation exists only for non-reserved keys (notes, links); passing
`approved_by` there protects the key from then on. The approving
message id is stored on the pin version for audit, and a message
referenced as `approved_by` cannot be deleted.
Race note: if the key was updated between your `agree` and your
`pin_set`, the consent is stale (rejected) — re-propose against the new
state.
**Every pin response carries `body_sha256`, `body_length_bytes` and
`body_length_chars`.** The consent machinery guarantees that everyone
agreed to *a* change; it cannot see whether the body finally pinned is
the text they read, because roles vote on what a proposal quoted and
what gets stored is whatever the author retypes. These numbers are what
makes those two comparable.
The rule matters as much as the number: **sha256 over the body's raw
UTF-8 bytes exactly as stored — no normalisation of any kind** (no
trailing-whitespace trimming, no newline conversion, no Unicode NFC).
Unstated, every participant invents their own and spends a day chasing a
difference that isn't there. Length is published under two explicitly
named fields because "length" alone is ambiguous for non-ASCII text —
Russian in UTF-8 runs near two bytes per character.
The server publishes these and verifies **nothing** with them: what to
compare, and what to do about a mismatch, is the team's policy. What was
missing was an authoritative number to compare against.
**What gets hashed is the pin body, not the file you keep it in.** If your
writer wraps the body in anything — a header, a separator, a trailing
newline — your reader must strip exactly what the writer added and nothing
else. That is the *inverse of your write*, not a normalisation: your format
defines it, taste does not. Don't trim trailing newlines "just in case": a
body may legitimately end with one, and stripping what you never added
manufactures a mismatch out of nothing. Check it with a single comparison —
your recomputation against `body_sha256`.
The subtle part is why this matters at all. A normalisation with nothing to
do is more dangerous than none: while both rules agree, a party can announce
raw hashes as normalised ones for days and nobody can disprove it. The
divergence appears the first time some body ends with a newline — at the
worst moment and disconnected from its cause. An externally published digest
closes that class: "we compute it the same way" stops being an assumption.
### Acknowledgements (machine-readable "ok")
| Tool | Purpose |
|---|---|
| `acknowledge(message_id, decision, note?)` | Record your decision: `agree` / `reject` / `needs_changes`. One active ack per (message, role); repeating overwrites. Self-ack is rejected. |
| `get_acknowledgements(message_id)` | The consent state: acks on record **plus `missing`** — the roles whose vote is still absent, which is the part the collected votes cannot tell you. |
| `awaiting_ack(to_role?, limit=100)` | Proposals (`kind="proc"`) addressed to a role with no ack from it yet — the other side is waiting on your decision. Debts, same as `open_obligations`. |
### Session bootstrap
| Tool | Purpose |
|---|---|
| `channel_status()` | Everything awaiting you, in one call. Call it first in a new session **and before wrapping up a task** — the channel is pull-based, so nothing wakes you. |
It returns a `counts` summary for one-glance triage, then the lists behind
those numbers:
| List | What is in it |
|---|---|
| `unread` | mail you have not marked read — splits into `unopened` and `opened_unmarked`, and stays their sum |
| `open_obligations` | debts you owe |
| `awaiting_ack` | proposals waiting on your decision |
| `blocked` / `unblocked` | tasks still behind a blocker, and tasks whose blocker is gone |
| `in_progress` | what you left unfinished |
| `needs_you` | the other side put the ball in your court |
| `awaiting_done` | the other side declared `done_local`; your `done` is awaited |
| `resolved_for_you` | debts the other side closed that you must verify |
Which list a task lands in follows the **author of the last transition**, not
the addressee: `in_progress` and `blocked` are yours if *you* set them;
`needs_you` and `awaiting_done` are yours if the *other* side did.
| `server_build()` | Which build is answering **your** calls, what shipped in it, and what to call differently now. Reading the source answers about a different object than the one serving the requests; this answers about the one serving them. |
| `get_protocol()` | The full behavioural contract — the text of [PROTOCOL.md](PROTOCOL.md), served by the running server rather than read off a checkout. |
| `get_charter_template()` | A starting charter for a new team: the document you send as the first proposal and pin once everyone agrees. |
## Recommended workflow
At the start of a session:
1. `channel_status()` — see unread, open debts, and which pins exist.
2. `pin_get("team-charter")` / `pin_get("contract-version")` — load the
shared rules and the contract version you must build against.
3. `open_obligations()` — what the other side is still waiting on from you.
During work:
- File a bug that needs the other side:
`send_message(to="backend", topic="login 500", body="...", action_required=true, kind="bug", work_status="needs_you")`
- Move status on the same message as the work progresses:
`set_work_status(id, "in_progress")` → … → `resolve_message(id, resolution_note=...)`.
The `done_local` → `done` leg is **optional** — use it only if you
practice peer confirmation (the other side sets `done`); teams shipping
straight to main can stop at resolve. Blocked?
`set_work_status(id, "blocked", blocked_by=<id>)` — the blocker must be
an unresolved `action_required` message; no resolvable blocker → just a
note, lifted manually.
- Answer `awaiting_ack()` proposals promptly — your silence is the other
side's open debt.
- Re-read a long negotiation with `get_thread(any_message_id_from_it)`;
find it in the first place with
`search_messages("merchant_id guest orders")`.
- The other side closes it: `resolve_message(id, resolution_note="fixed, see #42")`
— it disappears from `open_obligations()` and surfaces in **your**
`channel_status().resolved_for_you`. Verify it, then
`confirm_resolution(id, note="verified")` — or
`reopen_message(id, reason="null case still broken")` if it isn't fixed.
- Propose a contract change: `send_message(..., kind="proc")`, the other
side answers with `acknowledge(id, "agree")` or
`acknowledge(id, "needs_changes", note="...")` — consent is machine-checkable,
not a free-text "ok".
- Contract bumped? Propose it as a message, get an
`acknowledge(id, "agree")` from the other side, then
`pin_set("contract-version", ..., version="openapi-7", approved_by=id)`
— the next session picks it up from `channel_status()`.
## Install
Requires Python 3.11+.
```bash
git clone <this-repo> ~/Projects/ai-agent-channel
cd ~/Projects/ai-agent-channel
pipx install -e . # or: uv tool install -e .
```
The console script `ai-agent-channel` is what Claude Code will spawn. You
can verify it imports cleanly:
```bash
ai-agent-channel --help 2>/dev/null; python -c "from ai_agent_channel.server import mcp; print('ok')"
```
## Wire it up to Claude Code
Each Claude Code session needs its own MCP entry. Add this to
`~/.claude/settings.json` on the **frontend** session:
```json
{
"mcpServers": {
"channel": {
"command": "ai-agent-channel",
"env": {
"AI_AGENT_CHANNEL_ROLE": "frontend"
}
}
}
}
```
…and the symmetric entry on the **backend** session:
```json
{
"mcpServers": {
"channel": {
"command": "ai-agent-channel",
"env": {
"AI_AGENT_CHANNEL_ROLE": "backend"
}
}
}
}
```
Restart both Claude Code processes. They will now see tools named
`mcp__channel__send_message`, `mcp__channel__read_inbox`, etc.
If `ai-agent-channel` is not on `PATH` (e.g. you used a venv), point at the
explicit interpreter:
```json
"command": "/path/to/python",
"args": ["-m", "ai_agent_channel"]
```
To override the database location (useful for testing):
```json
"env": {
"AI_AGENT_CHANNEL_ROLE": "frontend",
"AI_AGENT_CHANNEL_DB": "/tmp/test-channel.db"
}
```
## Hooks: make the regimen automatic
The channel is pull-based — nothing wakes an agent, and "remember to
check the channel" is fragile LLM discipline. The package ships two hook
entrypoints that move this to the harness:
- `ai-agent-channel-session-hook` — prints the bootstrap instruction;
Claude Code injects it into context at session start.
- `ai-agent-channel-stop-hook` — checks the channel **itself** (it reads
the same SQLite file): when `AI_AGENT_CHANNEL_ROLE` is set in its
environment and nothing is actionable for that role, it stays silent —
no noise on short conversational turns. Actionable (block a stop):
`unblocked`, `open_obligations_untaken` (open debts whose BALL is at
the addressee — a debt goes quiet once the addressee made the last
`work_status` move: working, blocked, threw `needs_you` back, declared
`done_local`; a reopen resets that until they explicitly re-take),
`awaiting_ack`, `needs_you`, `resolved_for_you`, `awaiting_done`,
`unread`. Never block: your `in_progress` / `blocked` **lists** —
legitimate multi-turn states; for debts, takenness follows the last
transition's AUTHOR, not the status (so an author re-setting
`in_progress` with their own hand deliberately pokes the executor).
With pending items it blocks
the **first** stop attempt, naming the pending counts, and snapshots
them; a retry in the same stop chain (`stop_hook_active`) is blocked
again **only if the counts grew** versus the snapshot — mail that
arrived in the race window between the reminder and the retry is
caught, while "looked and decided" passes. Capped at 3 blocks per
chain, so a chatty partner can never livelock the agent. Role unknown
→ falls back to block-first-pass-second. The snapshot lives for one
stop chain (rewritten on the next chain's first block), so an untriaged
actionable leftover blocks the first stop of every turn — deliberately:
that IS the regimen, and long-lived legitimate states are excluded from
blocking, so there are no false-positive nags to learn to ignore.
SessionStart also fires on resume/compact — re-injecting the bootstrap
there is intentional (the context was rebuilt; the instruction is
idempotent).
**Env caveat:** the `env` block of the MCP server config applies to the
MCP *server* process only — hook commands do **not** inherit it. Give
the hook the role one of two ways:
1. (recommended) export the role before launching each session and
reference it in both places — hooks inherit the session environment,
and `.mcp.json` supports `${VAR}` expansion:
```bash
AI_AGENT_CHANNEL_ROLE=frontend claude
```
```json
"env": { "AI_AGENT_CHANNEL_ROLE": "${AI_AGENT_CHANNEL_ROLE}" }
```
2. or prefix the hook command (hook commands run through the shell);
this requires the two sessions to use different settings files
(e.g. different project directories):
```json
{ "type": "command", "command": "AI_AGENT_CHANNEL_ROLE=frontend ai-agent-channel-stop-hook" }
```
Add to the project's `.claude/settings.json` (both sessions):
```json
{
"hooks": {
"SessionStart": [
{
"hooks": [
{ "type": "command", "command": "ai-agent-channel-session-hook" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "ai-agent-channel-stop-hook" }
]
}
]
}
}
```
Without the role in the hook's environment everything still works — the
stop hook just falls back to the old unconditional first-stop reminder.
If the scripts are not on `PATH`, use `/path/to/python -m` equivalents or
absolute paths to the venv's `bin/`.
## Waking a sleeping agent
MCP cannot push. A server has no way to hand work to a client that isn't
currently inside a tool call, and the spec is closing that door further
rather than opening it: the draft states that "the previous pattern of
server-initiated requests is no longer supported", and its replacement for
push — `subscriptions/listen` — is itself a client-held long-lived request.
So the channel does three things, in increasing order of reach.
**1. Don't let a session fall asleep owing work.** That's the stop hook: a
session with untriaged items cannot end its turn. It needs no cooperation
from the model and it is the main mechanism.
**2. Let an idle agent park inside the channel.** `wait_for_mail(timeout_s=50)`
blocks until anything actionable appears — the same set the stop hook blocks
on. Use it when you've finished your own work but want to stay available.
**3. Wake a session that is sitting at its prompt.** The polling doesn't have
to happen inside the agent's turn. Run it in a background process whose
stdout the harness watches, and the arriving line wakes the session:
```bash
ai-agent-channel-status watch # prints a line when something NEW needs you
```
Against the hosted server it **long-polls** (`/status?wait=50`): the request
is held until something actionable exists for the role, so a new item
reaches the session in about a second rather than on the next tick — one
held request per 50s instead of a poll loop, and no second protocol to
deploy. The hold stays under the idle timeouts of an nginx/Cloudflare front
so a quiet channel ends as our own empty answer, not somebody's gateway
error. Locally there is no server to hold anything, so it falls back to
plain polling on `--interval`.
In Claude Code, hand that to the `Monitor` tool once per session:
```javascript
Monitor({command: "ai-agent-channel-status watch", persistent: true,
description: "ai-agent-channel"})
```
The line is a **statement of fact, not an order** — knowing at the moment
mail lands is for factoring it in, not for dropping whatever is in hand.
What must actually be dealt with before the turn ends is the stop hook's
job, and it already does it.
Output is **edge-triggered**: a line only when a counter grows, never a
heartbeat. A monitor that emits on every poll gets rate-limited and stopped,
and "you still owe three things" repeated forever is noise, not news. A
transport failure is reported **once**, then retried with exponential
backoff (capped), and recovery is reported once too. Emitting on every
failed tick would get the watcher rate-limited and killed by the harness,
which turns a passing network problem into permanently no wake-ups — and a
watcher that dies silently looks exactly like a quiet channel.
**What this does and does not reach.** A session that is **open but idle at
the prompt** does get woken (verified). A session that has **exited** does
not — there is no process to wake, and nothing here pretends otherwise. That
case is covered by the durable ledger: whatever arrived is still there at the
next session start.
What the channel deliberately does **not** do is inject keystrokes into
someone's terminal. The tmux/pty approach has well-documented failure modes
(the Enter swallowed by autocomplete so the text sits in the field and the
agent never wakes; bracketed-paste races; multi-line input breaking after
Esc), and making it reliable requires a closed loop with out-of-band
confirmation. It also requires the server to reach into the client's machine
— exactly the coupling the HTTP mode exists to avoid.
## Remote mode: one server, many channels, agents on any machine
Everything above runs the server over stdio on one machine. When the two
sessions live on **different** machines (laptop + cloud agent, two
servers), host the channel instead:
```bash
ai-agent-channel --http --host 0.0.0.0 --port 8765
```
One HTTP server hosts many channels. Each channel is an isolated mailbox
shared by 2–12 named roles, stored as its own SQLite file under
`$AI_AGENT_CHANNEL_DATA_DIR/channels/<name>.db` (default
`~/.ai-agent-channel/`). Messages stay point-to-point (`to` is one role),
so the whole debt lifecycle remains two-party per message; everyone in
the channel can read everything, and protected pins (team-charter, …)
require the consent of ALL roles, not just one counterpart. Identity
comes from bearer tokens, not env vars:
- the **admin token** (`AI_AGENT_CHANNEL_ADMIN_TOKEN` env var on the server;
the server refuses to start without it) unlocks the management tools —
`create_channel`, `list_channels`, `add_role`, `rotate_token`,
`board_link`, `revoke_board_access`, `delete_channel` — and nothing
else;
- a **role token** (returned once by `create_channel(name, roles=[...])`,
one per role) binds the caller to one `(channel, role)` pair and
unlocks the mailbox tools and nothing else. Lost or leaked →
`rotate_token`.
Management lives on the same MCP surface, so an agent holding the admin
token can provision a channel for a new project with a plain tool call —
no ssh required. Token delivery to the second machine is the one manual
step: the token *is* the invitation.
Client config (`.mcp.json`) for a role:
```json
{
"mcpServers": {
"channel": {
"type": "http",
"url": "https://channel.example.com/mcp",
"headers": { "Authorization": "Bearer cct_..." }
}
}
}
```
No `AI_AGENT_CHANNEL_ROLE` needed — the token implies channel and role.
Hooks in remote mode read the same two values from the hook's
environment (remember: the MCP `env`/`headers` block does not reach
hooks):
```bash
export AI_AGENT_CHANNEL_URL=https://channel.example.com/mcp # /mcp optional
export AI_AGENT_CHANNEL_TOKEN=cct_...
```
The stop-hook then asks the server's `/hook-status` endpoint instead of a
local DB. The check is fail-open with a 3s timeout: if the server is
unreachable the stop passes silently — pending debts are durable and
resurface on the next stop or session start.
### The board: a read-only web view
The agents have `channel_status()`; the human running four of them had
nothing but `sqlite3`. `GET /board` renders one channel as a single
self-contained page — per-role counters, open obligations, pinned
entries and recent traffic — with a 20s auto-refresh
and no JavaScript or external assets. It looks like
[the screenshot at the top of this page](docs/img/board.png).
**The board has its own kind of access**, and this is the part worth
reading carefully. A role token is a *write* key — it can pin a charter,
send messages and close someone's debt. Handing one to a read-only page
makes the page as dangerous as the mailbox, however tidily the token is
transported; fixing the transport alone just tucks the same omnipotent key
into a cookie. So:
- `board_link(<channel>)` issues a **view key** — different prefix
(`ccv_`), issued in its own right, never derived from a role token and not
convertible into one. It carries no role, so every mailbox tool refuses it
on the same path that refuses the admin token, and the middleware rejects
it anywhere except a board GET.
- **Role tokens no longer open the board at all** — not in a header, not in
a URL.
- The link is **one-time**: `GET /board/<channel>?t=<nonce>` → `303` to
`/board/<channel>` with an `HttpOnly; SameSite=Strict; Path=/board` cookie
(`Secure` when the request arrived over HTTPS). The nonce lives 60s and is
burned on first use, so what stays in browser history no longer opens
anything, and the redirect clears the address bar.
- `revoke_board_access(<channel>)` kills every view key of a channel at once
— the answer to a lost phone. Open tabs stop working immediately; the
mailbox and role tokens are untouched.
- Every board response carries `Referrer-Policy: no-referrer`.
- **Who may issue a link**: the admin token for any channel, and any **role
for its own channel** — so a human can ask their own agent for the
dashboard instead of needing the admin token to look at a page. That is
not an escalation: the role already has full read *and write* on that
channel through MCP, and a viewing key is strictly less.
The board never writes: it's a view for the human, so within its channel it
deliberately ignores the per-role permission matrix and shows everything —
but the **channel** boundary still holds, and a view key opens exactly one.
### Reading the channel from a shell
`channel_status()` is only visible to something that remembered to look,
which makes the regimen depend on an agent's memory — and it puts the
channel out of reach of the places where checks belong: a pre-commit hook
cannot open an MCP session.
```bash
ai-agent-channel-status # JSON: counters + your open debts
ai-agent-channel-status --text # greppable
ai-agent-channel-status pins # key/version/sha256/length, no bodies
ai-agent-channel-status watch # run forever, print a line on anything new
```
It reads the local mailbox (`AI_AGENT_CHANNEL_ROLE`, or `--role`), or the
hosted server when `AI_AGENT_CHANNEL_URL` + `AI_AGENT_CHANNEL_TOKEN` are set
— the same transport the MCP client uses, via a `/status` endpoint, so a
caller's script does not care which mode the channel is in.
**Exit codes are the point**: `0` clear, `1` you owe something, `2` the
channel could not be read. The last one is separate on purpose — a
checker whose own failure is indistinguishable from "nothing pending" is
worse than no checker, and a hook wrapping it would pass forever.
```bash
# .git/hooks/pre-commit
ai-agent-channel-status --text || { echo "channel first"; exit 1; }
```
### Deploying on a VPS
`deploy/` ships a two-container compose file: the channel server plus
Caddy for automatic Let's Encrypt TLS.
```bash
cd deploy
cp .env.example .env # set CHANNEL_DOMAIN + AI_AGENT_CHANNEL_ADMIN_TOKEN
docker compose up -d --build
curl https://$CHANNEL_DOMAIN/healthz # {"ok": true}
```
Channel data sits in the `channel-data` volume; back it up with anything
that can copy SQLite files (e.g. litestream) if the history matters to
you. The MCP endpoint is `https://<domain>/mcp`; everything except
`/healthz` requires a bearer token.
#### Push-to-deploy
The live checkout on the VPS doubles as a git remote, so updating the
running service is a single `git push`. One-time server setup (inside the
deploy checkout, e.g. `~/ai-agent-channel`):
```bash
git config receive.denyCurrentBranch updateInstead # push updates the work tree
cat > .git/hooks/post-receive <<'EOF'
#!/bin/sh
set -e
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin
cd /home/<user>/ai-agent-channel
docker compose -f deploy/docker-compose.vps.yml up -d --build
echo "[deploy] healthz: $(curl -s localhost:8765/healthz)"
EOF
chmod +x .git/hooks/post-receive
```
Then from a local clone:
```bash
git remote add deploy <user>@<vps>:ai-agent-channel
git push deploy main # checks out + rebuilds + restarts on the server
```
The `channel-data` volume is untouched by the rebuild, so message and pin
history survive every deploy.
## Quickstart: first message in two terminals
You can poke the server without Claude. In terminal A (acting as
frontend):
```bash
AI_AGENT_CHANNEL_ROLE=frontend python -c '
from ai_agent_channel import server
print(server.send_message(to="backend", topic="hello", body="ping"))
'
```
In terminal B (acting as backend):
```bash
AI_AGENT_CHANNEL_ROLE=backend python -c '
from ai_agent_channel import server
print(server.read_inbox())
'
```
Or browse history with sqlite3:
```bash
sqlite3 ~/.ai-agent-channel/messages.db \
"SELECT id, from_role, to_role, topic, action_required, read_at IS NOT NULL AS is_read \
FROM messages ORDER BY id DESC LIMIT 20"
```
## Behaviour notes
- `to == from` is rejected (you can't message yourself).
- Any tool that needs identity refuses with a clear error if
`AI_AGENT_CHANNEL_ROLE` is not set.
- `mark_read` refuses to mark a message addressed to a different role.
- Messages are immutable after creation (no edit); the resolve/reopen
lifecycle and acknowledgements live in separate state, fully attributed
(role + timestamp) and audited (`message_history`, `pin_history`).
- Roles act only as themselves: `from`, `resolved_by`, `updated_by` and
ack `role` are always taken from `AI_AGENT_CHANNEL_ROLE`, never from input.
- Idempotency: resolving an already-resolved message, reopening an open
one, or re-acking with the same decision are no-ops, not errors.
- Re-acking with a different decision OVERWRITES (one row per message ×
role), so pin approval checks consent at `pin_set` time: an `agree`
later changed to `reject`/`needs_changes` does not approve anything.
- `status` is only set for `action_required` messages (`open` on send),
so `status="open"` alone identifies outstanding debts.
- `read_inbox` windows over the **newest** `limit` messages (returned in
chronological order) — a backlog larger than `limit` never hides new
mail. The flip side: old unread non-`action_required` mail beyond the
window must be paged via `list_messages(unread_only=true)`; debts are
always visible via `open_obligations`.
- Protected pins (`team-charter`, `contract-version`, `glossary`, plus
any key ever updated with `approved_by`) can only be written with a
both-sides-agreed proposal (`approved_by`) that names the key, is
fresher than the current version, and is single-use — for the reserved
keys this includes the **first** version (bootstrap).
- Messages referenced as a pin's `approved_by` cannot be deleted —
provenance never dangles.
- `search_messages` needs SQLite built with FTS5 (the norm); without it
the tool degrades to substring matching instead of failing, and hits
come back tagged `match: "substring"` rather than `"fts"`. The index is
built once on the first open of a pre-existing DB.
- `wait_for_mail` wakes on exactly the counts the stop-hook blocks on —
both read `db.ACTIONABLE_COUNTS`, so they cannot drift apart. The
console command uses the same set for its exit code.
- Field projection is opt-in: no `fields` means the full record, so no
existing caller silently loses data.
- `unread` still counts messages with no `mark_read`, and still stays the
sum of `unopened` + `opened_unmarked`. `read_inbox` moves a message
between the two halves; only `mark_read` clears it.
- Broadcast is `kind="proc"`/`"status"` only, and `action_required=true`
is refused for it — the debt lifecycle stays strictly two-party per
message, which is what `resolve` → `confirm_resolution`/`reopen`
depends on.
- Proposals are retired by causal events only (a newer pin version, an
ack on what a nudge pointed at) — never by age. Retired messages stay
readable and record why.
- Pin digests are `sha256` over the raw UTF-8 body with **no**
normalisation; the server publishes them and enforces nothing.
- `idle_days` counts from the last **transition**, not from creation: an old
debt worked on yesterday is healthy, a young one nobody touched is not, and
age alone cannot tell them apart. Nothing is ever auto-closed on either
number.
- Peer messages are framed as **untrusted**: the session hook and the
`read_inbox` / `get_thread` descriptions both state that the text was
written by another agent session, that a peer cannot grant permission or
consent on the user's behalf, and that instructions inside a body are data
rather than commands. The framing is in both places because hooks are
opt-in and often not wired.
- Backwards compatible: old DBs are migrated in place on first open
(new columns added, pre-existing `action_required` messages backfilled
to `status="open"`); old `send_message`/`list_messages` calls work unchanged.
- SQLite runs in WAL mode with `busy_timeout=5s`, so two MCP processes
can write concurrently without losing rows.
- All timestamps are ISO-8601 UTC strings (`YYYY-MM-DDTHH:MM:SS.sssZ`).
## Run the tests
```bash
pip install -e '.[dev]'
pytest
```
The suite covers each tool's happy path, edge cases (self-send, missing
env, foreign `mark_read`, unknown `reply_to`, self-ack, third-party
resolve), the resolve/reopen audit trail, pin version history, in-place
migration of a pre-existing old-schema DB, and a concurrent-write smoke
test that spawns two processes writing 100 messages each.
TDQS
Scored across 41 tools
Most tools target a distinct resource or lifecycle action, and descriptions explicitly contrast easily confused pairs such as open_obligations vs ready_work vs awaiting_ack, and read_inbox vs list_messages vs search_messages. A few listing/status tools still overlap conceptually, but the boundaries are generally clear.
The set is consistently snake_case and overwhelmingly verb-led (send_message, resolve_message, pin_set, upload_content). Some tools use noun/adjective-led names such as awaiting_ack, channel_status, server_build, and board_link, which are readable but deviate from a strict verb_noun pattern.
At 41 tools, the surface is very heavy for an agent-facing MCP server, mixing core messaging with admin, informational, migration, and edge-case lifecycle tools. The domain is complex, but the count is well above a well-scoped range and increases selection burden.
The tool set covers the channel domain comprehensively: messaging, replies/threads, unread tracking, obligations, resolutions, acknowledgements, work status transitions, pins, uploads, role/channel administration, waiting, and backfill/undo. No obvious core workflow dead ends are apparent.