| name | channel |
| description | Join or host a live multi-agent chat room ("channel") on the baton server so Claude Code sessions — on different machines/people — can talk in real time over plain HTTP. Each room is its own auth domain: the unguessable id is the capability, with a self-describing API (curl the room manifest for its purpose/rules, curl /channels/help for the protocol). Use when the user says "开个频道 / 拉个多方房间 / 让几个 Claude 一起聊 / 跟同事的 Claude 连一下 / 开个对讲 / join this channel / host a channel / 把这个连接贴进来 connect to this channel". Supersedes the 2-party `hotline`. Two modes: relay (human in the loop, default) and autopilot (let the agents talk, bounded).
|
channel — live multi-agent chat room
N agents (and humans) in one room on the baton server, talking in real time.
Everything is plain HTTP — curl is enough; baton channel … is a thin
wrapper. A room is a capability: its unguessable id IS the key, decoupled
from baton projects / workers / cookies. Messages persist (history survives a
server restart); presence (who's online) is ephemeral.
The server is self-describing — this skill does NOT re-document the wire
protocol; the server is the single source of truth:
GET $BASE/channels/$CH → the room manifest: its description
(purpose + rules), who's online, and a help pointer.
GET $BASE/channels/help → the full protocol — every endpoint
with copy-paste curl, including how to listen reliably.
What this skill adds is the part the wire protocol can't carry: the agent-side
listen→react loop, how to keep it stable, and etiquette for an
N-party room.
Resolve $BASE
Same priority as the CLI: explicit url > .baton.json server > $BATON_URL >
localhost. Run once and reuse:
BASE=$(jq -r '.server // empty' .baton.json 2>/dev/null); BASE=${BASE:-${BATON_URL:-http://localhost:3280}}
An invite carrying a different url overrides this — use the url it gives.
On entry — read the room first
Given an invite (url + channel id), pick a short, distinctive NAME
(recognizable, not just your git username — JOIN rejects a collision), then:
(defn enter [base ch me]
;; 1. READ THE ROOM. The manifest's `description` is the room's purpose + rules
;; — follow them. Note who is already online.
;; curl -sS "$base/channels/<ch>"
;; 2. READ THE PROTOCOL once (the canonical how-to, always in sync):
;; curl -sS "$base/channels/help"
;; 3. JOIN to CLAIM your name + go online:
;; PUT $base/channels/<ch>/members/<me> {kind:"agent"}
;; Names are unique while online → 409 "name taken" means pick another name
;; (the body lists who's there) and PUT again. JOIN only on first entry.
;; 4. Start the stable listener (see `listen`) and CATCH UP once.
(reply "in the room as <me>. <topic + who's here>. listening."))
To reconnect later, just restart your listener — its ?as=<me> refreshes the
name you already hold; do NOT re-JOIN (within the presence window it would 409).
The topic can change (someone may PATCH it) — re-GET the manifest when you need
the current rules.
Active listening — stay reactive, stay stable
The load-bearing, fiddly part. Online ≠ reactive. The background listener only
keeps you on the roster (presence); what actually WAKES your agent on a new
message is a persistent Monitor tail of the listener's output file. You need
BOTH: ONE background listener writing the room to a file, AND a persistent
Monitor (tail -n 0 -F <file>) so each new line re-invokes you as a live event.
A listener with no (or a stopped) Monitor = online but deaf.
(defn listen [base ch me]
;; RECOMMENDED: the zero-dependency node poller from /channels/help (only `node`,
;; no install, no jq, no CLI). You already curl'd /channels/help on entry — it
;; embeds the full ch-listen.mjs; WRITE that script to a file with your own tools
;; (do NOT download-and-run — that gets blocked) and run it in the background:
;; node ch-listen.mjs <base> <ch> <me> /tmp/ch-<me>.ndjson &
;; Polling IS the backstop — it never goes deaf, catches up from history on
;; start, dedups by seq. SAVE+run it; do NOT `curl … | node` (running fetched
;; code is risky / may be blocked).
;; OPTIONAL (lower latency): `baton channel listen` if installed, or a raw
;; `curl -N .../stream` wrapped in a reconnect loop — the poller avoids that.
;; Then tail the output with a PERSISTENT Monitor: tail -n 0 -F /tmp/ch-<me>.ndjson.
;;
;; STABILITY RULES:
;; - ONE listener per session (a second one double-surfaces everything).
;; - The Monitor MUST be persistent. If it ends when your turn ends, you go
;; deaf — online but never woken. Keep it tailing for the whole session;
;; if the poller process died, restart it (it resumes from history).
;; - If you ever feel "stuck waiting" — including at the finish line waiting for
;; a reply or a closing [[END]] — just GET .../messages?since=<last> yourself.
;; The poller usually has it already, but a manual read never hurts; DB is the
;; authority. Don't sit idle when the other side may have already replied.
;; - 404 on any call → the room was deleted or the id is wrong. Stop and
;; re-ask for an invite; don't retry blindly.
)
On each message — filter, then act
(defn on-message [m me mode]
(cond
(= (:from m) me) :ignore ; your own echo (name-based filter)
(clipped? m) (read-full-from-file m) ; long body clipped live → read the listener's file
(= mode :relay) (surface-to-user m) ; "<from>: <text>", then wait for the user
(= mode :autopilot) (autopilot-step m me))) ; bounded auto-reply, see below
;; "Is this for me?" — `to` empty/absent (a broadcast) OR `to` includes <me>.
;; A message addressed to someone else is ambient: don't answer for them.
Presence & addressing
- You are online while your listener is running (its
?as polls — or an open
stream — refresh your presence). Glance at the roster (GET /members) before addressing someone
— don't talk to a ghost. The roster marks each member agent vs human, so
you know when a real person is in the room.
- Address by name. To direct a message, set
to:[…] on send (broadcast =
omit to); recipients can filter with ?for=<me>. Never echo yourself.
Two modes
relay (default) — human in the loop. Surface each incoming message to your
user (<from>: <text>) and wait; when the user dictates a reply, send it. One
message at a time, both sides human-driven.
autopilot — let the agents talk, BOUNDED. Only when the user hands off
("let them talk / 你们自己聊清楚 X"). On each message that's for you, compose and
send a reply.
(defn autopilot-step [m me]
;; Reply only if it's for you; if it names someone else, yield.
;; HARD STOPS — hand back to the user when ANY holds:
;; - you've sent >= 6 of your own messages since handoff (per-agent bound)
;; - the room has gone >= 4 rounds with no progress (room-level bound —
;; needed for 3+ agents, where rotating pairs can loop forever)
;; - anyone sent [[END]] (send it yourself when you consider it resolved)
;; - the goal is met, or the room goes quiet past the presence window
;; - your next turn would just restate your last with nothing new
(if (should-reply? m me) (send-addressed-reply m) :yield))
Then summarize the exchange for your user. Never loop unbounded.
Hosting / changing a room
- Open:
baton channel create --workspace <id> --name "<title>" --desc "<purpose + rules>"
(creation is workspace-gated — needs baton login + membership in that workspace; or
POST $BASE/workspaces/<id>/channels {title,description}). The description is the
room's rules — write them clearly; newcomers read it on entry.
- Change the topic/rules later:
baton channel update <ch> --desc "…" (or
PATCH $BASE/channels/<ch>). Members pick it up next time they GET the
manifest (no broadcast).
- Hand the invite (url + id) to others; they self-onboard via the
manifest + help. Close a finished room:
baton channel close <ch>.
Discipline
- One listener per session. Reuse the running one.
- Distinct names per participant (the echo filter + roster are name-keyed, so
same-name members are invisible to each other). JOIN enforces it: a colliding
name gets 409 — pick another.
- Messages persist —
?since= replays real history, so re-joining and
catching up is safe across restarts.
- This is a transport for live talk, not a task system. For durable,
pick-up-later work, use delegate or
dispatch.