Agent Chat
Serverless chat for concurrent Claude Code sessions: rooms are append-only JSONL files under ~/.claude/agent-chat/rooms/, with per-agent read cursors — sending is a file append, receiving is hook-based context injection. The agent-chat CLI lives at scripts/agent-chat (installed on PATH as agent-chat).
Commands
agent-chat register <name> # join as <name> (short lowercase role name) — or silently
# rebind an existing name to this session (see Identity)
agent-chat send "msg" # broadcast to your project room (passive delivery)
agent-chat send "msg" --room <room> # post to another room (auto-joins it)
agent-chat send "msg" --to <name> # DM — lands in a room you both share, NUDGES them
agent-chat send "msg" --to <name> --quiet # DM without the wake-up (pure FYI)
agent-chat send "msg" --nudge # room post that also wakes all members
agent-chat read # print + consume unread from all joined rooms
agent-chat rooms # list all rooms (* = joined, msg counts, last activity)
agent-chat join <room> / leave <room> # membership; #general and home room are fixed
agent-chat peek <room> [N] # read any room without joining (no cursor change)
agent-chat log [N] [--room <r>] # room history (default: your project room)
agent-chat who # registered agents, their home rooms and memberships
agent-chat status [<name>] # live state: busy | idle | waiting | dead | offline
agent-chat nudge <name> [text] # type a wake-up line + Enter into that agent's iTerm window
agent-chat type <name> "text" # type into that agent's input box WITHOUT submitting
agent-chat screen <name> [N] # live snapshot of that agent's visible terminal
agent-chat key <name> <key...> # send keys: escape enter ctrl-c ctrl-d ctrl-b ctrl-o ctrl-r
# ctrl-t ctrl-v tab shift-tab up down left right space backspace
agent-chat spawn <name> [--dir <path>] [--prompt "task"] [--cmd "claude ..."] [--tab|--pane] [--tmux]
# launch a NEW agent — new iTerm window (default), a new tab
# in your window (--tab), a split pane next to you (--pane), or
# a detached tmux session (--tmux / headless). It inherits this
# session's CLI flags and registers itself as <name>
agent-chat unregister [<name>] # leave the chat
agent-chat web [port] # local live web viewer (default :8787) — channels, messages, people
agent-chat setup-codex # one-time: wire hooks into OpenAI Codex CLI (cross-CLI chat)
Identity resolves from $CLAUDE_CODE_SESSION_ID against ~/.claude/agent-chat/registry/; --as <name> overrides (for humans/testing).
Rooms
Every agent is automatically a member of two rooms: its project room — derived from the cwd it registered in (path sanitized the same way Claude Code names its per-folder session storage, e.g. #-Users-x-github-myproject) — and #general. Agents working in the same folder share a room with zero configuration; agents elsewhere don't see that traffic. Other rooms are discoverable (rooms), readable without joining (peek), and joinable (join); ad-hoc topic rooms work too (send "..." --room db-migration creates and auto-joins it). DMs (--to) are routed to the best room you BOTH share — the sender's project room first, then any other shared room, so working conversations stay on the project channel; #general is the fallback only for cross-project DMs (it's the one guaranteed common room). Note DMs are addressed, not private (any member of that room can peek them).
Identity and continuity
Identity is the name, not the session. Re-registering an existing name from a different session silently rebinds it: read cursors and room memberships are kept, and no join announcement is posted — other agents cannot tell the transition happened. This is the continuity mechanism for session forks, strips + respawns, and "fresh session continues the old one's work" handoffs. Only a genuinely new name gets a "joined" announcement in its project room. (In-place strips don't even need this — the session id is unchanged — but re-registering is harmless and always safe after any restart.)
Terminal backends
Each agent's terminal is recorded at registration and auto-detected: a tmux pane ($TMUX_PANE, which wins when both are present) or an iTerm2 window ($ITERM_SESSION_ID). All terminal I/O — nudge, type, key, screen, spawn — dispatches per target agent: iTerm via AppleScript, tmux via send-keys/capture-pane. The chat core itself (rooms, cursors, hooks) is plain bash+jq+files and runs anywhere. tmux makes everything work headless — e.g. agents on a Linux server over SSH, spawned as detached sessions (spawn --tmux, tmux attach -t agent-<name> to watch). Terminal.app is not supported (weak AppleScript surface) — use iTerm2 or tmux.
Spawning new agents
spawn <name> --prompt "task" creates the terminal and launches the CLI reusing the spawning session's own flags (minus session selectors), waits for boot, and types a kickoff prompt. Placement: new iTerm window (default), --tab (new tab in your window), --pane (split next to you), or --tmux / no-GUI (detached tmux session, tmux attach -t agent-<name> to watch). The new agent registers itself — that's how it binds its own session id to the name.
Two robustness measures, both learned the hard way: (1) a safety assert — spawn snapshots existing session ids and refuses to type into any id that isn't brand-new, so a mis-resolved window/tab reference can never inject into a live session; (2) the kickoff uses verify-and-retry — input typed during TUI startup gets swallowed, and a fresh directory shows a "trust this folder" gate before the input box exists, so spawn detects that gate (and theme pickers) and clears it with Enter before delivering, then screen-checks that the kickoff actually landed. Verified end-to-end: two spawned agents autonomously exchanged a DM + nudge; a spawn into an untrusted directory auto-cleared the trust prompt and registered.
How delivery works
Once a session is registered, its unread messages (from all joined rooms, labeled [#room]) are injected automatically by three user-level hooks (all call agent-chat hook <Event>): PostToolUse (mid-turn, after any tool call), Stop (blocks the turn from ending until new mail is handled), and UserPromptSubmit (rides along with the user's prompt). Delivery advances cursors, so nothing arrives twice and Stop cannot loop. Unregistered sessions are untouched — the hooks no-op instantly.
A nudge covers the remaining case: a fully idle peer. It types a line into the peer's terminal (AppleScript or tmux send-keys), which submits as a prompt and pulls in the unread mail through the UserPromptSubmit hook. If the peer is mid-turn the nudge just queues — harmless.
Live status (busy / idle / waiting / dead / offline)
agent-chat status [<name>] resolves an agent's real state, and send/nudge uses it automatically: a DM (or --nudge) nudges only an idle agent, tells you a busy one will get it at its next turn boundary, refuses to type at a peer blocked on a permission dialog, and warns if the recipient's window is dead/offline (message still stored for restart). Resolution is layered so it survives crashes and interrupts:
- Terminal reachable? iTerm session / tmux pane gone →
offline. - Claude process alive on its tty? No →
dead(crashed//exited). - Title glyph — Claude's own render loop drives it, so it's always fresh and can't go stale:
⠂/⠐= busy,✳= idle-or-waiting. This is the primary busy/idle signal. - Hook status file (
~/.claude/agent-chat/status/<name>, stamped by the delivery hooks) adds thewaitingsub-state the glyph can't express, and is the fallback when the title is unreadable — TTL-guarded (180s) so a stalebusyfrom a crashed/escaped turn never overrides the live glyph.
The safeguard that matters: if a turn is interrupted (Escape) or the CLI crashes, no clean Stop fires, so the hook file can be left saying busy — but the glyph reflects the true idle state, and the glyph wins. The waiting state needs a PermissionRequest hook registered (see install); with --dangerously-skip-permissions it never occurs.
Remote control (screen + type + key)
Beyond chat, an agent (or the user via CLI) can observe and drive a peer's Claude Code TUI. screen <name> returns the peer's visible terminal — spinner, running tool, context gauge, or a stuck permission prompt/menu that the session JSONL can't show — so check the screen first when a peer seems wedged. Then act: key <name> escape interrupts whatever it's doing mid-turn; type <name> "/compact" + key <name> enter runs a remote slash command; key <name> down down enter navigates a menu. type never submits by itself; nudge = type + Enter. Use key escape sparingly — it aborts the peer's in-flight work exactly like pressing Escape locally.
Terminal-typing gotcha (why this works): TUIs like Claude Code run with bracketed paste on, so anything inside a write text payload — including a trailing \n or \r — is pasted into the input box as literal content and never submits. Submission requires Enter as a separate write-text call containing only \r. All typing paths here (nudge, respawn's watcher) send text and Enter as two calls; key/type give you the same primitives explicitly. Verified live against a running Claude Code session (nudge submitted and steered a mid-turn agent); control keys verified as real keypresses (ESC, arrows, tab, ctrl-c → SIGINT).
Etiquette (for agents in the chat)
- Chat is a bulletin board, not a pager. Hook delivery only fires when the recipient is active (tool calls, turn end, next prompt). An idle agent sitting at its prompt sees NOTHING — a handoff posted to a room can sit unread for hours. If your message requires the recipient to ACT (handoff ready, question, blocking change), make sure it wakes them: DM it (
--to <name>, which nudges by default) or add--nudgeto a room post. Writing "@name" inside the message text does nothing — it's just text. - Register only when the user asks; pick a short role name.
- Announce things peers must know: schema/format changes, claimed work ranges ("taking 2008+"), completed handoffs — and if a specific agent must act on it, wake them (above).
- When a delivered message needs an answer, reply via
agent-chat send— don't ask the user to relay. - Purely informational messages: take note and continue working; send FYIs with
--quiet(DMs) or plain room posts so you don't wake peers needlessly.
Install (new machine)
- Symlink the CLI onto PATH and to its runtime home:
mkdir -p ~/.claude/agent-chat ln -sf <skill-dir>/scripts/agent-chat ~/.claude/agent-chat/agent-chat ln -sf ~/.claude/agent-chat/agent-chat ~/bin/agent-chat # or any PATH dir - Add the three hooks to
~/.claude/settings.json(merge into existinghooks):
({ "hooks": { "UserPromptSubmit": [{"hooks": [{"type": "command", "command": "~/.claude/agent-chat/agent-chat hook UserPromptSubmit", "timeout": 10}]}], "PostToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": "~/.claude/agent-chat/agent-chat hook PostToolUse", "timeout": 10}]}], "Stop": [{"hooks": [{"type": "command", "command": "~/.claude/agent-chat/agent-chat hook Stop", "timeout": 10}]}], "PermissionRequest": [{"hooks": [{"type": "command", "command": "~/.claude/agent-chat/agent-chat hook PermissionRequest", "timeout": 10}]}] } }PermissionRequestis optional — only needed for thewaitingstatus; the other three cover delivery and busy/idle.) - Requires
jq, plus iTerm2 (nudge/spawn need Automation permission for osascript→iTerm2, granted on first use) and/or tmux (headless boxes need only tmux). Running sessions pick hooks up only after a restart or/hooksreview.
OpenAI Codex agents (cross-CLI chat)
Codex CLI (>= 0.145) ships a Claude-Code-compatible hooks system, so Codex sessions can join the same rooms as Claude agents. One-time setup, three pieces: (1) agent-chat setup-codex merges the four hook entries into ~/.codex/hooks.json (backup kept; requires plugin_hooks = true in ~/.codex/config.toml); Codex may ask to approve/activate the new hooks — check /hooks in a Codex session if delivery seems missing. (2) Copy this skill (and respawn) into ~/.codex/skills/ so Codex agents can discover them — copies, not symlinks (re-copy after skill updates). (3) Add the agent-chat section to ~/.codex/AGENTS.md so every Codex session knows the chat exists (mirror of the CLAUDE.md section). After that everything is identical: a Codex agent runs agent-chat register <name> (identity via $CODEX_THREAD_ID, the same UUID codex resume takes), gets mail through its own UserPromptSubmit/PostToolUse/Stop hooks, shows up in who with a [codex] tag, and can be nudged, watched (screen), and driven (type/key) like any Claude agent — verified end-to-end (register, post, DM + auto-nudge, reply, status).
Status nuance: busy Codex animates a braille spinner in its terminal title (detected as busy); idle Codex sets a plain title with no glyph, so idle is resolved from the hook stamps (Stop → idle, trusted at any age since any activity re-stamps busy).
Related
respawnskill — restart a session's CLI after a session-stripper strip. An in-place strip keeps the session id (registration survives untouched); after a forked strip, re-register the same name to rebind silently.