The connector turns a real
claudesession into a Cotal mesh peer. Hook source: code.claude.com/docs/en/hooks (Claude Code 2.1.16x, 31 events).
A bundled plugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the mesh tools: messaging, presence, and team supervision.
cotal_orientationis the orient-first entry point: a read-only card of identity, the channels it reads and may post to, capabilities, the tools available to it, present peers, and unread counts. The connectorinstructionstell the agent to call it first.cotal_spawngrows the team. The new teammate joins as a lateral peer. A taken name auto-numbers (reviewer→reviewer-2), so you never collide; the requested name still picks the persona file.cotal_despawntears one down. It leaves the mesh and its process or tab closes.cotal_personadefines a persona on the fly. It is saved as config and becomes spawnable.cotal_feedback(optional) sends beta reports.
In auth mode cotal_spawn and cotal_persona are injected only for personas declaring
capabilities: [spawn] — the same grant that opens the privileged ctl.manager subject — so an
agent's toolset matches what it can actually invoke (cotal_despawn stays; its no-name
self-despawn is granted to all). Open mode mints no creds, so the surface is permissive there.
Clearing retained history is operator-only (cotal history clear), not an agent tool. The
manager spawns the session in a PTY. Nothing wraps Claude; it is an ordinary session that
happens to be on the mesh.
The mesh runtime (agent,
cotal_*tools, hook relay) lives in@cotal-ai/connector-core. This package is the Claude-specific adapter over it. Its sibling is@cotal-ai/connector-opencode, a native plugin.
One command, from inside a cmux pane:
cotal go
It does the whole onboarding:
- installs the cotal plugin if needed (
cotal setup, so the repo's Claude sessions get thecotal_*tools), - brings up the mesh (
cotal up --open), - opens the manager in its own
cotal-managertab, and - opens a
cotal-<s>workspace with the live console plus a ready driving session.
Sessions auto-accept Claude's one-time dev-channels prompt (an Enter sent to their own cmux
surface), so they join the mesh without a keypress. Switch to that pane and use
cotal_persona to mint a teammate, cotal_spawn to bring it online, and cotal_despawn to
tear it down. Re-running it is idempotent.
Under the hood it is the existing pieces, so you can also run them by hand:
cotal setup(one-time plugin install)cotal up --opencotal supervise --runtime cmux --space <s>(the manager daemon, each teammate in its own cmux tab; drop--runtimefor the default pty runtime)cotal spawn <name> --space <s>(a foreground Claude on the mesh; a bare name with no agent file launches a personaless session)
Spawning from anywhere. cotal up records each running mesh in a machine-local registry
(~/.cotal/meshes/<space>.json — the broker URL, the project root that holds its creds and
personas, and its mode), so a bare cotal spawn <persona> from any directory joins the running
mesh with the right credentials and persona instead of mistaking ~/.cotal for a space. One mesh
up → used automatically; inside a project with its own .cotal/, that project wins. With several
up, pick one with --space <name> or set a default with cotal use <name>; cotal meshes lists
them (a * marks the default). cotal down removes the entry. The registry stores a path, never
a secret — trust material stays in each project's .cotal/auth. If the mesh is down or won't take
your creds, spawn fails with one sentence, never a raw NATS trace.
cmux is opt-in: the cotal binary registers it, and a build without import "@cotal-ai/cmux"
has no cmux runtime. To ship to others instead, the plugin path is the same cotal setup
install.
An agent's identity and persona can live in a local file instead of being passed flag-by-flag. It is a Markdown file with YAML-ish frontmatter, the same shape Claude Code uses for subagents:
.cotal/agents/<name>.md
---
name: dave # → COTAL_NAME / card.name
role: builder # → COTAL_ROLE / card.role (presence + anycast)
description: … # → card.description (A2A-style)
tags: [edit, test] # → card.tags ("what it can do")
subscribe: [general, team.backend] # → COTAL_SUBSCRIBE; channels it reads at boot (hierarchical)
allowSubscribe: [general, team.>] # read ACL: channels it MAY read (omit = same as subscribe)
allowPublish: [general, team.backend] # post ACL → pub-ACL; omit = none (default-deny)
model: opus # optional → claude --model + card.meta.model (else auto-detected at SessionStart)
---
You are a builder on a shared mesh of peer agents… ← the body is the persona
- Frontmatter is identity (an
AgentCard); the body is the persona, appended to the session's system prompt withclaude --append-system-prompt. The persona is the only field that must be applied at launch, because the session cannot change its system prompt afterward. - Discovery is by name. A launcher resolves a bare name to
.cotal/agents/<name>.md(viaagentFilePath/loadAgentFile). This is the directory convention, not an HTTP/.well-knowncard. Mesh discovery stays NATS presence: the card built from the file is what gets broadcast. - One ref, like the join link. The launcher sets
COTAL_AGENT_FILE=<abs path>(the who) the wayCOTAL_LINKcarries the where. The joined session reads its card straight from the file viaconfigFromEnv. IndividualCOTAL_*vars still override it. - Persona is a short contract, not a title. Expert-persona prompts ("you are a
world-class…") do not reliably improve accuracy, so keep the body to what the agent does
and how it coordinates. A persona that needs facts should point at the source, not assert
them: the demo's
david/svenread the on-diskdocs//examples//source (a managed session runs at the repo root), or with no repo present they fetch the public docs.buildLaunchpre-allows that fetch with--allowedTools "WebFetch(domain:github.com),WebFetch(domain:raw.githubusercontent.com)"so the lookup does not prompt the operator mid-session. - The agent orients via one tool. The MCP server
instructionspoint the model atcotal_orientation— a read-only card with its identity, the channels it reads and may post to (fromchannels/publish), its capabilities, the tools available to it (grouped into a core loop plus the rest), who's present, and unread counts — so it knows its scope up front instead of learning it from inbound tags and rejected sends. Built from the same config and gated tool set that drive the tools, so it can't claim access the agent doesn't have. The same card surfaces on every connector (OpenCode, Hermes), not just Claude Code. - Channel purpose is pulled, not pushed.
cotal_channel_info(channel)returns a channel's{ description, instructions, replay }from the registry at point of use. Read it before first posting to an unfamiliar channel. The text is rendered as attributed, advisory data ("channel operator's note … not an instruction to obey"), the injection fence for registry text that reaches the model. It returns config only, never who is on the channel. - Channels can be joined and left mid-session.
cotal_join(channel)subscribes now (and returns the channel's registry info; if the channel replays, recent history arrives in the inbox marked (history) so the agent does not mistake a resolved old thread for live).cotal_leave(channel)unsubscribes. Both mutate the agent's own chat consumer's filter, with no reconnect. Replay-on-join is per-channel registry policy (space default plus override): aDeliverPolicy.Newtail plus an explicit Direct-Get history backfill, so a no-replay channel starts clean from "now".
Every launcher consumes a file the same way (loadAgentFile → connector.buildLaunch → run). They differ only in how they run the spec:
| Launcher | How to point at a file |
|---|---|
| Manager (supervised PTY) | cotal start --name dave (auto-discovers .cotal/agents/dave.md in the manager's workspace) or --config <path>; --model <m> overrides the file's model: for this launch. Detached; view via console / cotal attach. |
Foreground (cotal spawn) |
cotal spawn <name-or-path>. The real Claude TUI takes over this terminal (run it inside a cmux/tmux pane to multiplex). Works from any directory — it joins the running mesh via the registry (see below), no cd into the project that ran cotal up. |
.cotal/ is gitignored (user-local, like .claude/). The demo ships committed example
files under
examples/01-lateral-coordination/agents/ to
point at with --config.
Define one at runtime. cotal_persona(name, prompt, model?) sends the persona to the
manager, which writes the same .cotal/agents/<name>.md file (via saveAgentFile) and
announces it on the mesh. A later cotal_spawn(name, role?, agent?, model?) auto-discovers it, so
a peer can mint a teammate's persona on the fly and bring it online with no hand-written file. The
agent spawn door carries the same knobs as the operator's cotal start — role, agent (harness)
and model (overrides the persona file's model:) — set at spawn because they are policy, not
persona content.
Resume an existing session (fork, never hijack). --resume <session-id> pulls an existing
Claude session — its deep context and long transcript — into the mesh instead of starting fresh
(issue #23). It forks: Claude mints a new
session id from that transcript (claude --resume <id> --fork-session) so the meshed agent gets its
own session and the original is left untouched — resuming never takes over the source session. The
session id is machine-local state, not a portable value: it only means something on the host
whose ~/.claude holds that transcript.
-
cotal spawn --resume <id>is the primary, least-surprising surface — it runs as you, in your cwd, on your machine, so the transcript is right there. Combines with--promptand a persona (the forked context runs under the current mesh persona). Because it launches in your own terminal (inherited stdio), a bad id / missing session / old-claudeerror is Claude's own stderr right in front of you and the command exits non-zero — never flattened into a generic "spawn failed". -
cotal start --resume <id>works too, but the manager is detached, so two things differ. Locality: the id resolves against the manager host's~/.claude, and you practically need--cwdto point at the original project directory. Failure reporting: the manager can't inherit your terminal, so it waits for a real outcome before replying — not a timer — resolving on whichever comes first:- the agent joins the mesh (registers presence under its assigned id) →
✓ started. That means it launched, authenticated, connected, and joined — not that it's fully healthy (the manager owns mesh lifecycle, not app self-test). - the child exits →
✗ … exited on launch — <Claude's last output>(a missing id, an old CLI rejecting--fork-session), and its minted footprint (creds + durables) is torn down, not orphaned. - neither within a generous backstop (~30 s) →
✗ … launch status uncertain — may still be booting or stuck before connector startup. Uncertain does not tear the agent down (it may still come up); inspect withcotal attach/cotal ps, or stop it to clean up. A launch that joins and then dies is reaped by the manager's exit watch (freed + deprovisioned).
So
✓ startedmeans it joined, never just "a process launched". Prefer foregroundspawnwhen you can (its errors are fully inline). (The unsupported-connector case fails earlier still —cotal start --agent opencode --resume …is rejected before any launch or mint.) - the agent joins the mesh (registers presence under its assigned id) →
-
Resume is an operator surface only — it is deliberately not exposed on MCP
cotal_spawn. Forking a host-local~/.claudetranscript is operator-local intent; letting a spawn-capable mesh peer name a host session id would widen thespawncapability into host-transcript disclosure with no broker-enforced boundary. A peer-facing, capability-gated resume is deferred (#159). -
Only the claude connector supports resume today; opencode and hermes fail loud rather than silently spawning fresh (opencode: "resuming an existing session … is not implemented", tracked in #154; hermes: "does not support resuming an existing session").
-
Needs a
claudenew enough to know--resume ... --fork-session(verified on 2.1.197). Cotal only co-emits the flags; it can't force an old binary to honor them — an oldclauderejects--fork-sessionwith its own error (surfaced inline on foregroundspawn; on detachedstart, the child exits, sostartreports the failure at the call site, per the readiness outcomes above).
Manage the catalog from the CLI. cotal personas is the operator-side counterpart to the
runtime cotal_persona tool. It reads and writes the same .cotal/agents/*.md files
directly — instant, offline, no mesh — where the tool path goes over the wire with the
manager's ownership checks; two trust contexts, kept separate. cotal personas (or list)
shows the catalog — --running adds a live ● marker for personas an agent of that name is
currently running, an explicit overlay that connects and fails loud if the mesh is
unreachable. show <name> prints a card, edit <name> opens one in $EDITOR and re-validates
on save (a save that breaks the frontmatter fails loud, never ships a bad card), new <name> (--prompt <text> | --from <file|->) [--role <r>] [--model <m>] [--force] writes one, and rm <name> --force deletes it.
Tab completion. cotal completion <bash|zsh|fish|powershell> prints a shell stub to
stdout for a manual or one-shot source; cotal completion install [shell] installs it
persistently — it caches the stub (~/.config/cotal/completion.<shell>, or fish's completions
dir) and sources that from your shell rc (auto-detects $SHELL, idempotent, opt-in — never run
by setup). Each <TAB> then forwards
to a hidden cotal __complete, so completion sees real data: cotal spawn <TAB> lists the
personas of the mesh that spawn would join (resolved offline from the registry, not the cwd's
catalog), cotal spawn --space <TAB> the running meshes, cotal send msg <TAB> the channels your
agent files declare, cotal send ask <TAB> the declared roles. By contract __complete reads only local files — never the mesh — so a
keystroke never blocks on the network, and there is no fallback: a completer that can't
produce its authoritative answer (e.g. a malformed agent file) fails the process (nothing
emitted, non-zero exit) rather than offer a silently-partial set, with the broken file named
loudly by cotal personas list (set COTAL_COMPLETE_DEBUG to see why on stderr). Enable it
for the current shell with source <(cotal completion zsh) (fish: cotal completion fish | source; PowerShell: cotal completion powershell | Out-String | Invoke-Expression). A command
provides its candidates through the optional complete() on the
Command contract, the same way it owns run().
A single join link carries server, auth, and space, so a peer joins by pasting one string instead of setting several env vars:
cotals://<token>@host:4222/<space>?channel=general # cotals:// = TLS, cotal:// = plaintext
- Humans:
cotal join --link cotals://…(name defaults to the OS user). - Agents:
COTAL_LINK=cotals://… claude …. The connector expands it into space, servers, and token and auto-joins; settingCOTAL_LINKalone satisfieshasIdentity(). IndividualCOTAL_*vars (andCOTAL_TOKEN/COTAL_TLS=1) still override the link.
The nats.js client does not read credentials from a URL, so the link is ours: we parse
it (link.ts) and pass token / user+pass / tls as
explicit connect() options. This is the cotal up --open dev path, where isolation is
soft (one shared token, spaces separated only by the cotal.<space>.* subject prefix).
The default (cotal up) makes the account a real boundary: the connector threads a
minted creds file via COTAL_CREDS and the agent authenticates as its own JWT identity. See
architecture.md → Identity & authorization.
extensions/connector-claude-code/src/extension.ts
builds the launch the manager runs:
claude --strict-mcp-config --mcp-config '{"mcpServers":{"cotal":{"command":"node","args":["<plugin>/dist/mcp.cjs"]}}}' \
--dangerously-load-development-channels server:cotal
# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_SERVERS, COTAL_CHANNEL=1
- MCP isolation. By default a spawned agent runs with only the cotal MCP server.
--strict-mcp-configignores every other MCP source, crucially the operator's personal~/.claude.jsonservers. A meshed teammate needs none of them, and several spawns each booting a Chromium/DB/etc. helper would starve memory and kill the sessions before they register presence.--mcp-configre-supplies cotal (plus any servers the operator opted to share — see Sharing personal MCP servers). Because the plugin's own MCP server is suppressed, the channel ref is the manually-configured server taggedserver:cotal(notplugin:cotal@cotal-mesh); the plugin stays installed for its hooks (message delivery), independent of the wake nudge. - Installed, not
--plugin-dir. The plugin is installed once (claude plugin install cotal@cotal-mesh --scope local). Its hooks bind only to an installed plugin, so--plugin-dir(which loads but does not "install") is not enough. Local scope keeps it to this repo (a gitignored.claude/settings.local.json), never user-global. In a clone, the marketplace is the repo root's.claude-plugin/marketplace.json.cotal setup(npx, no clone) materializes the same marketplace at~/.cotal/claude-plugin/from the published package's plugin assets and installs from there. The marketplace name iscotal-meshin both, and the channel ref depends on it.cotal setupis two-tier: the first run (no~/.cotal/onboarded.jsonmarker) does this install as a narrated step; later runs just verify it in the compact status. The plugin install is local-scope, so the two keys it writes land in the working dir's.claude/settings.local.json—enabledPlugins("cotal@cotal-mesh": true, turns the plugin on) andextraKnownMarketplaces("cotal-mesh", points at the materialized marketplace dir:~/.cotal/claude-plugin/aftercotal setup, the repo root in a clone); the marketplace pointer may instead sit in user-global~/.claude/settings.json. See setup-internals.md for the full flow and the invariants that keep this install working. - Bundled. The MCP server and hooks are esbuild-bundled to
dist/*.cjsand run with plainnode(pnpm --filter @cotal-ai/connector-claude-code bundle). The.mcp.jsonandhooks.jsonpoint at the bundles. Bundling is required because pnpm's symlinkednode_modulesdo not survive Claude's copy-install. - Identity-gated. Connector code requires
COTAL_NAMEorCOTAL_LINK(hasIdentity()inconfig.ts). A plainclaudewith noCOTAL_*env stays inert and never joins, so an operator's own sessions in the repo do not appear as stray peers. - Hands-free spawn. The dev-channels flag prints a one-time "Enter to confirm" prompt.
The PTY runtime auto-clears it via
LaunchSpec.confirm, so a supervised launch needs no keypress.
Isolation is the default, but sometimes a meshed teammate genuinely needs one of your own tools (say web search). The cotal config file is the opt-in. It's per-connector — MCP passthrough isn't a portable agent concept (OpenCode inherits the operator's servers via its merge layer; Hermes has no MCP), so it lives in connector settings, not the agent file.
- Where it lives. Two layers, merged by server name (more specific wins):
~/.config/cotal/config.json(or$XDG_CONFIG_HOME/cotal/config.json) as the operator-level base, and a space-local.cotal/config.jsonon top. Personal servers are an operator concern, so the global file is the usual home; the space file is for project-specific overrides. - How servers are written. Each entry is the de-facto
.mcp.jsonshape, so you can copy one straight out of your own Claude / VS Code / Cursor config. Spelled out in full (command/args/env) — not referenced by name — because the heaviest servers (e.g. a plugin/npx-sourced Chromium) are invisible to a by-name lookup of~/.claude.json. - Secrets stay references. Write keys as
${VAR}/${VAR:-default}, never literals — the config file is safe to keep in~/.configor a gitignored.cotal/. At launch the connector forwards only the named vars the chosen servers declare (from the operator's env, by name — never the whole environment, preserving the P3 env allow-list) and passes the merged config as an owner-only file in a private temp dir (0600/0700on POSIX; on Windows — where those modes are a no-op — the file and dir are ACL-hardened viaicaclsto the current user + SYSTEM + Admins); Claude expands the references from that env at launch, so the secret never lands on disk or the command line.--strict-mcp-configstays on, so only cotal + the explicitly-shared servers ever load. (Operator config lives at$XDG_CONFIG_HOME/cotal/config.json, else~/.config/cotal/on POSIX /%APPDATA%\Cotal\on Windows; machine state under~/.cotal/%LOCALAPPDATA%\Cotal.) - Sharing a server grants its credential to the agent. The forwarded var lives in the Claude
process's environment (that's how Claude expands the
${VAR}and the server reads it), so an agent with shell/tool access can read it directly — not only through the server's tools. Keeping it off disk/argv is about the host's exposure, not the agent's: share a server only when you're fine with that teammate holding the key. - Per-spawn override.
cotal spawn <name> --share-tools tavily,figmashares only those (they must be declared);--share-tools noneshares nothing. Absent, all declared servers are shared. Manager-spawned agents (cotal start) use the config as-is. Default — no config file — is unchanged: a spawned agent gets only cotal. - Mind the memory. Sharing re-opens the cost isolation guards: a heavy server booted once per spawn multiplies across a team. Share lean servers; keep the Chromium-class ones out.
Peer messages land in the connector's inbox from its durable JetStream consumers (per the DM / chat / task streams in architecture), so a message sent while the agent is busy or offline waits on the stream instead of being lost.
Two things move a message from the inbox to the model. One delivers, one only wakes:
- Hook drain (delivery).
SessionStart/UserPromptSubmitdrain the inbox, inject the messages asadditionalContext, and ack them on the stream. This is the single authoritative path: gating-free, and it works on any Claude Code build. A message is acked only here, once actually surfaced, so a crash before injection redelivers it. - Channel nudge (wake). An arriving message fires a
notifications/claude/channelnudge that wakes an idle session into a turn, so the hook drain runs now instead of at the next prompt. The nudge never acks or removes anything, so if the channel cannot run, delivery still happens at the next turn. It takes three things together: the plugin's MCP declares theclaude/channelcapability, the session is launched with--dangerously-load-development-channels server:cotal(research preview;server:<name>because cotal is supplied via--mcp-configunder MCP isolation, and aplugin:…@…ref would point at the strict-suppressed plugin server), andCOTAL_CHANNEL=1. The last one matters because Claude does not echoclaude/channelback in its MCP client capabilities, so the connector would auto-detect the channel as off and never send the nudge. The env flag forces it on.
Two priority tiers. Not every message should interrupt. A directed message (a DM, an
anycast, or a channel message that mentions us by name) always nudges, so the addressee
sees it promptly. Ambient channel chatter (not addressed to us) does not nudge while we
are mid-turn (working). It accumulates in the inbox, and the Stop→idle transition fires
one batch nudge so the whole backlog is drained together on the next turn. An addressed peer
is woken now; a busy peer reading along is left alone until it finishes. Stop only wakes
(it cannot inject context itself), so the hook drain stays the sole ack site and nothing is
lost. mentionsMe is computed once on receipt and surfaced as a mentioned="true" tag
attribute.
An agent picks how aggressively peer traffic reaches it via cotal_status({ attention }).
Three modes, orthogonal to presence (idle/working/… are unchanged):
- open (default): receive everything. Ambient wakes you when idle, holds while you are working.
- dnd: ambient never wakes you, but still arrives in the next turn's context.
- focus: only subject-directed dm/anycast reach context. Channel ambient and
@mentionsare acked-and-dropped at ingest. An@mentionstill wakes you to pull, but its body is not auto-injected, so a forged mention can cost you at most a wake. Pull the held chatter withcotal_inbox.
What each arrival does, by mode:
| arrival | open | dnd | focus |
|---|---|---|---|
| subject-directed (dm/anycast) | buffer + wake + inject | buffer + wake + inject | buffer + wake + inject |
channel @-mention |
buffer + wake + inject | buffer + wake + inject | ack-drop; wake to pull; not injected |
| ambient (channel, no mention) | buffer; wake unless working, hold while working; inject next turn | buffer; never wake; inject next turn | ack-drop; no wake; recall via cotal_inbox |
"Subject-directed" means a dm or anycast. Its class comes from the delivering subject,
not the forgeable payload (see
architecture). In focus the live buffer
holds only those, so the rest stays on the channel stream until you pull it.
cotal_inbox changes meaning in focus. Since the live buffer holds only directed items,
cotal_inbox additionally pulls back the channel ambient since you entered focus. This is a
replay-gated read of the channel stream (a replay=off channel yields nothing; focus is
not a history bypass), with a never-silent marker when older chatter may have aged out of
the per-channel window (it only fires once a channel has actually hit its retention cap).
Advisory, not a boundary. Attention is UX, not a security or cost control. @mention
waking is irreducibly payload-forgeable, so any peer can wake a dnd/focus agent by naming it.
Focus's real effect is reducing the untrusted-ambient prompt-injection surface, since only
subject-authenticated dm/anycast auto-inject. It does not eliminate it. Focus resets to
open on SessionStart (fail-open, so a restarted agent never stays silently deaf).
Attention can also be set per channel, overriding the global mode for one channel — "which
channels I receive from, and which stay quiet." Set it with cotal_channel_mode({ channel, mode });
see all your channels and their modes at a glance in cotal_channels.
- quiet: still delivered and buffered (read it on your terms, or with
cotal_inbox), but it never wakes you. An@mentionon a quiet channel still wakes you. (Per-channeldnd.) - muted: you stop receiving the channel entirely — its ambient and
@mentionsare dropped on receive. DMs and anycast are not channel-scoped, so they still reach you. - normal (default): the channel follows your global attention mode.
A per-channel override is the final word for that channel. Precedence, per message: a DM/anycast
always buffers + wakes (channel rules never apply); otherwise a per-channel muted drops, a
per-channel quiet buffers without an ambient wake, and a channel with no override falls through to
your global mode. So quiet buffers even under global focus ("retain this channel, just don't
wake me"), and muted drops even under global open.
| arrival | quiet channel |
muted channel |
|---|---|---|
channel @-mention |
buffer + wake + inject | dropped; no wake |
| ambient (no mention) | buffer; never wake; inject next turn | dropped; no wake |
| dm / anycast | not channel-scoped — always buffer + wake + inject | same |
Two layers. An operator sets a one-way default in the agent-file frontmatter (quiet: [..] /
muted: [..] — concrete channels within your read ACL, allowSubscribe). Because the file is a shared template (many
instances can wear one persona), the runtime never writes back to it: a live cotal_channel_mode
change is per-instance and resets on restart (boot re-seeds from the file).
Visible to peers (advisory). Your attention — the global mode and per-channel overrides — is
published in your presence record, so peers see it in cotal_roster (e.g. "locally muted #deploys —
DM to reach"). It is advisory, not access control: muted means you opted out of receiving a
channel, not that the channel is blocked — the broker still authorises and could deliver it, and
a successful send never implies you read it. muted ambient is not locally recallable (that is
the "don't receive" contract; for "read later, no wake" use quiet).
The connector wires a small subset of Claude Code hooks to Cotal presence states (idle /
waiting / working / offline). Presence is coarse: only hooks that cross a state
boundary move it. "What it is doing" rides on channel updates, not presence.
| Hook | → state |
|---|---|
SessionStart |
idle (join; drains the inbox; also captures the session's live model into meta.model when the operator didn't pin one) |
UserPromptSubmit |
working (turn starts; drains the inbox) |
PreToolUse |
no state change; records what the turn is about to run, so a following permission Notification can name it |
Notification (permission_prompt / elicitation_dialog) |
waiting (blocked on a human) |
Stop |
idle (turn done) |
StopFailure |
idle (turn died on an API error, so Stop will not fire) |
SessionEnd |
offline (graceful leave) |
SessionStart is also the one hook whose payload carries the session's model (a model id like
claude-opus-4-8; absent after /clear or conversation recovery). When the operator pinned no model
(model: / COTAL_MODEL), the connector mirrors it into the card's display-only meta.model so the
roster shows the live model. A mid-session /model switch fires no hook, so the value holds until the
next (re)start; an explicit pin always wins over the reported value.
The waiting activity says what the session is blocked on. For a tool-permission prompt
it leads with the pending PreToolUse, for example Bash: git push --force origin main, so a
one-line card preview stays informative (the waiting status plus the web dashboard's Agent
Detail "BLOCKED ON" label convey the why). Otherwise (idle-input or elicitation, no tool) it
falls back to Notification.message. The pending tool is cleared on turn start and end, so an
idle-input wait never inherits a stale command.
Wired in hooks.json, relayed over
the connector's authenticated control endpoint
(connector-core/src/control.ts), and mapped
to presence by the Claude handle in
mcp.ts. The endpoint is a per-user socket on
POSIX and a named pipe on Windows; its path + a random per-launch token ride the child env
(COTAL_CONTROL_SOCKET/COTAL_CONTROL_TOKEN), and every frame's token is checked (constant-time)
before any handler runs — so a local process that finds the path still can't drive presence, inject
messages, or stop the agent. The manager also uses it to send a cooperative {op:"shutdown"} on a
graceful stop where the runtime can't deliver a signal (Windows ConPTY), so the agent leaves the
mesh cleanly instead of being hard-killed.
A managed session mirrors its own transcript onto a per-agent channel, tr-<name>, so
peers (and cheap observer agents) can read what the agent actually did, not only what it
chose to narrate. The hooks already deliver transcript_path (the session's JSONL) on every
event. On each turn boundary and tool boundary the connector tails it from the last offset and
condenses each entry to its observable surface: assistant text in full, tool calls as ⚒ Tool: <salient input> one-liners, tool results truncated, thinking omitted. It multicasts the
batch (transcript.ts).
Gated by COTAL_TRANSCRIPT: buildLaunch sets it for managed sessions, so a personal session
with the plugin installed never mirrors. Mirroring starts at the file's current end on adopt,
so a resumed session does not rebroadcast history. A tr- channel is a regular chat channel:
durable on the chat stream (a rolling window, since the chat stream keeps the last 1000
messages per subject, so a long session's earliest entries age out), listed by
cotal_channels, and readable on demand via cotal_join (backfill) plus cotal_leave. An
observer should not stay subscribed unless it wants waking on every flush. In auth mode the
launcher must provision publish rights for tr-<name> (export transcriptChannel())
alongside the agent's chat channels, or every flush is rejected.
cotal_feedback works out of the box. Without a key it posts to the public intake at
https://cotal.ai/v1/feedback, which requires a contact email (sourced from
COTAL_FEEDBACK_EMAIL, then git config user.email, otherwise the agent asks the user). The
CLI can send too: cotal feedback "<summary>" [--type bug] [--email you@example.com].
Set this in a beta tester's agent environment to route to the keyed intake instead:
COTAL_FEEDBACK_KEY=fbk_<per-tester-key>
With a key the tool posts to https://broker.cotal.ai/v1/feedback with Authorization: Bearer .... The server derives tester identity from the key, not from the model-supplied
body, so no email is needed. COTAL_FEEDBACK_URL overrides either URL (self-hosted intakes).
Each submission has origin: "human" | "agent": human means the tester asked the agent to
send feedback; agent means the agent independently hit a major Cotal issue and auto-reported
it.
Run the intake server behind HTTPS (for example Caddy):
pnpm cotal up --space beta-feedback
mkdir -p .cotal/agents
Create .cotal/agents/feedback-intake.md before minting so the creds can publish to
#feedback:
---
name: feedback-intake
kind: endpoint
role: feedback
subscribe: [feedback]
allowPublish: [feedback]
---
Authenticated beta feedback intake.Then mint and run:
pnpm cotal mint feedback-intake --profile agent --out .cotal/auth/creds/feedback-intake.creds
pnpm cotal feedback \
--keys .cotal/feedback/keys.json \
--creds .cotal/auth/creds/feedback-intake.creds \
--space beta-feedback \
--channel feedback \
--port 8787
Key file format:
{
"keys": [
{ "key": "fbk_alice_secret", "tester": "alice", "name": "Alice Example" }
]
}The intake writes .cotal/feedback/feedback.jsonl first, then publishes an attributed,
untrusted summary into #feedback. Use the JSONL file as the source of truth; Cotal is the
live triage stream.
To read submissions yourself:
pnpm cotal mint feedback-observer --profile observer --out .cotal/auth/creds/feedback-observer.creds
pnpm cotal console --plain --space beta-feedback --creds .cotal/auth/creds/feedback-observer.creds
For the browser dashboard, run pnpm cotal web --space beta-feedback --port 8788 --no-open on
the server and tunnel the port. For raw storage, inspect .cotal/feedback/feedback.jsonl.
The full set of Claude Code hook events, grouped by lifecycle. The connector consumes the subset mapped in Presence mapping and Message delivery; the rest are listed for completeness.
| Event | Fires when | Matchers |
|---|---|---|
SessionStart |
a session begins or resumes | startup, resume, clear, compact |
Setup |
started with --init-only, or --init/--maintenance in -p |
init, maintenance |
SessionEnd |
a session terminates | clear, resume, logout, prompt_input_exit, bypass_permissions_disabled, other |
| Event | Fires when | Matchers |
|---|---|---|
UserPromptSubmit |
user submits a prompt, before Claude processes it | (none) |
UserPromptExpansion |
a typed command expands into a prompt | command name |
Stop |
Claude finishes responding | (none) |
StopFailure |
turn ends due to an API error | rate_limit, authentication_failed, oauth_org_not_allowed, billing_error, invalid_request, model_not_found, server_error, max_output_tokens, unknown |
| Event | Fires when | Matchers |
|---|---|---|
PreToolUse |
before a tool call executes (can block) | tool name |
PermissionRequest |
a permission dialog appears | tool name |
PermissionDenied |
a tool call denied by auto-mode classifier | tool name |
PostToolUse |
after a tool call succeeds | tool name |
PostToolUseFailure |
after a tool call fails | tool name |
PostToolBatch |
after a parallel tool batch resolves, before next model call | (none) |
SubagentStart |
a subagent is spawned | agent type |
SubagentStop |
a subagent finishes | agent type |
TaskCreated |
a task is created via TaskCreate |
(none) |
TaskCompleted |
a task is marked completed | (none) |
TeammateIdle |
an agent-team teammate is about to go idle | (none) |
| Event | Fires when | Matchers |
|---|---|---|
PreCompact |
before context compaction | manual, auto |
PostCompact |
after compaction completes | manual, auto |
| Event | Fires when | Matchers |
|---|---|---|
CwdChanged |
working directory changes | (none) |
FileChanged |
a watched file changes on disk | literal filenames |
ConfigChange |
a config file changes mid-session | user_settings, project_settings, local_settings, policy_settings, skills |
InstructionsLoaded |
CLAUDE.md / .claude/rules/*.md loaded into context |
session_start, nested_traversal, path_glob_match, include, compact |
Notification |
Claude Code emits a notification | permission_prompt, idle_prompt, auth_success, elicitation_dialog, elicitation_complete, elicitation_response |
MessageDisplay |
while assistant message text is displayed (display-only) | (none) |
| Event | Fires when | Matchers |
|---|---|---|
WorktreeCreate |
a worktree is created (--worktree / isolation: "worktree") |
(none) |
WorktreeRemove |
a worktree is removed | (none) |
| Event | Fires when | Matchers |
|---|---|---|
Elicitation |
an MCP server requests user input during a tool call | MCP server name |
ElicitationResult |
after the user responds, before it is sent to the server | MCP server name |