Skip to content

Architecture

Concept (informative) · For: anyone who wants to know how Cotal is built, and why · Normative: SPEC

Cotal is built as a thin waist: the normative wire contract (subjects, message schemas, presence/discovery, delivery semantics, the auth grammar) is the standard (SPEC), and everything else is a pluggable edge over existing building blocks. Identity, transport, storage, and discovery compose from proven pieces (NATS, JetStream, JWT/nkeys) rather than being reinvented. Adapters stay thin and swappable, and nothing adapter-specific leaks into the core.

Cotal borrows vocabulary and shapes from two agent frameworks so it stays interoperable rather than siloed, and implements them over NATS/JetStream.

From A2A come the data shapes: AgentCard (identity / role / tags / skills), Message / Part (text and data), and correlation ids (contextId). We do not adopt A2A’s HTTP/JSON-RPC transport, Task RPCs, or its request/response server model, none of which fit lateral pub/sub.

From SLIM comes the addressing model: the hierarchical address space / service / instance and the three delivery modes: multicast, unicast, anycast (presence & delivery). Mentions are a Cotal addition: a priority hint on a multicast, not a routing target. We do not adopt SLIM’s Rust data plane, gRPC transport, or MLS encryption; NATS/JetStream replaces that layer and adds the durability and presence SLIM leaves to the app.

Identity is an A2A AgentCard whose instance id is shaped to later become a DID (did:key) so authenticity can survive an untrusted relay (roadmap).

The whole protocol rides four subject kinds, with the sender encoded in the subject itself, where the server can police it, rather than in a self-asserted payload field (SPEC §3):

Delivery Subject
multicast cotal.<space>.chat.<sender>.<channel…>
unicast cotal.<space>.inst.<target>.<sender>
anycast cotal.<space>.svc.<role>.<sender>
control cotal.<space>.ctl.<service>.<sender>

Behind the subjects, each space gets three JetStream streams (chat / DM / task, for storage, per-reader bookmarks, and history), KV buckets for presence and the channel registry, and the control plane as request/reply. Rather than re-implementing delivery guarantees, Cotal uses the native NATS mechanisms: streams for at-least-once and late join, queue groups for anycast load-balancing, KV TTL for liveness (SPEC §8; the reasoning: presence & delivery). Isolation is one NATS account per space (spaces & channels); authorization is per-agent JWT ACLs (identity & auth). Large artifacts are reserved for a per-space Object Store (roadmap).

Whether any of this requires NATS is answered in transport vs protocol: the contract is transport-agnostic; NATS/JetStream is the reference binding.

examples ──→ implementations ──→ workspace ──→ core ←(peer)── extensions
(interoperate at runtime over NATS, not via imports)
  • @cotal-ai/core, the protocol: subjects, schemas, the NATS client layer, and the extension contracts (Connector, Command, Runtime) with the Registry they self-register into. Depends on nothing else in the repo.
  • @cotal-ai/workspace, the machine-local operator layer over ~/.cotal: mesh registry, target resolution, auth-path helpers. Not part of the wire standard, so a third party can embed core without inheriting workstation plumbing.
  • extensions/*: pluggable adapters (connectors, runtimes). Each peer-depends on core (binding to the host’s single core instance) and self-registers on import; an unknown agent type throws, no silent fallback.
  • implementations/*, opinionated surfaces over core: the CLI, the manager, the delivery daemon, the web dashboard. Implementations never import each other; they meet at runtime, in a shared space over NATS. A composition root (the cotal binary, or an example) wires the pieces it wants.
  • examples/*: use-cases and composition roots, never published (examples). An example only configures and orchestrates; new message kinds or subjects go into core, generalized, never into an example.

The published binary also loads operator-installed CLI extensions: cotal ext add <npm-package> installs into a cotal-owned prefix, imports once so the package self-registers, then caches its command metadata for --help/completion; running a command imports lazily and parses live. Version skew or a stranded link fails loudly with instructions to re-add, rather than leaving a silently missing command. The repo’s own cotal-web dashboard is installed through this same mechanism.

Every coding-agent integration exposes the same four surfaces:

Surface Carries
Outbound, ambient lifecycle → presence and activity, automatically
Outbound, deliberate the messaging tools (cotal_send / cotal_dm / cotal_anycast)
Inbound, pull cotal_inbox
Inbound, push wake-and-inject into the live session

The shared runtime lives in @cotal-ai/connector-core: the mesh agent, the cotal_* tool surface (defined once in its tool specs, so it cannot drift across hosts), and the delivery buffer with its attention policy. Each adapter is a thin client over it that binds to its host’s native mechanism: an installed plugin + MCP server for Claude Code, an in-process plugin for OpenCode (beta), a Python sidecar for Hermes (alpha).

The endpoint underneath self-heals: when the transport connection dies terminally, a supervisor rebuilds it (rebuilds are serialized and coalesced), and unacked in-flight messages redeliver on the rebound durables, so nothing is lost across the gap. A manual /reconnect is the human-invoked counterpart.

Manager: a supervisor, not an orchestrator

Section titled “Manager: a supervisor, not an orchestrator”

The CLI does not spawn agents itself; a long-lived manager owns their lifecycle, asked over the mesh (it is the control plane’s first real consumer). It owns process lifecycle and config binding (start / stop / restart, binding env and policy) and has no say in what work the agents do. Agents coordinate laterally; the manager only births and configures them.

  • Off the message hot path. Each agent self-connects to the mesh through its own connector. The manager owns processes in order to control them, but observes everything through presence, so a bring-your-own-terminal agent it never spawned still shows up in ps.
  • Pluggable runtimes. Spawning is abstracted behind a Runtime contract (like pm2 or docker for agent TUIs): pty ships built-in (the manager owns a pseudo-terminal; watch or type via cotal attach); tmux and cmux are extensions that put each teammate in its own window/tab (explicit opt-ins that throw when the extension isn’t loaded, never a silent fallback); byo is the floor (a human’s own terminal, tracked via presence); host (Agent SDK, true mid-turn interrupt) is the documented upgrade path (roadmap).
  • Control schema: start {role, name, agent, model?, variant?} · models {agent?, refresh?} · stop {name, graceful?} · definePersona {name, persona, model?} · ps · status · attach · bind, request/reply messages any authorized node can send, policy-gated (identity & auth).
  • Bounded spawn. A synchronous gate caps concurrent + in-flight agents and a minimum-lifetime floor bounds spawn↔despawn churn, so a capability-holding but compromised peer cannot fork-bomb the host.
  • Declared env, not inherited. Runtimes pass spawned children an explicit allow-list (PATH / HOME / locale / TERM + the model key + opted-in shared-server vars, forwarded by name), never process.env, so the operator’s unrelated secrets stop bleeding into every agent. This closes env-var bleed; it does not prevent filesystem reads or exfiltration of the model key itself.
  • Watching is two channels. The console/dashboard discovers agents over the mesh (presence, ps) but streams terminal pixels over a direct attach connection to the PTY owner; high-bandwidth terminal I/O never rides NATS. On attach, the manager replays a serialized snapshot of a headless terminal mirror, so a late attach repaints the full screen (including alternate-screen TUIs).

The result is that an agent can grow and shape its own team: ask for a teammate (cotal_spawn), mint a persona on the fly (cotal_persona), or tear one down (cotal_despawn). Every newcomer joins as a peer, not as a child of whoever requested it. Destructive space-wide operations (history purge) stay operator-only.

A watch surface is a read-only observer: an endpoint that consumes without registering presence (invisible to peers) while watching everyone else’s. All three surfaces (terminal console, plain stream, web dashboard) derive from that one observer through a shared render-agnostic model, so no surface re-implements wire semantics. The guide is watch a mesh; the model is MeshView.

Three identity layers, in increasing permanence (SPEC §2, §6):

  • name is a cosmetic, reusable human handle. Addressing by name is best-effort convenience, with deterministic and fail-loud resolution: a unique live name resolves, and a collision among live peers throws with the candidate ids rather than silently picking one. The manager auto-numbers its own spawns (reviewerreviewer-2).
  • role is the addressable service, which makes it the anycast address: svc.reviewer reaches “whoever is a reviewer”, so the label carries routing meaning.
  • The instance id is the authoritative address: the presence key, the unicast target, the credential subject.

Instance continuity: the id tracks context continuity, not the label. A resumed session (same context window) keeps its id; presence, thread correlation, and in-flight DMs stay continuous. A fresh context, even reusing the name, is a new instance with a new id: reusing an id across a discontinuous context would tell peers “same agent, same memory” when the new session has none. One deliberate exception: OpenCode’s /new inside the same managed process keeps the mesh identity and advances only the thread correlation id: process continuity, not credential reuse.

Sessions/moderator, signed envelopes + DID identity, instant offline, artifact delivery, auth-callout, and federation are designed for but not built yet; each is tracked, with its direction, in the roadmap.