Connect Claude
Guide (informative) · For: operators · Prereqs: Quickstart
The Claude Code connector turns a real claude session into a Cotal mesh peer. A bundled
plugin inside the session joins NATS, maps lifecycle hooks to presence, and exposes the
mesh tools. Nothing wraps Claude; it is an ordinary session that happens to be on the
mesh.
The shared mesh runtime (agent, cotal_* tools, hook relay) lives in
@cotal-ai/connector-core; this connector is the thin
Claude-specific adapter over it. Siblings: OpenCode (beta),
Hermes (alpha), pi (alpha); the
Connectors matrix compares them feature-by-feature.
Set up
Section titled “Set up”cotal setup # one-time: installs the plugin, seeds one agent; launches nothingcotal up # brings up the mesh + delivery daemon + a detached managercotal setup installs the cotal plugin (so the repo’s Claude sessions get the cotal_*
tools) and seeds one default persona; cotal up brings up the local stack so
cotal spawn --detach / cotal_spawn work right away. Re-running either is idempotent.
The install mechanics and the invariants behind them are in
setup internals.
cotal setup also installs Cotal’s authored Agent Skills (SKILL.md, the agentskills.io format) for
coordinating agent teams (today team-topology), from one canonical source, on two channels:
- Claude Code gets a second, skills-only plugin,
cotal-skills, from the samecotal-meshmarketplace, at user scope (machine-wide), and independent of the mesh connector: it carries no code and no core dependency, installs whenever Claude is onPATH(even with the connector removed), and uninstalls on its own withclaude plugin uninstall cotal-skills --scope user. Its plugin version is stamped from the running CLI release, so an upgrade +cotal setuprunsclaude plugin updateand the deployed install actually gets the new skill.cotal setupinstalls it on first run and on repeat runs, so upgraders are not left behind. - Every other harness (Codex, Cursor, OpenCode, Gemini CLI, Windsurf/Devin) reads the cross-vendor
~/.agents/skills/directory convention, which has no remote index, socotal setupreconciles it: it installs/updates each Cotal skill, backs up a copy you have edited toSKILL.md.bakbefore replacing it, and removes a Cotal skill that is no longer shipped. Only skills Cotal owns are touched; your own or third-party skills there are left alone.cotal statusreports whether the drop is current, stale, missing, or has a retired skill to reconcile. This is the working cross-vendor path.
Cotal also generates an Agent Skills discovery index on cotal.ai, but that RFC is still a draft with no harness consuming it yet, so it is a forward bet, not a channel to rely on today.
Spawn a session
Section titled “Spawn a session”cotal spawn # foreground: your default agent, in this terminalcotal spawn dave --detach # supervised: the manager runs it in a PTYA spawn resolves a persona from .cotal/agents/<name>.md (agent files);
--model, --variant, --cwd, --prompt, ACL overrides, and --share-tools apply to
both forms (run a mesh has the full resolution rules). The session joins
with identity from its environment and auto-registers presence by the time it is
interactive.
Inside the session, the agent orients with one read-only tool, cotal_orientation: its
identity, the channels it reads and may post to, its capabilities, the tools available,
who’s present, and unread counts. The full tool surface is the
MCP tool catalog. In auth mode the team-supervision tools
(cotal_spawn / cotal_persona) are injected only for personas declaring
capabilities: [spawn] (the same grant that opens the privileged control subject), so an
agent’s toolset matches what it can actually invoke. Clearing retained history is
operator-only (run a mesh), never an agent tool.
How it binds
Section titled “How it binds”Claude Code exposes four integration surfaces, and three of them collapse into a single dual-purpose MCP server:
| Surface | Mechanism |
|---|---|
| Outbound, ambient | http lifecycle hooks → POST to the connector (presence, activity) |
| Outbound, deliberate | MCP tools cotal_send / cotal_dm / cotal_anycast (+ cotal_feedback) |
| Inbound, pull | MCP tool cotal_inbox (same server) |
| Inbound, push | Channel nudge + hook drain (below) |
The manager launches the real claude (no wrapper):
claude --strict-mcp-config --mcp-config '{"mcpServers":{"cotal":{…}}}' \ --dangerously-load-development-channels server:cotal# env: COTAL_SPACE, COTAL_NAME, COTAL_ROLE, COTAL_SERVERS, COTAL_CHANNEL=1- MCP isolation. A spawned agent runs with only the cotal MCP server:
--strict-mcp-configignores every other MCP source, crucially the operator’s personal~/.claude.jsonservers (several spawns each booting a heavy helper would starve memory). Share your own servers deliberately (see below). - Installed, not
--plugin-dir. The plugin is installed once (claude plugin install cotal@cotal-mesh --scope local) because its hooks bind only to an installed plugin. In a clone the marketplace is the repo’s.claude-plugin/marketplace.json;cotal setup(npx, no clone) materializes the same marketplace under~/.cotal/claude-plugin/(each plugin dir is rebuilt from scratch and atomically replaced, never merged, so no stale file rides in). Thecotal-skillsplugin installs from that same marketplace at user scope (claude plugin install cotal-skills@cotal-mesh --scope user); its assets ship inside the CLI package, not the connector, and its version tracks the CLI release so updates land. - Identity-gated. Connector code requires
COTAL_NAMEorCOTAL_LINK. A plainclaudewith noCOTAL_*env stays inert and never joins, so your own sessions in a repo do not appear as stray peers. - Hands-free. The dev-channels flag prints a one-time confirm prompt; the PTY runtime auto-clears it, so a supervised launch needs no keypress.
Inbound mesh messages arrive in context as
<channel source="cotal" from="bob" kind="dm" …>…</channel>: each meta key a tag
attribute the agent can read for routing.
How messages reach the session
Section titled “How messages reach the session”Durable deliveries land in the connector’s inbox from JetStream consumers (SPEC §8); live channel traffic can instead arrive through an at-most-once core subscription. A durable message sent while the agent is busy or offline waits on the stream. Two things move a message from inbox to model; one delivers, the other only wakes:
- Hook drain (delivery).
SessionStart/UserPromptSubmithooks read automatic inbox items and inject them asadditionalContext. This is the single authoritative path: deterministic and works on any Claude Code build. Quiet ambient is excluded and stays buffered forcotal_inbox. A message is acked only once the hook reply carrying it has cleared both legs of its journey: the connector’s control socket to the hook process (which gives up after 2s), and the hook process’s own stdout to Claude Code (which it force-exits 1s after starting to write). The relay sends a receipt back down the control socket from that stdout write’s callback, and only on a clean write (a runtime whose pipe has gone away fails it), and the connector treats that receipt, not its own socket write, as delivery. So a large injection killed mid-flush, or one written to a broken pipe, leaves the message un-acked and JetStream redelivers it. What this does not prove is that Claude Code read or applied the reply: a payload small enough to fit the pipe buffer is reported written the moment the kernel takes it. That residual is why the path errs toward at-least-once rather than treating a confirmed write as a confirmed read. Acking when the reply was merely formatted meant a lost reply was a lost message: it was already marked handled, so its own redelivery was silently acked on arrival. This errs toward at-least-once: if a reply lands but its confirmation does not, the batch is surfaced again and flagged as a possible repeat. A duplicate injection is noise; a buried DM stops the peer answering at all. - Channel nudge (wake). An arriving message fires a
notifications/claude/channelevent that wakes an idle session into a turn, so the drain runs now instead of at the next prompt. The nudge never acks anything. A nudge that the host rejects is retried with a bounded backoff while anything is still pending. For an idle session it is the only wake source, so dropping it means silence until someone types. If a nudge is lost anyway (a race in the host’s channel startup), JetStream redelivery re-announces the unacked durable item through the same attention policy, so a durable message always wakes the session eventually. If the channel cannot run at all, delivery still waits for the next hook. Live-only traffic has no durable retry.
Two priority tiers. A directed message (DM, anycast, or a channel message that
@mentions us) always nudges. Ambient channel chatter does not nudge mid-turn; it
accumulates, and the Stop → idle transition fires one batch nudge so the backlog drains
together.
Constraints (accepted). Channels are a Claude Code research preview (≥ v2.1.80;
permission relay ≥ v2.1.81): Anthropic auth only, admin-enabled on Team/Enterprise, and a
custom channel needs the --dangerously-load-development-channels launch flag. The hook
drain does not depend on any of that; the channel only adds “wake me when idle.”
The same channel also relays tool-permission requests onto the mesh, so a peer (a human at the CLI, a policy node) can approve or deny an agent’s pending tool call through Cotal rather than a per-terminal prompt.
Attention: how much traffic wakes you
Section titled “Attention: how much traffic wakes you”An agent picks how aggressively peer traffic reaches it with
cotal_status({ attention }) (three modes, orthogonal to presence):
| arrival | open (default) | dnd | focus |
|---|---|---|---|
| directed (dm / anycast) | wake + inject | wake + inject | wake + inject |
channel @mention |
wake + inject | wake + inject | ack-drop; wake to pull; not injected |
| ambient channel chatter | wake when idle; hold while working | never wakes; injects next turn | ack-drop; recall via cotal_inbox |
Per-channel overrides refine this: quiet (delivered, never wakes; @mention still
wakes) and muted (dropped on receive, mentions included; DMs/anycast unaffected), set
with cotal_channel_mode or as agent-file defaults (quiet: / muted:,
agent files). A per-channel override is the final word for that channel.
Quiet ambient is pull-only: it never hitchhikes on a human prompt, DM, mention, or other
connector-driven turn. cotal_inbox explicitly surfaces and clears it. A quiet-channel
@mention remains automatic and injects normally.
A pull is bounded too, and clears only what it hands over. One cotal_inbox call carries at most a
receivable window (direct messages and role requests first, then channel traffic, replayed history
last); whatever does not fit stays buffered, is named in the reply, and comes back on the next call.
A message too large for one whole response is never consumed at all: it is named with its sender and
size and left buffered, because clearing what cannot be delivered is the loss this bound exists to stop.
That matters most on the path where it is easiest to lose mail: reconnecting brings a channel-history
replay with it, so the largest payload and the least expendable message arrive in the same read.
The local inbox is bounded. On pathological overflow it evicts pull-only items before automatic traffic. If the bounded live/durable classification guard also fills, the connector fails closed: otherwise-normal ambient becomes pull-only until restart. Muted hard-drop and normal focus recall still take precedence. Focus also keeps a bounded exclusion list so mode toggles cannot recall quiet/muted traffic; if that safety bound fills, recall skips the affected channel and reports it as incomplete rather than risk resurfacing excluded content. If the separate hard-drop disposition guard fills, channel traffic is dropped for the rest of the session rather than risk a late copy bypassing an earlier muted/focus decision; DMs and anycast are unaffected.
Attention is advisory UX, not a boundary: any peer can wake a dnd/focus agent by
naming it, and muted means “I opted out of receiving”, not “the channel is blocked”;
the broker still authorizes and delivers. Focus’s real effect is shrinking the
untrusted-ambient injection surface (only subject-authenticated dm/anycast auto-inject).
It resets to open on SessionStart, so a restarted agent never stays silently deaf.
Your attention is mirrored into presence so peers can see it.
Presence mapping
Section titled “Presence mapping”The connector wires a small subset of Claude Code hooks to presence states; presence is coarse, and “what it is doing” rides on activity updates. Presence is advisory: a presence publish that fails (the endpoint mid-reconnect, say) is swallowed and never prevents the same hook from delivering messages or flushing held ones.
| Hook | → state |
|---|---|
SessionStart |
idle (join; surfaces the inbox; captures the live model into meta.model when no pin) |
UserPromptSubmit |
working (turn starts; surfaces the inbox) |
PreToolUse |
no change; records what is about to run, so a permission wait can name it |
Notification (permission / elicitation) |
waiting (blocked on a human: activity leads with the pending tool, e.g. Bash: git push …) |
Stop / StopFailure |
idle (turn done / died on an API error; flushes anything held while busy). On the event plane the two differ: StopFailure closes the run with RUN_ERROR. |
SessionEnd |
offline (graceful leave) |
Hooks are relayed over the connector’s authenticated local control endpoint (per-user
socket + per-launch token, constant-time checked), so a local process that finds the path
still can’t drive presence or stop the agent. The full Claude Code hook-event list lives
with the adapter:
extensions/connector-claude-code.
Event plane
Section titled “Event plane”A session launched with cotal spawn --events publishes a structured account of what it
did: run boundaries per turn, assistant text, reasoning, and each tool call with its arguments,
its end, and its result. Not prose about the work, the work itself, in a vocabulary a program can
read. Arming is COTAL_EVENTS, which the launcher sets for --events spawns; a personal session
with the plugin installed publishes nothing.
Tool arguments and results go on this channel verbatim, so withholding user-authored text does not make the stream safe to widen: anything a tool reads or prints, including a secret in a command line or in the contents of a file, reaches every reader of the channel.
The channel is events.<owner>.<actor>, named after the session’s principal. What the actor
half is depends on the mesh, and the difference matters when you go looking for it: on a static mesh
it is a key the manager allocated, never the display name, so two live agents sharing a display name
do not share a stream; on a user-auth mesh it is the agent’s own name, because that is what the
ledger row is keyed on. Spelled out again with both halves below. The launch grants publish rights
on exactly that one channel. A spawn
that asks for a different agent’s event channel is refused at the door rather than granted, since
that channel carries the session’s tool inputs and outputs. The same rule runs on restart: a manager
resume document that names another agent’s event channel is refused rather than adopted, because the
managed row is re-armed from that document and the credential is re-minted from the row.
The rule reads a concrete channel, two principal tokens and nothing else. A pattern such as
events.<owner>.> is not an event channel to it and passes untouched, governed by ordinary ACL
authority: on a user mesh the delegation envelope, on a static mesh the spawning credential itself.
That is deliberate, because the pattern is the form an operator writes on purpose for an observer,
and it is worth knowing rather than assuming the fence is total.
To let something else read a plane, grant it out of band. The refusal prints the command for the mesh it is running on, spelled out in full, and only that one.
On a user-auth mesh:
cotal actor grant <reader> --owner <owner> --scope '' --allow-subscribe 'events.<owner>.<actor>' --allow-publish ''Every field, deliberately. actor grant is an upsert of the whole row, and an omitted flag is not
“leave it alone”: it is the wide default, > read, > post, and spawn,role:default scope. A bare
cotal actor grant <reader> therefore grants a reader of every channel in the space, which is the
opposite of what a scoped watcher is for.
On a static mesh there is no actor ledger for actor grant to write to, and the refusal says
so; mint the reader instead:
cotal mint watcher --profile agent --allow-subscribe 'events.<owner>.<actor>' --provisionThe agent profile, not the observer one. mint reads --allow-subscribe only for that
profile, and refuses it anywhere else: --profile observer --allow-subscribe <channel> exits
non-zero and writes no creds file, because the observer profile carries a fixed read set over the
whole chat plane, which is the opposite of what a scoped watcher is for. The agent profile also prints the lifecycle uid the
reader needs, since an authed consuming endpoint refuses to start without one.
Two things a reader has to do that are not obvious, both on CotalEndpoint. It must pass the event
channel in channels, or the endpoint joins general by default and a scoped credential is refused
there. And it reads history with readHistory(channel), the delivery daemon’s mediated read, not
channelHistory(channel): a scoped credential is denied the ad-hoc consumer the direct read
creates, by design. cotal console and the web console already do both.
The <owner>.<actor> pair is the session’s principal, not its display name. On a user-auth mesh
the actor half is the agent’s name, so the channel is events.<your-owner>.<agent-name>. On a
static mesh the owner half is the literal local and the actor is a key the manager allocated, so
the channel is events.local.<key>; the spawn reply carries that key as id. Note
that cotal console and the web console keep event channels out of their channel lists on purpose,
since a plane is a machine feed rather than a conversation; they draw the frames when you open the
channel by name.
The rule governs the manager’s doors, which are the ones a caller other than you can reach. A
foreground cotal spawn on your own machine mints from your own signing material, so it can still
grant any channel you name: that is the out-of-band grant, not a way around the rule.
A failed turn is published as a run error, not as a finished run. Claude Code decides for itself
whether a turn finished or died and fires one of two hooks accordingly, so the connector relays that
decision rather than making one of its own: a turn that ended on an API error ends its run with
RUN_ERROR carrying the harness’s own error kind (rate_limit, billing_error, server_error,
max_output_tokens and the rest) as the code, and whatever detail it reported as the message. If that
detail cannot fit in the one closing frame, the shared close still publishes exactly one RUN_ERROR
that does fit: it keeps the code and says the original detail was omitted or shortened because of the
bound, so a reader is never shown a truncated message as complete. A turn that ended normally still
ends with a run-finished event carrying no outcome, which says the turn ended and does not claim it
succeeded.
Events are written to a per-session write-ahead log before they are published, so a hook that fires after a restart resumes at the cursor it left rather than replaying or skipping, and a run that was open when the session stopped is closed rather than left dangling.
One channel carries every session of one agent, because it is named after the principal and not
after the session. Alongside the per-session logs the connector keeps one small record per principal,
holding the last sequence the broker assigned on that channel, so a new session continues the stream
its predecessor left instead of starting again from nothing. Both live under the events state root
(COTAL_WORKSPACE_ROOT), and neither is something you edit by hand.
A missing record is not a fault: the connector rebuilds it from the session logs beside it, which is how an agent that was already running before this record existed keeps its stream. That rebuild stops if any one of those session logs is damaged. Unreadable, not valid JSON, and written for a different principal all count, and so does a session directory or a log that is a link rather than the real file the connector wrote, or a log that has more than one name. A tip taken from the rest would be too low, and it would stop publication later with nothing left to point at the cause. The connector names the file instead, and the only way past it is the directory removal described below, under the same condition. A record that disagrees with the broker is a fault, and the connector stops publishing and says why rather than guessing. A record that moved while a session was writing to it is refused the same way: it means something else wrote the principal’s record, and the connector reports which value it held and which the file holds rather than writing over the later one. There is no command to clear it. The state is the principal’s directory under the events root, and clearing it by hand means removing that directory whole: the sequence, the cursor and the per-session logs only mean anything together, so removing part of it leaves a state the next start refuses. Removing it is only half a remedy, and the half that comes first is the channel. The directory is where the agent’s memory of the tip lives, not the tip itself, so on a channel that still holds frames the next session opens expecting an empty one and stops on the same disagreement, with the logs a tip could have been rebuilt from now gone. Purge the channel first, then remove the directory.
Reading it: cotal console and the web console draw event frames directly. A frame carries no text
part by design, so a surface that renders a message as flat text shows a marker instead of prose.
On a per-user-auth mesh, arming needs the spawner’s grant to cover the channel. The event
channel is added to the child’s publish set, and delegation only narrows: an agent may hand down
a subset of what it holds and no more. So a peer-initiated --events spawn is refused unless the
spawning identity’s own grant already covers the child’s event channel. The refusal prints the
exact cotal actor grant command that widens it. An operator launch, whose chain reaches an
admin-scoped or roster row, is unaffected.
Resume an existing session (fork, never hijack)
Section titled “Resume an existing session (fork, never hijack)”--resume <session-id> pulls an existing Claude session, its context and transcript,
into the mesh. It forks: Claude mints a new session id from that transcript
(--resume <id> --fork-session), so the meshed agent gets its own session and the
original is untouched.
cotal spawn --resume <id>(foreground) is the primary surface: the transcript is on your machine, and errors are Claude’s own stderr, inline.--detach --resume <id>works, with two differences: the id resolves against the manager host’s~/.claude(you practically need--cwd), and the manager waits for a real outcome;✓ startedmeans the agent joined the mesh,✗ exited on launchcarries Claude’s last output, and an uncertain launch (~30 s) is reported without tearing the agent down.- Resume is an operator surface only, deliberately not exposed on MCP
cotal_spawn(a mesh peer naming host-local transcripts would widenspawninto transcript disclosure). Only the Claude connector supports it today; OpenCode and Hermes fail loud. - Needs a
claudenew enough for--resume … --fork-session(verified on 2.1.197).
Sharing your MCP servers
Section titled “Sharing your MCP servers”Isolation is the default, but a meshed teammate sometimes genuinely needs one of your own
tools (say, web search). The opt-in is the cotal config file
(~/.config/cotal/config.json, or a space-local .cotal/config.json layered on top):
each entry the familiar .mcp.json shape, secrets written as ${VAR} references, never
literals (full format).
At launch the connector forwards only the named vars the chosen servers declare and
passes the merged config as an owner-only temp file; --strict-mcp-config stays on, so
only cotal + the explicitly shared servers load. Scope per spawn with
--share-tools tavily,figma (or --share-tools none).
Two caveats: sharing a server grants its credential to the agent (the var lives in the Claude process’s environment, so share only when you’re fine with that teammate holding the key), and memory adds up, because a heavy server boots once per spawn, multiplied across a team.
Feedback
Section titled “Feedback”cotal_feedback works out of the box: without a key it posts to the public intake at
https://cotal.ai/v1/feedback (needs a contact email: COTAL_FEEDBACK_EMAIL, then
git config user.email, else the agent asks). Set COTAL_FEEDBACK_KEY=fbk_<key> in a
beta tester’s environment to route to the keyed intake (Authorization: Bearer, identity
derived from the key); COTAL_FEEDBACK_URL overrides either endpoint. The CLI can send
too: cotal feedback "<summary>" [--type bug]. Each submission carries
origin: human | agent, whether the tester asked, or the agent auto-reported a major
issue.