Watch a mesh
Guide (informative) · For: operators · Prereqs: Quickstart
A running mesh is a stream of live activity: who is present, what they are doing, what they
are saying to each other. Cotal gives you three read-only surfaces onto one space. All three
render the same observer model (MeshView); none opens its own connection or
re-implements the wire. Pick by where you are:
| Surface | Command | Use it to |
|---|---|---|
| web dashboard | cotal web |
a god-view browser dashboard: see at a glance what needs a human |
| console (TUI) | cotal console |
drive it interactively in the terminal: drill into agents, channels, DMs |
| stream | cotal console --plain, or any pipe |
tail a passive line log: grep it, pipe it, watch it in CI |
The web dashboard ships inside cotal-ai and is seeded automatically; the console ships with
the CLI.
Terminal console
Section titled “Terminal console”cotal console auto-selects its renderer: a real TTY gets the lazygit-style Ink TUI; a pipe or
--plain gets the line stream. Both read from one invisible observer over the space.
cotal console --space main # the TUI for one spacecotal console --plain # the passive line stream (also the default when piped)cotal console # no --space on an open mesh → the admin overview first
Admin overview. On an open mesh, cotal console with no --space opens a space picker:
every space on the server (enumerated from its CHAT_* streams and presence buckets) with its
agents, channels, and message counts. Pick one to drop into its console; b returns to the
overview. --space X skips the picker. Under auth a server hosts a single space, so the console
enters it directly (no overview). D deletes the selected space once you type its name. A space this
host registered as a static-auth mesh on that server is deleted under a teardown minted from its trust
material, which names the resume transfer buckets the space holds at that moment; any other space is
deleted under the console’s own connection, bare on an open mesh or your --creds file.
Lenses and keys (TUI). The layout is a roster, a live feed, per-channel tabs, a golden-signal tiles strip, and toggleable lenses:
| Key | Does |
|---|---|
1–9, [ ] |
select a channel tab |
n |
the NEEDS-YOU rail: agents currently blocked or waiting |
d |
the DM lens: per-peer roll-up and threads (god-view only; shows “DMs hidden” under chat-only creds) |
t, then v / 1–3 |
the topology lens: who-talks-to-whom, as a swimlane, a heat matrix, or a ring map, with the broker’s own membership overlaid when the delivery daemon serves it (a header pill reads live, stale, traffic-only, or unreadable) |
/ |
search / filter the feed |
: |
the command palette |
D |
kill the selected agent (control-gated, below) |
arrows / h l |
move focus; select a row for its detail card |
? · b · q |
help · back to overview · quit |
Operator control. Watching stays read-only, but the console can also drive the
manager. The observer endpoint never carries
control: each action is one call through the same per-action path cotal stop, cotal ps, and
cotal spawn --detach take. It resolves the mesh, mints a one-shot instrument (or connects bare
on an open mesh, or rides your bearer on a user-auth mesh), calls the manager over the endpoint
rails, and keeps nothing. That gate is canControl, decided by whether that path can produce a
caller at all (a raw --creds file cannot), and it is independent of canWrite, which gates
chat. D kills the selected agent behind a confirm (y graceful stop, f force-kill), and the
: palette adds spawn <persona> [name] (waits for the join, like cotal spawn --detach),
status <agent>, ps, purge (type the space name to confirm, like the space delete), and
delchan <channel> (type the channel name to confirm): the web dashboard’s channel delete, run
from the console. It is core’s clearChannel (a filtered history purge plus the registry entry)
with the dashboard’s per-action authority, a one-shot channel-purger credential minted from the
resolved mesh’s seed on a static mesh, a channel-purger view on a user mesh, a bare connection
on an open one; no manager is involved. A wildcard is refused, so the one destructive control
names one channel. A refusal, from the broker or the manager, lands on the status line. On a
space with several managers, D, :status, and a are pinned to the manager hosting the seat and
:ps merges every manager’s rows; :spawn and :purge ride the class queue, as cotal spawn --detach and cotal purge do without --on. A manager that does not answer keeps its last known
rows and is named as silent. A manager that answers with an error is named with that error and its
old rows are withheld rather than shown as current.
a on a roster agent (or :attach <agent>) suspends the console and hands the terminal to the
seat: it is cotal attach run in place, the same one-use holder-bound mesh session, the same
reconnect on a lost link, the same end reasons, with the observer running in the background
throughout. Ctrl-] (or COTAL_DETACH_KEY) returns and repaints the console, and the verdict
lands on the status line: detached, the seat is gone, or why it could not attach. A bad
COTAL_DETACH_KEY is refused before the screen is handed over. Attach is canControl-gated and
needs what cotal attach needs: a pty seat and the space’s local seed to redeem the session
grant (a static-auth mesh; an open mesh holds no seed, so attach refuses there as it does on the
command line).
Operator participant mode. By default the operator is invisible, and a message it sends is
one-way: an agent’s DM reply has no peer to land on. On an open mesh, the operator’s first send
(:dm, :call, :ask, :msg, or a compose) puts the operator on the roster: the console starts
a second, presence-only endpoint carrying the observer’s own card (the same id, name, and
role: "operator", so it is the from of everything the observer sends), the status bar says
on roster for the rest of the session, and the god-view tap the console already runs shows the
replies in the DM lens. The heartbeat survives a broker
reconnect, and the operator leaves cleanly (an offline record) on exit. This is canWrite-gated,
so a pure-watch session never registers, and a peer the broker refuses stays invisible, blocks that
send, and leaves the refusal on the status line so the operator is never told replies can land when
they cannot. Concurrent sends wait for the same startup result; the next later send retries after a
refusal. Under auth the console does not upgrade: the read-only default cannot
send at all, and an agent-grade --creds holds no live read of its own DM inbox (DMs ride its
lifecycle-keyed durable, which the observer does not consume), so a send there is one-way and the
status line says so once. An auth participant needs a credential profile that can publish
presence and chat and read its own inbox; that profile does not exist yet.
The stream is line-oriented, so the signals stay out of it; it is just a timestamped log of
presence changes and messages, ready for grep.
Web dashboard
Section titled “Web dashboard”The dashboard ships inside cotal-ai as the @cotal-ai/web extension and is seeded automatically on
first run (like the built-in connectors), so cotal web is there out of the box and tracks your CLI
version on upgrade. If a seeded copy is damaged, cotal ext seed --repair restores it.

cotal web --space main # opens http://cotal.localhost:7799/cotal web --space main --detach # background; stop with cotal down webcotal web --space main --port 8080 --no-opencotal web --space main --host 192.0.2.10 # explicit remote bind and browser addresscotal web --space main --creds ./admin.creds # use a cred you minted yourselfFlags: --space (default main), --server (the mesh’s broker, resolved from the registry),
--host (HTTP bind and browser host, default 127.0.0.1), --port (1 to 65535, default 7799), --detach
(run in the background), --no-open (skip auto-launching the browser), --creds (override the
self-minted cred). Remote exposure requires an explicit concrete --host; wildcard addresses
0.0.0.0 and :: are refused because neither is a browser destination. Detached mode waits for
the real HTTP server at the bound host and port before returning, logs to <mesh-root>/.cotal/web.log, and is stopped by
cotal down web or bare cotal down. On the default host it probes 127.0.0.1, because a system
resolver such as WSL2’s may not answer the branded cotal.localhost. It requires a recorded mesh root; after cotal up records the
mesh, it can be launched from any directory. The branded URL http://cotal.localhost:7799/ resolves
to loopback with no DNS setup in Chrome, Firefox, and Edge. Safari and a system resolver such as
WSL2’s may not resolve *.localhost, so on this default the launch link is printed again at
http://127.0.0.1:7799/ with the same single-use token. A custom --port uses the plain loopback address. An explicit
--host is also the advertised address and the only allowed browser Origin for that process.
The link is single-use, and the surface authenticates the caller. Starting the dashboard prints a
URL carrying a one-time token; opening it exchanges the token for a session cookie and the token is
then spent. Binding loopback keeps other hosts out, but it never kept out other processes on your
machine, nor a page in your own browser posting to http://127.0.0.1:7799, so the token is what
makes the session yours. Requests without it are refused with the reason named (unauthenticated,
launch-token-already-used, or cross-origin) rather than silently returning nothing.
Practical consequences: open the printed link in the browser you want to use it in, because the
token is spent on first use. Re-opening it in another browser or profile is refused with
launch-token-already-used. (In the browser that already holds the session, re-opening the link
still works: the session is checked before the spent token, so the page loads on the session you
already have.) If you lose the line, the link is also written to <mesh-root>/.cotal/web.session,
mode 0600 on every write. The session is bound to the origin you opened, so one started on
http://cotal.localhost does not carry over to http://127.0.0.1. Restarting cotal web mints a
fresh link and invalidates every earlier session.
A god-view, minimal privilege. The dashboard is always the full god-view; there is no
read-only viewer mode. In auth mode it self-mints its own admin read cred (the scope that lets
it tap DMs and anycast), then drops the space signing seed so a dashboard compromise can’t mint
identities; it keeps only one narrow cred for its single write path. In open mode it connects bare.
Pass --creds to use a cred you minted yourself instead. On a per-user-auth mesh there is nothing
to mint: the dashboard rides the read-only admin view over your login, and the channel-delete
write path asks for its own channel-purger view per click (both need ledger scope admin;
identity & auth).
The dashboard is read-only except that one write path: deleting a channel and its content (a filtered history purge plus the channel-registry key), which is POST-gated and confirm-guarded in the UI.
The views. Every view keeps the same skeleton: navigation on the left (roster, channels, DMs), the selected content in the centre, the NEEDS-YOU lane always on the right.
- Monitor: the all-activity feed (two-line messages with a delivery-mode badge, per-mode filter chips, and pause), the roster (status as shape and colour, role, a one-line activity, and the agent’s harness: claude / opencode / hermes), and the golden-signal tiles (working / waiting / idle / offline / oldest-unattended). The roster groups live peers by the machine each one reports as its host, with a count per machine, so placement across machines reads at a glance. A peer that reports no host, such as a manager, is listed last under host not reported. Seats do not report which manager runs them, so the roster has no manager grouping.
- Channel view: one channel’s message list, members shown in the header.
- Direct messages: a per-peer roll-up (one row per peer, not the n² pair list); expand a peer for its conversations. Threads key on authenticated ids, and every shown name, role, and status comes from the roster by id, never from the message payload.
- Agent Detail. A per-agent drill-down rendered from the peer’s card: name, role, the harness and model, capabilities, and what it’s working on or blocked on.
- Graph view (
/graph, linked from the Monitor header): the same feed as a live force-directed constellation. Channels and agents are both nodes; a wire is drawn per membership (a spoke to every channel an agent subscribes to) and glows when a message flows. Membership is broker-sourced and authoritative, reconstructed by the delivery daemon from the broker’s connection view unioned with the durable-members registry, so silent subscribers show too. A header pill reports the feed as live, stale, traffic-only (no daemon, e.g. open mode, where the graph degrades to traffic-derived spokes), or unreadable. The last meaning the read itself did not answer, which is a fact about the viewer rather than about the mesh, and is kept distinct from traffic-only for that reason. A hide-offline control collapses durable-but-away members. The live feed opens as the page loads rather than after it, so the pill reports the connection honestly from the first moment instead of sitting in its down state for as long as the first read takes. What the feed says outranks the page’s own startup reads: a read issued before a live update cannot overwrite it when it lands afterwards, whether it answers or refuses, so a slow link cannot make the pill contradict what the feed already reported. Broker-sourced membership needs the delivery daemon (auth mode) and is provisioned on a freshcotal up.
When a read does not land. A poll that fails never blanks the page. The dashboard keeps the
last values it actually read and marks them stale in the header, naming which source is stale and
why (stale: peers, activity, with the server’s own reason on hover); the next successful read
replaces the data and clears the mark.
When the observer itself goes deaf. Presence liveness is derived from heartbeat timestamps, so
a watch that hears nothing for longer than the TTL used to flip every peer offline at once. The
sidebar is an online-only list, so the page emptied while the browser’s connection pill stayed
live: that pill is the local SSE link, not the observer’s upstream. Whole-bucket silence past
TTL is now a fact about the view: the header says stale: roster (observer presence watch silent since T) and the last-known online list stays on screen until the watch delivers again.
A single peer whose own heartbeat lapses while the watch is live still drops out. A stall
shorter than TCP-level detection never reconnects, which is why this is a freshness gate on the
watch rather than a connection event.
How the all-activity page is ordered. When the page loads, the dashboard reads the newest chat
messages in the order the broker stored them and the newest direct messages, orders the two sets
together by ts, the time each sender wrote into its message, and keeps the newest of them. Chat
and direct messages are stored in separate streams with no arrival order in common, so ts is the
one key both carry. Where a sender’s clock disagrees with the broker, messages can appear in an
order different from the one they arrived in. Messages with the same ts keep the order the broker
stored them in, and chat comes before direct messages.
The all-activity read is bounded by an 8000 ms deadline, so on a slow link it can
come back SHORT rather than late: the header then says partial: activity, and the page reports how
many sources answered out of how many were asked and names the ones that did not. Each missing source
also carries its reason in the response’s reasons and in the line the server prints: the read did not finish within <deadline>ms when the deadline cut it, or the read failed: and the error when it was
refused, such as a chat read whose filter list exceeds the broker’s max_payload. A short page and a
complete one are never the same bytes. On a link too slow to finish anything the honest answer is
zero sources answered, and you keep looking at the last good data with the marker up. When the
deadline wins, Cotal also cancels the unfinished history pulls and removes their ephemeral consumers;
an abandoned poll does not keep occupying the link and starve the next one.
The open channel’s own history read is bounded by the same deadline. It is a single read, so there
is no short page to serve: it either produced the messages or it refuses, naming the channel and the
bound it exceeded, and the view keeps the messages it already had rather than emptying. A sparse
channel (fewer messages than the page) is bounded by that channel’s own first and last matching
sequences, not by walking the stream back to sequence 1. Every one of
these routes takes an optional limit, and a value that is not a whole number is refused outright
rather than guessed at. The same holds for the channel name in the URL: an escape the decoder cannot
read is the caller’s typo, not a broken server. Either way a malformed request is answered as a bad
request and never as the dashboard having broken.
A refusal names the value it received, and it renders that value so you can read it. Characters that would otherwise be invisible, rearrange the text around them, or mark part of it as an annotation come back as their escape in both the response and the line printed in the terminal, so what you read is what was actually sent. Ordinary text, accents and non-Latin scripts included, is left alone: a character that renders as itself is left as itself.
A channel name has to be the name the mesh actually uses: dotted segments of letters, digits, _
and -, or a * or > where the mesh reads a whole subtree. Anything else is refused rather than
quietly rewritten, because the wire rewrites what it cannot use and two different names would then
be one channel. That matters most on the delete button: a name that had to be rewritten would have
purged a channel you did not name, while the answer showed you the name you typed. Delete takes no
wildcard at all, so the one destructive control names one channel.
The delete request itself is capped at 8 KiB, which is far more than a channel name can be and far
less than a machine can spend. A larger body is refused with a 413 naming the limit, the server
stops reading it rather than taking it all in first and complaining afterwards, and the connection
that body arrived on is closed so the rest of it cannot be sent. It is never shortened to fit: a
trimmed name is a name you did not type, which is the thing the paragraph above exists to prevent.
Every other route takes no request body. It refuses an announced body before reading it with the
same 413 and closed connection. A request the gate refuses is closed the same way when it
announced a body, so a caller without a session cannot hold the dashboard reading an upload.
Ordinary bodyless requests keep their connection as usual.
Message bodies render Markdown (headings, lists, bold, code, blockquotes, links) across
the Monitor, channel, and DM views, parsed and sanitized client-side. Agent text is untrusted, so
raw HTML is stripped and only http(s)/mailto links survive. A peer’s name, role and activity show
as plain text, including inside an attribute such as the activity’s hover title. Long bodies still
clamp to a few lines
with a per-message show more; a channel-wide expand / collapse all in the header opens or
closes every message at once.
Append ?demo (http://127.0.0.1:7799/?demo) to render the design reference as a static
showcase with no mesh, including forward-looking elements that have no protocol backing yet
(intent badges, approval requests, task-failed alerts). Live mode renders only what the god-view
can actually read.
What each surface can see
Section titled “What each surface can see”Every surface is a read-only observer; what it sees depends on its credential:
- console TUI and web self-mint an admin god-view cred under auth, so both show the
whole space: chat, DMs, and anycast (
dmVisible: true). console --plaindeliberately narrows to the chat subtree, so DMs and anycast stay confidential in a line log even under an admin cred.- An explicit
--credslimits each surface to the credential’s grants; a chat-only observer cred hides the DM lens.
See identity and auth for the observer vs admin scopes, and MeshView for the shared model behind all three surfaces. Normative delivery and visibility rules live in the SPEC.