# CLI messaging

Message receipts distinguish broker storage from delivery and completion. Chat adds a live inbox; history and pending reads describe bounded broker observations.


## send

```bash
cotal send -t <agent> "<text>"   [--space <s>] [--server <url>] [--creds <path>]
cotal send -c <channel> "<text>"
cotal send -r <role> "<text>"
```

Choose one destination: `--to/-t` for a direct message, `--channel/-c` for a channel,
or `--role/-r` for the role's native work queue. Choose one body source: a quoted positional
argument, `--file/-f <path>`, or explicit `--stdin`; `--file -` reads stdin as `--stdin` does. There
are no implicit stdin reads. Files and stdin must contain nonempty UTF-8 text; their content is
preserved and never printed in the receipt. A file that cannot be used is refused with its path:
`TARGET_NOT_FOUND` when it does not exist, `PERMISSION_DENIED` when this account may not read it,
and `USAGE_INVALID` (exit 1, without the command's help) for a directory or text that is not UTF-8.
The refusal names the mesh the send was aimed at when this machine's records name one, and its
remedy keeps the destination with a quoted body. No destination, several destinations, several body sources or an empty message is refused. A lone
`-` is refused too, because stdin is read only through `--stdin` or `--file -`. An empty
`--to`, `--channel` or `--role` value is refused for that flag, and the remedy keeps it.
The recipient grammar is checked before connector installation or target preparation. Channel
and role names use the native subject grammar. A channel name holding `*` or `>` is refused with
`name one concrete channel, without wildcards`, and a channel or role name outside the grammar is
refused with the rule it breaks.
A direct target resolves by its full identity or an unambiguous name. Names match without regard
to case, so `bob`, `Bob` and `BOB` are one name, and a participant whose name another shares in any
case is reached by its full identity. A name that no participant
can carry, such as one holding `\` or longer than 128 characters, is refused with the name rule
before any presence lookup. A name collision refuses
with `TARGET_AMBIGUOUS` and lists the full identities that carry the name, each with its display
name, its kind (`agent`, `endpoint` or `chat session`) and the local time its process started, and a target that is not
present refuses with `TARGET_NOT_FOUND` and points at `cotal who --all`. No fuzzy match selects an
agent for you.


### Message addressing

For a channel post, repeat `--mention <agent>` to add explicit wake hints without changing the
destination. Each one resolves like a direct target and is sent as that agent's name, which is what
a receiving session compares. An instance id is refused when its name also resolves to another
participant, and a shared name refuses with the full identities to send to with `--to` instead.
Direct and role sends have no native mention field and refuse that option as `USAGE_INVALID`
(exit 1), before connecting.
A direct send may use `--reply-to <message-id>` to identify the message it answers. The sender
still has no inbox and does not promise thread continuity.

A one-shot sender displays the OS login and host as `<login>@<host>`, not the launching
seat's name. On a local static-auth mesh, the account signer binds that label to a stable
`local.cli_<sha256>` principal. Each send still uses a fresh, short-lived connection credential
with the existing operator permissions. An explicit `--creds` keeps that credential's principal,
and a signed-in mesh keeps the user's `cli` actor. Open meshes do not authenticate a sender.
A peer message is not proof of the person's authorization to perform an action.


### Storage receipt

The receipt names the destination and message identifier without repeating the body. Direct and
role sends include the broker's stored sequence. Storage does not confirm delivery, acceptance
or completion. A direct send to an agent the roster shows offline also warns on stderr:
`<agent> is offline. The message is stored and delivered when <agent> reconnects; a new agent spawned as <agent> does not receive it.`
`--json` lists it as the `RECIPIENT_OFFLINE` warning. Use `--json/-j` for the same receipt as one versioned envelope, `--plain` for a text report, or
`--verbose/-v` to show the actual sending principal. A post to an unregistered channel warns
`CHANNEL_UNREGISTERED`: it says whether the channel already has messages or this message starts it,
and lists the space's registered channels, so a mistyped name stands out. It reads that count as
`cotal history` lists channels. Its native space policy still controls publication and no registry
metadata is created. Metadata or a count it cannot read stays not checked. A role send is stored
whether or not anyone holds the role. When no present participant holds it, the send warns
`ROLE_NOT_HELD`: the message waits on that role's queue until one takes it, and the warning names the
closest role a present participant holds, or lists the roles present when none is close. A roster
that is not current within two seconds adds no warning. A message over the broker's message limit, envelope included, refuses
`UNSUPPORTED` (exit 2) with its size and the limit, and nothing is sent. A file or stdin body is read
only to one byte past that limit. A pipe or a device such as `/dev/zero` has no size to report, so
its refusal names the limit alone. A channel post the broker
refuses was not stored, so it refuses `PERMISSION_DENIED` (exit 2): the channel does not exist in
the space or this identity may not post to it. The remedy lists the channels this identity may post
to, read from the grant ledger as `cotal whoami` reads it, or with `--creds` from an agent file's own
claims. When the ledger is on another machine it says so and lists the space's registered channels
instead. A broker whose JetStream store holds more
than its cap refuses every write with JetStream error 10023 and stores nothing, so `send` refuses
`DEPENDENCY_UNAVAILABLE` (exit 2), naming the store and the `--max-file-store` cap this machine
recorded for it. Its Next is `cotal history clear --yes`, which purges the space's channel history;
a larger cap applies after that through `cotal down` and `cotal up --max-file-store <bytes>`. Any other unconfirmed publish outcome is
unknown and never retried automatically. A broker that cannot write its store sends no
acknowledgement at all, so when such a publish times out and the space's broker log on this machine
records a store write failure, the `OPERATION_UNKNOWN` message names its time, cause and file and
says the message was probably not stored. Its Next names the fix for that cause, write access to
the store for a permission error or free space for a full disk, and restarts the broker before the
message is sent again. When the broker disabled JetStream, it adds that `cotal down` names the
manager's process to end first if the manager's stop cannot be recorded.
The command sends once and exits. It does not wait for an answer, retain an inbox
or transfer replies to a later chat session.


### Sender connection

The sending principal comes from the selected space's credentials or user login. Its display name
is the OS login and host, not `COTAL_NAME`. A one-shot sender never registers as a participant.
The broker and native channel policy still decide which publications are authorized.

The transient endpoint never joins the roster and binds no inbox. With the static credential this
machine records for the space, on an open mesh, or on a user-auth mesh on your user bearer, it reads
the roster and the channel registry once when it connects and binds no watch consumer, so it leaves
nothing on the broker when it exits. A `--creds` credential reads once when its own grant allows the
batched read, as an agent credential from `cotal auth mint` does, and watches otherwise; each watch's
consumer stays on the broker for five minutes after the send exits, and a sustained send rate on a
watch can reach the broker's limit of 1000 consumers per stream, after which the broker refuses
further sends until the old consumers expire. A channel send also reads that channel's policy, with
one Direct Get where the credential allows it and with one more scan consumer otherwise. On a
user-auth mesh whose auth service predates the batched registry read, the send refuses with
`PERMISSION_DENIED` and names the release to update to. A `--creds` credential that may not
read the roster and the channel registry at all refuses with `PERMISSION_DENIED` (exit 2) before
anything is sent, and the remedy says to run without `--creds`.

A recipient can still answer a `send --to` or `send --role` with `cotal_dm` or from a `chat`
session. On a static or open mesh every send has its own principal under the same display name, so
the sender's name works only while it names one principal among the senders the recipient has heard
from and the present peers. Once the recipient has heard two sends from the same login and host, a
reply by that name refuses with `TARGET_AMBIGUOUS` and lists the principals. When none of them is
present on a current roster, the refusal says they are no longer present and names how to answer one
message. Answer by the id on the message instead (`from.id` in `chat --stdio`, or `sender_id` in a Codex injected frame and in `cotal_inbox`, with `replyTo` set to
the message id), or with `/reply` in `chat`. The reply is stored
under the sender's id in the space's DM history, which an operator's DM view such as the
dashboard's Direct messages lens shows. The `cotal send` that asked has already exited, so the reply
never reaches that shell.


## chat

```bash
cotal chat [<channel>] [--name <n>] [--plain | --stdio]   [--space <s>] [--server <url>]
```

`chat` opens one live session in the selected space, under an identity of its own. Each opening is
a new principal with its own inbox and presence, so two windows are two sessions, and leaving ends
the session. `--name` sets its display label, 1 to 128 characters, not blank and without control
characters; a longer label is refused as `USAGE_INVALID`. The default is your OS login and host with
a short suffix. The label carries no authority. When another live participant already carries the label,
the session still opens and warns on stderr, naming that participant, because a direct message by the
name refuses as ambiguous while both are present.

The mesh is resolved once, and its recorded auth mode picks the admission. No other mode is tried.

| Mode | Admission | Lifetime |
|---|---|---|
| open | Routing identity only, with no credential | Until you leave |
| static | The space's manager admits the session and mints its own credential ([Static chat sessions](/docs/control-surface/#static-chat-sessions)) | 15 minutes, never renewed |
| user | The auth service admits it under your own `cli` grant; sign in with `cotal login` first | Up to 8 hours, never extended |

On a static mesh, `chat` reads the space's service registry before it asks the manager for a
session. When the registry lists no live manager, as after `cotal service stop stack-manager`, it
refuses `TARGET_UNAVAILABLE` (exit 2) at once and names bare `cotal up`, as the run verbs do. A
manager that stays registered but does not answer, such as one that crashed, is refused
`TARGET_UNAVAILABLE` too once its answer deadline passes, with `cotal status` as the next step,
because no session was asked for.

A static or user session shows its end time in the banner, and says when it ends a minute before.
After a session ends, `--plain` answers every line with how to leave, and `/quit` or the end of
input leaves.


### Channel admission

A named channel is the session's initial channel. On a static or user-auth mesh it is also the
session's whole read and post list, so a session opened without one can send and receive direct
messages only. A send, join or channel mode outside that list refuses with `PERMISSION_DENIED`
before anything reaches the broker. `/leave` does not change that list, so `/join` returns the
session to a channel it left. On a session opened without a channel, the refusal says to
leave and run `cotal chat <channel>` with the flags this session was started with, such as
`--stdio` and `--space`. Before the session is admitted, `chat` reads the channel registry on the resolved
mesh the way `send` does, under the same one-shot identity, and says on stderr whether the channel
is registered, unregistered, or could not be read. The space's native policy decides whether a send
is allowed, and nothing creates registry metadata.

When the channel's replay is on, the session holds its retained messages in the replay window in
its inbox. The inbox keeps at most 500 unread messages, so a channel that holds more replays only
its newest 500 and the oldest are dropped. One `inbox` gap reports how many were dropped.
`cotal history <channel> --limit <n>` reads further back. `channel show` names the bound in its
`Chat replay` row.


### Session views

The terminal view needs a terminal for input and output; without one, `chat` refuses and names
`--plain` and `--stdio`. `--plain` is the line flow, with a separate confirmation for each send,
and it writes its punctuation in ASCII as every plain report does.
`--stdio` runs the NDJSON participant protocol for programs: requests on stdin, records on stdout,
diagnostics on stderr. Closing stdin ends the session. `--stdio` does not combine with `--plain`,
and `chat` has no `--json`. Under `--no-input` only `--stdio` runs.

In `--plain`, a line you type becomes the draft, and it is sent only when you answer `y` to the
question that follows. Any other answer keeps the draft unsent. A slash command typed as the answer
runs, and the question is asked again. A new draft that replaces a kept one says so, with the kept
draft's length. At the end of input an unsent draft or an unfinished `:multi` message is reported
by its length and never sent. `:to` lists the recipients by number, and `:to <target>` sets one directly:
`#<channel>`, `@<name>`, `@<owner>.<actor>` or `role:<role>`. A target in another form, such as a
name without `@`, is refused with that list of forms, and the numbered list opens. An `@<name>`
that names no present peer and no sender the session heard from is refused there with the reason a
send to it would get, and the numbered list opens. A `#<channel>`
holding `*` or `>` is refused with the sentence `cotal send -c` gives. In the list, an empty line
keeps the current recipient, and `/quit` leaves. `:multi` starts a message of several lines, which a line
holding only `.` ends. A line starting with `/` runs a slash command, and `//` sends a line that
starts with `/`. `/help` lists the slash commands, in `--plain` and in the terminal view. A slash
command that takes a channel reads it with or without its `#`.
`/history` reads the current channel's retained messages. On an open mesh, which runs no delivery
daemon, it reads them the way `cotal history` does.

A direct message to an agent the roster shows offline is stored, and the view adds a notice that
the agent receives it when it reconnects, as [`send`](#send) warns. `--stdio` carries that roster
status as `recipientStatusAtSend` in the stored result.

Message times show in local time, with the date when it is not today, the same way `history`
shows them.


### Direct-message addressing

`/reply` answers the latest direct message or role task the session received. It addresses the
sender's principal and quotes the message id, with no name lookup. A sender shows by display name,
and with its principal when another principal in the session carries the same name, ignoring case.
`--plain` cannot relabel a line it already printed, so it prints one line naming those principals
when a name becomes shared. A typed `@<name>` must name one principal among the present peers and
the senders the session heard from. A name that more than one of them carries refuses and lists
them, so type `@<owner>.<actor>` instead. A target that parses as `<owner>.<actor>` goes to that
principal whether or not it is present or was heard from, and is never matched against display
names. A peer whose display name parses as a principal therefore cannot be reached by that name;
`/reply` and its full principal still reach it. Typing the session's own principal refuses with
`USAGE_INVALID`, and so does its own name when no other participant carries it. A `--stdio` `dm` request resolves its `to` the same way.


### Declared options

Every admission presents the session as an endpoint, so `--kind endpoint` is accepted and changes
nothing. `--kind agent` refuses with exit 1 because a chat session never presents as an agent, and
so does any other kind.

`--role`, `--link`, `--token`, `--creds` and `--lifecycle-uid` are declared but no admission
supports them yet, so each refuses with exit 2. A role would put the session on that role's work
queue, and the view has no way yet to answer the sender of a task. The static and user-auth
admissions take no role.


### Session close

A session that ends by leaving, by end of input or at its lifetime exits 0 when its connection
close was confirmed and it was retired, or was an open-mesh session with nothing to retire. It exits
4 when its connection close was confirmed and its retirement was requested but not confirmed. It
exits 3 when the retirement outcome is unknown, its connection close was not confirmed, or no close
was observed. A session that cannot reconnect because its inbox is gone from the broker, as when the
space restarted and retired it while the connection was down, ends by itself with that reason and
exits 5 when its close is confirmed. On a static mesh, leaving waits up to 30 seconds for the
manager to answer the retirement. When the delivery daemon does not answer, the manager waits 15
seconds for it, then reports the retirement pending and names the daemon, and the session exits 4.
In the terminal view Ctrl-C and Ctrl-D leave. With `--plain` or `--stdio`, Ctrl-C ends
input and the session closes, then exits 130. A terminate signal exits 143 and a hangup 129. A
signal that arrives before the view starts is held: no session is opened if admission has not
begun, and a session admitted meanwhile is closed at once. The exit status is still the signal's.
When the reader of `--plain` or `--stdio` output closes it, as `| head` does, input ends the same
way and the session closes, then exits with its close status: 0, 3 or 4.

Before a session is admitted, an error prints on stderr only, since `chat` has no `--json`. An
error that ends `chat` exits 1 for `USAGE_INVALID` or `SCHEMA_INVALID`, 2 for any other refusal, 3
when the outcome is unknown and 5 when it failed.


## chat --stdio protocol

stdin carries requests and stdout carries records, one JSON object per line, in UTF-8 with LF line
ends. A CR before the LF is dropped, a blank line is ignored, and a last line with no LF still
counts when input ends. A line longer than 1048576 UTF-16 code units is discarded up to its next LF
and reported once with an `error` record. Input is decoded as UTF-8 with replacement, so bytes
that are not valid UTF-8 become U+FFFD. In ordinary string content the line can still parse: a
`text` value is sent with U+FFFD in their place, and a field that accepts only some characters,
such as `id`, refuses. In an escape sequence such as `\` or `\u` and its four digits, or outside a
string, they make that line's JSON parse refuse. stdout carries records and
nothing else. The session's diagnostics go to stderr, prefixed `cotal chat: `, and never repeat a
message body.


### Request grammar

Every request and record carries `"schemaVersion":1`. A request names its `type` and an `id` you
choose, 1 to 128 printable ASCII characters, and its `result` record repeats that id. A request that
reuses the id of one still in flight is refused with `USAGE_INVALID`, in a result under that same
id that follows the first request's result. Requests run one at a time in arrival order, and a
refusal waits its turn too, so every `result` and `error` record comes back in the order its line
arrived. `message`, `presence` and `gap` records can arrive between results, so match each result
by its id. A missing field, a field the type does not take or a malformed value refuses with
`USAGE_INVALID` before the session sees the request. A channel must be a valid channel name with no `*` or `>`, and is refused with the sentence `cotal send -c` gives for that name. A role must be a
valid role name. A `text` that is empty or only whitespace is refused with `the message is empty`,
as `cotal send` refuses an empty message.

| `type` | Fields | Result |
|---|---|---|
| `send` | `channel`, `text`, optional `mentions` (names of 1 to 128 characters) | `stored`: `messageId`, `channel`, `registered` |
| `dm` | `to` (a display name or `<owner>.<actor>`, 1 to 512 characters), `text`, optional `replyTo` (a message id, 1 to 512 characters) | `stored`: `messageId`, `seq`, `duplicate`, `recipient` (`id`, `name`) |
| `anycast` | `role`, `text` | `stored`: `messageId`, `seq`, `duplicate` |
| `status` | `status` (`idle`, `working` or `waiting`), optional `activity` | `ok` |
| `attention` | `mode` (`open`, `dnd` or `focus`) | `ok` |
| `channelMode` | `channel`, `mode` (`normal`, `quiet` or `muted`) | `ok` |
| `join` | `channel` | `ok`: `joined`, `backfilled`, `durable`, optional `reason` |
| `leave` | `channel` | `ok`: `left` |
| `inbox` | optional `peek` (default `false`) | `ok`: `peek`, `messages` |
| `roster` | none | `ok`: `rows`, `view` |
| `channelInfo` | `channel` | `ok`: `channel`, `registered`, `replay`, `readable`, `postable`, optional `description` and `instructions` |
| `connection` | none | `ok`: `state`, `identity`, `bufferedInbox`, `joinedChannels`, `attention`, `channelModes` |
| `invoke` | `endpoint` (1 to 200 characters), `command` (1 to 128), optional `args` (an object) and `timeoutMs` (an integer from 1 to 120000, default 30000) | `ok`: the endpoint's data, or `null` when it returned none |

`identity` in the `connection` result names the session, and no other record names its space. It
has `space`, `mode` (`open`, `static` or `user`), `principal` (`<owner>.<actor>`), `lifecycleUid`,
`label`, the `allowSubscribe` and `allowPublish` ceilings, and `expiresAt` in epoch milliseconds
when the session has a set end. A roster's `view` is `current`, `unpopulated` or `stale`, and its
`rows` are complete only when it is `current`. A row has `id`, `name`, `kind` and `status`, and
`role`, `activity` and `sessionKind` when they are set. A `status` request with an `activity` sets
both in one presence write, so a peer gets one `updated` record carrying both.

A `result` record has `type`, `id` and `outcome`. With `ok` or `stored` it carries `result`, which
is `null` when the request returns nothing. With `refused`, `unknown` or `failed` it carries `error`
with `code`, `message` and, when the native layer gave one, `cause` with its own `code` and
`detail`. `refused` means nothing happened. `unknown` (`OPERATION_UNKNOWN`) means the effect may
have happened. The session never retries it, and it reports an unexpected failure of its own the
same way. `failed` (`OPERATION_FAILED`) is a step that did not complete and had no effect. `stored`
means the broker's stream accepted the message. It does not say anyone read it.


### Unsolicited records

The session also writes these records on its own:

| `type` | Fields | When |
|---|---|---|
| `message` | `message`: `id`, `ts` (epoch milliseconds), `kind` (`channel`, `dm` or `anycast`), `from` (`id`, `name`, optional `role`), `channel` or `service`, `text`, optional `replyTo`, `mentionsMe`, `historical`, optional `deliveryCount` | A message arrived |
| `presence` | `change` (`joined`, `updated` or `left`), `row` (a roster row) | A roster row changed, this session's own row included. As the session opens, its own row and each member already online come as `joined` |
| `gap` | `scope` (`live` or `inbox`), `reason` | Messages may have been missed |
| `error` | `id` (`null`), `error` | A line was refused before an id could be read from it, or was too long |
| `end` | `reason`, `close` | The session ended. Nothing follows it |

A `presence` record's `joined` is a member coming online and `left` one going offline. `updated`
means a field the row shows changed, such as `status` or `activity`. A presence change the row does
not show, such as a member's attention or channel modes, writes no record. `cotal who --all --json`
shows attention.

A message's `text` is untrusted. JSON escapes its control characters, so a body can never start
another record. `channel` is set on a channel message and `service` on an anycast. `mentionsMe`
means the message's mention list holds this session's label, ignoring case. The sender writes that
list, so treat it as a hint. `deliveryCount` appears only on a durable delivery and is 1 the first
time. Above 1, an earlier copy went unacknowledged and may already have been shown.


### Acknowledgement

A message is acknowledged once its record is handed to stdout. A DM or anycast is written when it
arrives. A channel message is held in the inbox instead when its channel is quiet, when attention is
`focus`, or under `dnd` unless it mentions the session, and join backfill is held too. A muted
channel's messages are dropped. `inbox` with `peek` lists the held messages and leaves them held.
Without `peek` it returns them, and they are acknowledged once its result is handed to stdout. A
message still unacknowledged when the session closes is left unacknowledged and does not come back
to this session.

`join`'s `backfilled` counts the messages the join read back from the channel's retained history:
those sent before the join and within the channel's replay window, not counting this session's own.
It is 0 when the session is already on the channel or replay is off for it. Each of them then follows
the rules above. One the session already handed over, holds or dropped is not held again, and one on
a muted channel is dropped, so the inbox can gain fewer messages than `backfilled` says. The session
remembers at least the last 4096 messages it handled for this.

A `live` gap means the connection dropped or is reconnecting, so live channel messages may be
missed until it returns. Its reason defaults to
`connection <state>: live channel messages may be missed until it returns`. An `inbox` gap means
the held buffer was full: channel messages were dropped and direct messages were left for the broker
to redeliver. Its reason counts each kind that happened. One gap covers the drops of up to a second,
and a replay past the bound is one gap when the session starts. When a write waits for stdout to
drain, or more than 4 MiB of records are waiting, the session stops reading stdin until stdout drains.


### End record

`end` is the last record. Its `reason` is `input closed` when stdin ended, `interrupted`,
`terminated` or `hangup` when SIGINT, SIGTERM or SIGHUP ended input, and `session ended` when the
session ended by itself, for example at a static session's expiry. A session that could not
reconnect gives that reason instead, and its `close` carries it as `lost`.
When stdin ends, the requests already read finish first. `close` has `retirement` (`retired`,
`requested`, `unknown` or `not-applicable`), `connectionClosed` (`confirmed` or `not-confirmed`) and
`message`. A static session's `close` can carry `credentialValidUntil` in epoch milliseconds: the
broker accepts its credential until then unless its eviction was verified. Once the session has
ended by itself and before the `end` record, a request refuses with `LIFECYCLE_BLOCKED` and
`the session is ending; no new requests are accepted`. When the reader closes stdout, stderr says
`stdout closed by the reader; ending the session`, and no later record is written, `end` included.
Any other failed write, such as to a full disk, says `stdout could not be written; ending the
session` and the one line about lost output, and the session ends the same way with exit 3.


## channel

```bash
cotal channel
cotal channel set <name> [--replay | --no-replay] [--window <duration>] [--delivery <live|durable>] [--desc <text>] [--instructions <text>]
cotal channel defaults [--replay | --no-replay] [--window <duration>] [--delivery <live|durable>]
cotal channel show <name>
cotal channel members <name> [--follow] [--json | --plain]
cotal channel remove <name> [--purge] [--yes]
```

`channel` has the shorthand `ch`; `remove` also accepts `rm`. The inventory shows configured
metadata, not every channel with traffic. A saved entry whose value does not decode is listed
under `Not decoded` with its key and the decode error, and the key is left in the registry. Its
channel resolves to the default policy, as an endpoint's registry watch does. `show` of that
channel and `defaults` when the defaults key does not decode say the same. A `set` or `defaults`
that would write such a key refuses with `CONFLICT` and leaves the bytes as they are. `remove`
of that key removes the entry. `show` includes inherited replay and delivery settings.
`members` reads the broker-derived membership feed. Its timestamp is the last successful feed
poll, not proof of current activity or access. With `--follow --json`, it emits membership NDJSON
records. A missing feed remains unknown rather than an empty confirmed census. Without `--follow`, a
census with no member for a name that has no saved entry and no retained messages is refused with
`TARGET_NOT_FOUND`, listing the registered channels, or naming the registered spelling when only the
case differs. A channel with a saved entry or retained messages and no member reads as empty. A
follow shows the census again only when it changes: a poll that observes the same members writes no
record and prints nothing, and the `end` record's `lastAsOf` is the last poll the follow read. A follow's
view offers no command to follow. A lost connection is
one `TARGET_UNAVAILABLE` error record, or one line, while the follow re-establishes it, and a `gap`
record and the current census follow once the feed answers again, so a follow outlives a broker
restart. A credential that can no longer connect ends the follow with the refusal a one-shot
command with that credential gets, as [`history --follow`](#history) does. The delivery service
writes that feed, and an open mesh runs none, so `members` refuses an open mesh with `UNSUPPORTED`.

Removal keeps retained history unless `--purge` is present. The confirmation names the channel,
space and history scope. Scripts pass `--yes`. A name with no saved entry and no retained messages
is refused with `TARGET_NOT_FOUND` before the confirmation, listing the registered channels, or
naming the registered spelling when only the case differs. Removing a channel that has retained
messages but no saved entry is a `no-op`, and its Metadata row says `no saved entry`; `--purge`
still deletes that channel's retained messages. In `--json`, `result.removed` says whether a saved entry was removed. Neither
removal nor replay settings revoke grants or stop participants; future posts can create retained
history again. A `set`, `defaults` or `remove` that the broker refuses for the credential's grant
applied nothing, so it is refused with `PERMISSION_DENIED` (exit 2), naming the credential's profile,
the write it lacks and the subject the broker refused. With `--creds`, the Next is to run it without
`--creds`, on the operator credential this machine records. A remove whose credential may not read
the registry skips the name check, and the broker's answer to the remove decides. A change that the
root's default `.cotal/channels.json` would undo at the next start warns that it will, as the
[channel seed file](/api-reference/cli-startup/#channel-seed-file) section says.


| Flag | Default | Meaning |
|---|---|---|
| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |
| `--replay` / `--no-replay` | none | `set`/`defaults`: replay history to new joiners, or not. They take no value: `--replay off` is refused with the flag to use |
| `--window <duration>` | none | `set`/`defaults`: replay window, for example `24h` |
| `--delivery <live\|durable>` | none | `set`/`defaults`: choose the live-only or durable-backstop delivery class; history retention is separate. Shell completion offers both values |
| `--desc <text>` | none | `set`: channel description |
| `--instructions <text>` | none | `set`: instructions shown to joiners |
| `--purge` | off | `remove`: also delete the channel's retained history |
| `--yes` / `-y` | off | `remove`: confirm the stated channel and deletion scope without a question |
| `--follow` | off | `members`: follow broker-observed membership changes |
| `--json` / `-j` | off | One JSON document; `members --follow --json` emits NDJSON records |
| `--plain` | off | The same report without color |


### Channel policy

Inspects and edits the channel registry: replay and delivery policy, description, and joiner instructions. `live` has no per-member backstop; it does not disable ACL-bounded retained history. Changing a channel's delivery class does not settle already pending durable copies. `--delivery durable` requires a registered static-auth or user-auth space; an open or unregistered target has no established delivery-service support. A recorded auth mode does not prove that daemon is currently healthy. ACL
semantics (who may read or post) are set at mint / provision time, not here; see
[Channels and permissions](/api-reference/channels-and-permissions/). On a user-auth mesh, `list` rides your
own login as is; `set` and `defaults` edit the registry over a short-lived
channel-writer view, which needs ledger scope `admin` ([Identity & auth](/docs/identity-and-auth/)).
On a remote user-auth mesh that view is served by the public exchange; space-history `purger`
and the read-only admin view are not.

`set` and `defaults` change only the fields you name. Each write lands on the registry entry as it
was read, so writers changing different fields of one channel at once all keep their change: one
that loses the race reads the entry again and merges again. A write that keeps losing to other
writers refuses with `CONFLICT` (exit 2) and saves nothing. Two writers setting the same field leave
the value of whichever wrote last. When the entry already holds every value you name, nothing is
written: the receipt is `Channel unchanged` or `Channel defaults unchanged`, and the outcome is
`no-op` (exit 0).

With the static credential this machine records for the space, or on an open mesh, `list` reads the
registry with one Direct Get and leaves no consumer on the broker. On a user-auth mesh your login has
no Direct Get row on the registry, and neither has an agent credential passed with `--creds`, so
`list` scans it with a consumer that stays on the broker for five minutes after the command exits.


## history

```bash
cotal history [<channel>] [--dms] [--since <time|seq>] [--limit <n>] [--from <name>] [--grep <text>] [--follow] [--json | --plain]
cotal history clear [--dms] [--yes | --dry-run] [--json | --plain] [--space <s>]
cotal history limit [--max-age <age>] [--per-sender <n>] [--yes] [--json | --plain] [--space <s>]
```

| Flag | Default | Meaning |
|---|---|---|
| `--space <s>` / `--server <url>` / `--creds <path>` | resolved mesh | Target mesh |
| `--dms` | off | Also read DM history, which needs a DM read grant. With `clear`, also clear DM history |
| `--since <time\|seq>` | none | Only messages after an ISO 8601 date and time, an age such as `30m`, `2h` or `1d`, or a chat stream sequence. A date, or a date and time without an offset, is local time |
| `--limit <n>` | 50 | How many of the newest retained messages to read, 1 to 1000 |
| `--from <name>` | none | Keep messages from this sender name or id |
| `--grep <text>` | none | Keep messages whose text contains this, ignoring case |
| `--follow` | off | Keep listening and print each new message |
| `--json` / `-j` | off | One JSON document, or with `--follow` one JSON record per line |
| `--plain` | off | The same report without color or marks |
| `--yes` / `-y` | off | `clear` and `limit`: confirm without prompting. Scripts and `--json` require it |
| `--dry-run` | off | `clear`: show the selected target and streams without connecting, and purge nothing |
| `--max-age <age>` | none | `limit`: delete a channel post once it is older than this, from `2m` to `3650d` |
| `--per-sender <n>` | none | `limit`: keep this many of each sender's newest posts on each channel, at least 1 |

Reads the newest retained messages in the selected space, newest last: one channel, or every
channel the identity may read. The channel may be written as `#general`, and a name holding `*` or
`>` is refused with `name one concrete channel, without wildcards` before connecting. A channel with
no retained messages and no saved entry warns `CHANNEL_NOT_FOUND`, names the channels that hold
messages, and reports no replay policy for it. When the space's broker runs on this machine and logged
damage in a stored file since it started, `history` warns `STORE_DAMAGE_LOGGED` with the broker's
words, because it serves only what it can still read. The read stays complete, as it read the stream
the broker serves. A broker whose log is not on this machine is not checked. `--limit` bounds the read, and `--since`, `--from` and `--grep` then
filter that window on this machine. They do not search the whole stream, and with older retained
messages behind the window the report says that `--from` and `--grep` searched only the messages
read. At `--limit 1000`, the most it reads, the report says older retained messages are not shown and
names no larger limit. `result.window` says how many messages were read, whether older retained
messages exist that the window did not reach, and whether the window goes back to `--since`. The report names the local time and the ISO instant a
time `--since` resolved to, or the chat stream sequence a bare number was read as. A four-digit
number from 1900 to 2099 is still a sequence, and the report says to write a date in full. A bare
number past the chat stream's newest sequence names no message, so it is a `USAGE_INVALID` refusal
that names that sequence and how to write a date. An age outside the supported date range, a date
that is not on the calendar, or a sequence that cannot be represented without rounding is a
`USAGE_INVALID` refusal before connecting. A time is an ISO 8601 date such as `2026-10-23`, or a date and time such
as `2026-10-23T09:00:00Z`, so `-5` and `10/23/2026` are refused. A date alone, or a date and time
with no offset, is local time, so `--since 2026-10-23` starts at local midnight.
The report shows each message's local time, with its date when it is not today; `result.items[].ts`
is the ISO time. A DM row shows its recipient's name when the roster carries that principal or a
message in the window came from it, and the principal otherwise. `--dms` reads the roster for those
names, waiting up to two seconds for it. `result.items[].to` is the principal.
A line break or other control character in a message's text shows as its `\uXXXX` escape in the
text and `--plain` reports, so a message cannot clear the screen or start a row of its own.
A row shows the first 500 characters of a longer message and marks the cut with the message's size.
`--json` carries the text as it was sent, in `result.items[].text`.

Retained history is not every message sent. The chat stream keeps a post until its sender has posted
the space's per-sender count of newer ones on that channel, or until it is older than the space's age
limit when one is set. A new space keeps each sender's newest 1000 posts on each channel with no age
limit, and `history limit` changes both.
Past the count a busy sender's oldest posts are deleted while other senders' posts from the same time
remain. `result.retention` names both limits, `oldestAt` (the time of the oldest post the stream
still holds), and each sender at the count on the channels read. The report prints that time and a
`gap` line for each such sender. A sender at the count may have posted only that many, so a gap says
their older posts may be gone. It describes the stream, not a partial read. `result.replay` gives each channel's replay policy for new members, read from the
channel registry. DM gaps are not reported.

`history limit` sets how long the space keeps channel posts. `--max-age` deletes a post once it is
older than the age, and `--per-sender` keeps that many of each sender's newest posts on each channel.
One limit holds for every channel in the space. Give either flag or both, and the other part keeps
its current value. An age is a whole number of `s`, `m`, `h` or `d` from `2m`, the chat stream's
duplicate window, to `3650d`. A space has no age limit until one is set, and `history limit` does
not set no age, so on such a space `--per-sender` needs `--max-age` as well. A bad value is a `USAGE_INVALID` refusal before connecting.
Run it on the broker host from the space's root: it mints a one-shot provisioner credential from the
root's trust, as `up` does, and a root without that trust is refused with `PERMISSION_DENIED`. A
space with no chat stream is refused with `TARGET_NOT_FOUND`; `cotal up` creates the stream.

A lower limit deletes the posts it no longer keeps as soon as the broker applies it. In a terminal,
lowering either part asks first, and the question comes with what the stream held when it was read:
each sender on each channel with more posts than the new count and how many of them go, and every
post older than the new age, with the time of the oldest retained post. Raising asks nothing, and
posts deleted under an earlier limit do not come back. Scripts and `--json` need `--yes`, checked
before any credential is minted. The update carries the limit that was read, and a stream that holds
another one by then is refused with `CONFLICT` and left as it is. The read and the update are two
steps, so two operators changing the limit at the same moment can still overwrite each other. An
update sent without a confirmed outcome is `OPERATION_UNKNOWN` and is not retried. The receipt names
the old and new limit, and running it again with the same values changes nothing and asks nothing.


### Read failures

A channel the identity may not read is refused with `PERMISSION_DENIED` (exit 2), never shown as
empty. On a user-auth mesh each channel read uses the connection's broker-enforced channel grant.
Revocation follows the effective credential and connection retirement, as it does for live channel
reads. Concurrent commands sharing one identity wait for the broker to release its transient history
reader; no command deletes another reader. Each channel has a ten-second read budget, with a
thirty-second budget for the whole channel walk. A busy or cut-short read fails with `OPERATION_FAILED` (exit 5) rather than showing
an empty conversation. Reading every channel leaves out channels the grant does not cover and names
them in a `CHANNELS_DENIED` warning. DM history and `--since` by sequence are not available to a
user-auth identity. An agent's `--creds` credential reads the same way, one channel at a time under
the lifecycle its own history grant names, with the same limits. A channel that grant leaves out is
refused with `PERMISSION_DENIED` before it is read, quoting the channels the grant does name, or
"no channel" for a grant that names none, and its `CHANNELS_DENIED` warning names them too. An
observer or admin credential reads across channels.
A part that could not be read is a warning and a `partial` run (exit 4): the
retention, a replay policy, the channel list when a channel is named, or one channel of several.

`--follow` prints the window and then each new message until interrupted, each as one line in the
form `watch --plain` uses: `[<space>] <time>  <sender>  <destination>  <text>`, with a 24-hour local
time and `#<channel>` or `DM to <principal>` as the destination. A purge by `history clear` is not
announced to a follow or to `watch`; messages sent after it keep arriving. A lost connection is
reported in one line while the follow re-establishes it, so a follow outlives a broker restart. A
credential that can no longer connect ends the follow. It may have expired, or the broker may now
reject it, as after `reset space`. The follow then ends with the refusal a one-shot command with that
credential gets, `AUTH_REQUIRED` or `PERMISSION_DENIED`, and its Next. Without `--creds` on a
static-auth space the follow mints its own credential from the space's trust, so when the broker
rejects it the Next says to run the follow again, which mints one from the current trust. With
`--json` each line is one record: `{"schemaVersion":1,"stream":"history","type":<type>,"target":…,"receivedAt":…,"data":…}`.
The first record is a `snapshot` holding the window, then one `event` per message. An event has
no `seq`, because the live feed carries no stream sequence; the snapshot's items keep theirs. To
read on from a follow, give `--since` the last event's `ts`. An `error`
record names a failure on the live feed, and a lost connection is one `TARGET_UNAVAILABLE` error. A
`gap` record says the connection was re-established and messages sent while it was down are not
shown. An `end` record always closes the stream. The exit code is the snapshot's, the refusal's
when the credential ended the follow, or 130 or 143 when SIGINT or SIGTERM ended it.

`history clear` purges retained history from every channel in the space it targets; `--dms` extends
it to direct-message history. It refuses extra channel names, read filters and `--follow` before
purging. These inputs cannot narrow a clear operation. It is the same operation as [`clean history`](/api-reference/cli-shutdown/#clean). On a user-auth mesh the purge rides a short-lived
purger view over your login, which needs ledger scope `admin` ([Identity & auth](/docs/identity-and-auth/)).
A purge the broker refuses for the credential's grant purged nothing, so it is refused with
`PERMISSION_DENIED` (exit 2), naming the credential's profile; with `--creds`, the Next is to run it
without `--creds`, on the operator credential this machine records.


## pending

```bash
cotal pending <agent> [--durable <exact-dm-durable>] [--limit <1..100>] [--space <s>] [--json|--plain]
```

The finite read reports the selected DM durable's native pending count, ack
floor, delivered sequence, frontier, retention limits and up to 20 candidate
IDs by default. JSON and plain output use the common one-document result. A
name must match one observed roster entry; an exact principal ID wins over a
display-name match, and an ambiguous display name refuses. `--durable` reads a
previous lifecycle by its complete durable name without substituting a
same-named current agent. An absent or unreadable durable is not `pending 0`.
The caller needs the native admin read authority; user-auth has no equivalent
view here and refuses without a static or bare fallback. A failed read does
not prune a locally saved mesh registration.

Candidates are read without acknowledging DMs. The transient read consumer is
deleted after the bounded pass; an unconfirmed cleanup or candidate read is
reported as partial. Candidate IDs are not evidence of delivery, a missing
message or complete history. Each candidate's `from` is the sender principal
parsed from its stored message subject, which the broker binds to the sender's
credential. The sender's own card is not printed, and a candidate that cannot
be decoded is withheld and reported as partial. Recipient contents are not
printed. The retained `deliver pending` spelling keeps its earlier output shape.


## Clearing retained history

`history clear` purges all retained channel history in the space it targets: `--space`, else this
folder's space, else the selected one. It takes no channel argument. `--dms` also requests DM
history under the existing purger authority. In a terminal, confirmation names the space, broker
and space-wide scope and defaults to cancellation. Scripts
and `--json` require `--yes`. The old `--force` spelling refuses with the replacement.

`history clear --dry-run` resolves the target and names the affected streams offline. It issues no
caller credential, connects nowhere and does not check authorization or retained message counts.
A space that is prepared but not started is refused with `TARGET_UNAVAILABLE` (exit 2), and the
refusal says how to start it.
The purge uses the existing purger instrument or one-action user purger view, not a broader caller.
On a user-auth mesh whose grant lacks scope `admin`, the purger view is not issued and the refusal
is `PERMISSION_DENIED` (exit 2). Its remedy is the identity provider's own sentence, which names
the missing scope and the whole-row re-grant that adds it.

JSON and plain receipts retain each native stream acknowledgement and message count. A later
refusal after an acknowledged purge is partial. An unconfirmed submitted purge is unknown and is
not retried. A confirmed clear interrupted by SIGINT, SIGTERM or SIGHUP exits 130, 143 or 129 and
says which purges were acknowledged or submitted, or that none was submitted and no history was
cleared. Clearing history does not stop agents, remove stream definitions or reset identity;
new messages may arrive after the acknowledged purge.


### History window

See the [current command contract](/api-reference/cli-messaging/#history) for this behavior.

### Follow stream

See the [current command contract](/api-reference/cli-messaging/#history) for this behavior.
