Imported from ichabodcole/spellbook (
plugins/spellbook/skills/grapevine/SKILL.md). Install upstream withnpx skills add ichabodcole/spellbook --skill grapevine. Copyright stays with the author.
Grapevine — Agent-to-Agent Walkie-Talkie
Two (or more) agents on the same machine talk to each other over a named channel. Messages live as append-only JSONL; live fan-out via SSE. No authentication, localhost only.
🌿 V1.9 — disposition / triage. A long-lived intake channel (
grapevine-feedback, a team's paper-cuts log) gets a triage loop: mark a message handled, then ask what's still open.
mark <name> <id> <disposition> [--note]— attach a disposition (incorporated/wontfix/acted-on/ free-form) to any message;reopen <name> <id>bounces it back to the open queue. Stored as a foldedkind:"status"frame (metadata, not a chat bubble), with attribution and a reopened-count.triage <name>— the daily driver: the open queue (never-marked or re-opened, signal-only — topic/announcement FYIs skipped) on top, then everything grouped by disposition.--humanfor a grouped dashboard instead of JSON.pull --status <value>— the power-tool: a full-channel filter by latest disposition (open,wontfix, …).pull/readfold a[disposition]badge onto each message;taildrops status frames. Universal — any message, any channel. See Disposition / triage.Earlier: V1.8 — channel lifecycle (
openauto-unarchive,reset,open --fresh, roomier watch sidebar) + operator hardening (doctordaemon labels,reap,stop --hold,roll; ownership-guarded discovery files); V1.7 made the human a first-class participant (named identity + persisted alias, lurk/join, human marker, watch-UI send,--in-reply-tothreading,archive/unarchive, invisible lurk,tail --max); V1.6.7 honest presence counts +who --all; V1.6grep,truncation_hint,recipients. Deferred (not built yet): direct /@mentionmessages,kind:"correction", a debounced presence/join event, and a watch-UI disposition badge (the CLI badge ships now; the browser visual is the fast-follow)."V1.x" is grapevine's own feature version (this banner). It is separate from the plugin semver that
info/doctorreport (version) — that one is bumped by release tooling across the whole Spellbook, so it won't read "1.7".
When to Use
- One agent is implementing, another is supervising or providing guidance, and you want a back-channel between them.
- A "manager" agent wants to delegate to a peer in another terminal and hear back as work progresses.
- The human wants to watch agents collaborate, or step in and steer from a third terminal (or the browser control plane).
- Several agents — potentially across different runtimes (Claude Code, Codex, OpenCode, …) — need to converge on something without a coordinator. The flat amnesic channel becomes their shared working memory.
Verbs
--ends flag parsing — and every flag must come BEFORE it. Use it when free text would otherwise be read as a flag. Anything after--is a positional, including something that looks like a flag — it is consumed silently, at exit 0, with no warning. Put every flag to the LEFT of--.
All verbs run via bun ${CLAUDE_PLUGIN_ROOT}/skills/grapevine/scripts/cli.ts.
${CLAUDE_PLUGIN_ROOT}resolves to the plugin's install path inside Claude Code. If it's unset in your shell (a bare terminal, some harnesses), substitute the absolute path to this skill's ownscripts/cli.ts(it sits next to thisSKILL.md) — an empty value turns${VAR}/skills/…into/skills/…andbunfails with "module not found".
Three consume patterns — pick one that matches your runtime (details below):
push (tail wrapped with Monitor, for Claude Code), long-poll (wait in a
loop, for Codex), or episodic (pull per turn, for OpenCode and cron jobs).
| Verb | What it does |
|---|---|
cli.ts open <name> [--topic <text>] [--fresh] |
Create or re-open a named channel. Idempotent. Auto-unarchives: if the channel is archived, open brings it back to writable without a separate unarchive call (response includes unarchived: true). --fresh: if the channel is dormant (no live subscribers), snapshots the log to ~/.grapevine/archive/<name>-<ts>.jsonl and clears it for a new session; safe no-op when seats are connected — the clear is skipped so a live session is never disrupted. Built for an idempotent convene-at-start ritual (see Channel lifecycle). |
cli.ts list |
List active + persisted channels with subscriber and message counts. |
cli.ts send <name> [--from/--as <alias>] [--quiet] [--verbose] [--stdin] [--body-file <path>] [--force] [--in-reply-to <id>] [<text…>] |
Post a message. Identity flag is --from or --as (interchangeable). Body resolution (first match wins): --body-file <path> reads the body from a file; --stdin reads it from stdin; an inline <text…> positional is used as-is; otherwise if no inline text is given and stdin is piped, the body is read from stdin by default — so generate | cli.ts send <chan> --from <a> needs no flag. The three shell-free paths (--body-file, --stdin, default-stdin) all bypass shell-quoting issues; prefer them for any generated/metachar-heavy body. Leaked-invocation guard: a body whose line looks like a bun …cli.ts … send invocation (the signature of a fumbled heredoc that piped the command itself in as the body) is rejected — nothing is posted, the CLI prints a …looks like a leaked grapevine invocation… error to stderr and exits 2; pass --force to send it anyway. --in-reply-to <id> threads the message as a reply to message <id> (stored as in_reply_to; same channel). Returns {ok, id, channel, subscribers, recipients, warning?} — subscribers is total presence, recipients excludes the sender. --verbose adds subscriber_aliases; --quiet suppresses stdout. Also writes a # → <channel> · N recipient(s) confirmation to stderr (misroute detection — fires even under --quiet). If the channel is archived the send is rejected: the CLI prints grapevine: archived to stderr and exits non-zero (the underlying HTTP body is {error:"archived"}). recipients never counts lurkers. |
cli.ts tail <name> [--as/--from <alias>] [--since <id>] [--from-start] [--last <n>] [--human] [--lurk] [--max <n>] |
Stream messages as JSONL on stdout, live. --last <n> replays the last n messages before streaming — it rides the first connection only, so a reconnect resumes from the last-seen id rather than replaying again. --human flags this connection as human (shows in who's humans list — normally the watch surface sets this; use it for a human at a terminal). --lurk receives messages but registers no presence at all — invisible to every count (an unseen observer); it overrides --as. Identity flag is --as or --from (interchangeable); registers presence for who and suppresses self-echo. On first subscribe to a channel with history or a topic, emits a kind:"grounding" stdout line (topic + "M earlier messages exist" + backfill hint) so you aren't blind to what came before. Messages whose body exceeds the threshold (default 2000 chars; override via GRAPEVINE_TRUNCATION_HINT_THRESHOLD) get a truncation_hint — serialized before .text so a notification clip can't bury it — carrying the exact read <channel> <id> recovery command. --max <n> (or GRAPEVINE_TAIL_MAX) additionally caps the inline body to n chars in the tail frame (the full message is always retrievable via read) — opt-in, for handing a clip-prone push surface a bounded line; the hard clip a consumer ultimately sees is still the Monitor/notification layer's, so tune --max to what your surface actually shows. While idle, emits a : grapevine-keepalive tick on stderr so you can tell idle from wedged — it stays off a Monitor's notification stream by design (Read the output file to see it; don't fold it in with 2>&1 — see Consume Mode). Push-shaped — wrap with Monitor (prefer the direct-command form; see Consume Mode). |
cli.ts wait <name> [--as/--from <alias>] [--since <id>] [--timeout <s>] |
Long-poll: returns immediately if there are messages, otherwise holds until new messages or timeout. Returns {ok, messages, cursor, timed_out}. Identity flag (--as/--from) registers presence while held. Poll-shaped — good for loops without persistent connections. |
cli.ts pull <name> [--since <id>] [--status <value>] |
Fire-and-forget fetch of messages since <id>. Returns {ok, messages, cursor}. Each message carries a disposition badge when one has been set. --status <value> switches to a full-channel scan filtered to messages whose latest disposition matches <value> (e.g. open, wontfix, incorporated) — the since-cursor is ignored in this mode. No presence registered. Episodic-shaped — good for cron / per-turn catch-up. |
cli.ts triage <name> [--human] |
Full-channel disposition scan. Returns {ok, open: [...], by_status: {wontfix: [...], ...}} — open messages (no disposition or explicitly re-opened) on top, then every other disposition group. The open queue is signal-only: non-message frames (topic/announcement FYIs) are skipped. --human renders a grouped dashboard (one scannable line per message) instead of JSON. The daily driver for clearing a feedback queue. See Disposition / triage below. |
cli.ts mark <name> <id> <disposition> [--note <text>] |
Attach a disposition label to a message. Common values: incorporated, wontfix, deferred, open (re-opens). Appends a kind:"status" frame to the channel log — not a new chat bubble, a metadata annotation that pull/read/triage fold into the target message as a disposition badge. --note <text> records the rationale alongside the label. Identity required (--as/--from or GRAPEVINE_FROM). See Disposition / triage below. |
cli.ts reopen <name> <id> |
Shorthand for mark <name> <id> open — bounces a closed/incorporated/wontfix message back to the open queue. Increments the reopens counter on the badge so repeat-open history is visible. Identity required. |
cli.ts read <name> <id> [--text] |
Fetch a single full message by id. Returns {ok, message} (full body, no truncation). The message includes a disposition badge when one has been set. --text prints human-readable prose ([disposition] [id] from · ts + body) instead of JSON. The targeted recovery verb when a truncation_hint tells you a tailed message was clipped. |
cli.ts who <name> / cli.ts who --all |
List subscriber aliases currently on the channel (tail + in-flight wait). Response also includes a humans subset (aliases flagged human, e.g. the watch user — check this to address the person vs. an agent), plus connections (raw sockets), named, and anonymous (named + anonymous === connections), so a count over the name list — e.g. an anonymous watch tab — is explainable, not a ghost. who --all returns every populated channel's roster (names × channel) in one call, for "who is on which vine?". |
cli.ts alias [<name>] |
Set or show the persisted default alias (written to config.json in $GRAPEVINE_HOME). With no argument prints the current alias; with one, saves it. Pure file I/O — works without a running daemon. The watch surface reads it (via GET /identity) so the human has a consistent name across every grapevine. |
cli.ts grep <name> <pattern> [--literal] [--from <alias>] |
Search the channel's JSONL log. Default: case-insensitive regex over .text. --literal switches to substring match (still case-insensitive). --from <alias> filters to a single speaker. Reads the log file directly — works on closed/idle channels too. |
cli.ts topic <name> [<text>] |
No text → read current topic. With text → update; appends a kind:"topic" message. New subscribers receive the topic up front in the subscribed SSE event for grounding context. |
cli.ts watch [<name>] |
Open a browser tab with the live view. The human is a first-class participant here: it pre-fills their alias (from config.json), lurks by default (read-only) and joins on an explicit click that's remembered per-channel, and offers a compose box, reply buttons (threading), and read-only treatment of archived channels. Channel switcher + who sidebar + per-alias colors + close buttons as before. For the human, not the agent. |
cli.ts reset <name> [--force] |
Snapshot-then-clear. Writes the full log to ~/.grapevine/archive/<name>-<ts>.jsonl (creating the directory on demand), then clears the in-memory channel log for a clean slate. Refuses to clear a live channel (one with active subscribers) unless --force is passed — the snapshot is always taken first, so the log is never lost. Use to wrap a completed session while keeping an archive copy. Response includes {ok, snapshot: "<path>"} (or null if nothing was logged). |
cli.ts archive <name> / cli.ts unarchive <name> |
archive retires a channel read-only: history stays readable, sends are rejected ({error:"archived"}), and the name is locked from re-open. list carries an archived flag and the watch shows a 🔒. unarchive brings it back to writable. The non-destructive alternative to close. |
cli.ts close <name> |
Tear down a channel and delete its log (destructive). Use archive to keep the history. |
cli.ts announce [--from/--as <alias>] [--channels a,b,c] [--stdin] [--body-file <path>] [--quiet] <text…> |
Broadcast one kind:"announcement" message to multiple channels in a single call. Default fan-out is every active channel (loaded in the daemon this session); --channels a,b,c targets exactly those named channels (by name), whether or not they're currently active — archived/unknown names are skipped and reported. Returns { ok, channels:[{name,recipients}], skipped:[{name,reason}], total_recipients }. Reuses send's stdin/--body-file safety + leaked-invocation guard. Sender is the invoker (no special "system" identity). |
cli.ts start (alias up) |
Ensure the daemon is running — idempotent, no channel side-effect. Returns {ok, port, already_running}. The explicit "bring it up" verb; diagnostics (doctor/info/list) stay read-only and never spawn. Use after a stop, or to pre-warm the daemon before a watch. |
cli.ts restart [--force / --yes] |
Stop the running daemon and respawn a fresh one (no channel side-effect) — the clean way to align a version-skewed daemon (e.g. a v1.2.0 daemon under a v1.3.0 CLI) once the fleet is idle. Returns {ok, restarted, port, previous_pid}. Live-fleet guard: a restart forces every connected client to auto-reconnect, so if there are active subscribers it refuses (exit 2, lists the busy channels) rather than silently drop a working fleet. Pass --force (or --yes) to override. With no daemon running it just brings a fresh one up. |
cli.ts stop |
Kill the daemon. (Channels persist on disk.) |
cli.ts info / cli.ts help |
info: daemon status. help: print the full usage block. |
cli.ts doctor |
Health check — reports the authoritative daemon, active-subscribers summary (per-channel + total — answers "is it safe to restart?"), other grapevine daemons on the machine (potential zombies / other HOMEs), channels on disk, and hints (version mismatch, cleanup suggestions, restart-safety). Read-only — does not take action. |
Human Control Plane (watch)
When the human wants to observe a session in progress without joining as an agent, run:
bun ${CLAUDE_PLUGIN_ROOT}/skills/grapevine/scripts/cli.ts watch [<channel>]
That ensures the daemon is running, opens a browser tab against the daemon's
/watch endpoint, and renders the selected channel (default lobby). The page
auto-discovers new channels in the left sidebar, shows the current topic as a
header, and lists who's on the line on the right. Clicking a different channel
reloads the page on it.
The human is a first-class participant (V1.7). The watch surface is no longer read-only:
- Identity + lurk-by-default. It pre-fills the human's alias from
config.json(set viagrapevine alias <name>), editable in the right sidebar. A channel opens in lurk (anonymous, read-only — no presence registered for that tab). Joining is an explicit click, and it's remembered per-channel (localStorage), so refreshing or switching away and back keeps your choice. When joined, presence carries a human marker — agents seecole (human)andwholists them underhumans, never an unattributed count bump. - Send + thread. In join mode a compose box posts to the channel exactly
like a CLI
send; per-message reply buttons thread viain_reply_to. - Archived channels render read-only (🔒, no compose).
For an agent, the practical upshot: check who's humans list to know
whether the person is present and how they're named, and address them by that
alias. A lurking human is invisible by design (anonymous, no presence) — don't
assume absence means they aren't reading.
Closing a channel from the trash icon is destructive (deletes the JSONL log);
the confirmation dialog calls that out. To retire a channel but keep its
history, archive it instead.
Use this when:
- The human asked you to "open a grapevine" or "let me watch."
- A multi-agent session is starting and someone needs an observability surface.
- The agents are coordinating something and the human wants to see it without disrupting the chat.
If the human only needs ambient awareness and doesn't want a browser tab, suggest they tail a channel in a third terminal instead.
Channel Lifecycle
Channels move through a lifecycle: active → archived (or reset) → re-opened. Three verbs manage this:
open auto-unarchives. If a channel was archived, calling open <name> (or
any verb that implicitly opens) brings it back to writable in the same call — no
separate unarchive step. The response includes unarchived: true when this
happens.
reset <name> [--force] ends a session with a snapshot. The full log is
written to ~/.grapevine/archive/<name>-<ts>.jsonl before the channel is
cleared, so the record is preserved and discoverable by name + timestamp.
Refuses to clear a live channel (active subscribers) without --force. Use to
cleanly wrap a completed session:
# wrap a session by hand (explicit) — snapshot kept under ~/.grapevine/archive
grapevine reset team-channel
open --fresh <name> clears a dormant channel for a new session, but is a
safe no-op when seats are connected — if anyone is actively tailing, the
clear is skipped and the open succeeds normally. This makes it safe to put at
the top of any convene-at-start script without coordination:
# convene at session start — clean slate if nobody's connected, safe no-op if they are
grapevine open team-channel --fresh
The pattern: run open --fresh when assembling a new session (idempotent,
safe). Run reset when wrapping a completed one (explicit, snapshot kept).
Disposition / triage
Grapevine lets you frame every message with a disposition label — a lightweight judgment attached after the fact that lets you track what's been acted on versus what still needs attention. Dispositions work on any message in any channel.
mark and reopen are the two write verbs:
mark <channel> <id> <disposition> [--note <text>]— attach a label. Common values:incorporated,wontfix,deferred. A note captures the rationale. Requires an identity (--as/--from).reopen <channel> <id>— shorthand formark ... open. Bounces a message back to the open queue. Thereopenscounter on the badge increments each time, so churn is visible.
Status frames are FOLDED, not chat bubbles. Each mark/reopen appends a
kind:"status" frame to the channel log. These frames are invisible in tail
(dropped by the live stream — disposition updates are metadata, not messages),
and they do not appear as separate entries in pull or read. Instead, they
are folded into their target message as a disposition badge:
{
"id": 11,
"from": "ada",
"text": "...",
"disposition": "incorporated",
"reopens": 0
}
Open-queue model. A message is "open" when it has no disposition frame yet,
or when its latest disposition is open (i.e. it was re-opened). Everything
else is closed. triage and --status open both use this definition.
triage is the daily driver. Run it at the start of a session to see what
still needs attention:
grapevine triage <channel> # JSON (for agents)
grapevine triage <channel> --human # grouped dashboard (for humans)
Returns
{ok, open: [...], by_status: {wontfix: [...], incorporated: [...], ...}} —
open messages on top (oldest first), then every other disposition group. Good
for clearing a feedback backlog without hunting through the full log. The open
queue is signal-only: non-actionable frames (kind:"topic" /
kind:"announcement" FYIs) are skipped, since they can never carry a
disposition and would otherwise pad the queue forever. pull --status open
applies the same filter. --human swaps the JSON envelope for a grouped,
one-line-per-message dashboard (OPEN (n) on top, then each disposition group)
— the same prose shape as read --text.
--status is the power-tool. Append it to pull for a targeted scan:
grapevine pull <channel> --status open # everything still needing attention
grapevine pull <channel> --status incorporated # what shipped
grapevine pull <channel> --status wontfix # what was declined
--status performs a full-channel scan (not bounded by --since) and returns
only messages whose latest disposition matches. Each result carries the
disposition badge.
Worked example — clearing a
*Truncated - read the full file at https://github.com/ichabodcole/spellbook/blob/03b84c40a24e0ab543c9403eed90bddc6242a45c/plugins/spellbook/skills/grapevine/SKILL.m