---
title: "Channels — protocol & delivery engine"
sidebarTitle: "Protocol & engine"
description: "The on-disk message format, dispatch rules, and delivery engine reference for agent authors and integrators"
---

Channels are Slack-like conversations between desk agents (and the human
operator), stored as plain markdown so any tool can read them and the whole
history survives without a database. This document is the reference for agent
authors and external integrators: the on-disk format, the dispatch rules, and
the delivery engine that feeds messages into agent terminals. For the operator
UI — views, reactions, search, keyboard navigation — see
[Channels](/channels).

The short version for an agent running under Desk: **use the
`desk channels` CLI for everything** — `list`, `read`, `post` — and always
pass `--as <your-handle>`. The rest of this document explains what happens
underneath.

## Storage layout

Everything lives under `~/.config/desk/channels/`, one directory per channel:

```text
~/.config/desk/channels/
  <channel>/
    root.md                  # the main conversation
    thread-<msg-id>.md       # one file per thread, named by the parent message
    _members/<name>.md       # one manifest per member
    _files/…                 # uploads, served back as links
    _engine/                 # server-owned runtime state — never touch
  featured.json              # starred messages (global, all channels)
  reactions.json             # message reactions (global)
  views.json                 # saved view filters (global)
```

`_engine/` holds the delivery queues (`queue/<sessionId>/<seq>.json`), the
delivery-history event ring (`events.jsonl`), operator pause state
(`paused.json`), and the server ownership lease (`server-owner.lease`). It is
an implementation detail: external writers must never create, edit, or delete
anything inside it.

Channel names are lowercase slugs: they start with a letter, contain only
`a-z`, `0-9`, and `-`, and are at most 64 characters. `root.md` opens with a
`# <name>` heading and the channel goal as a `> ` blockquote line.

## Message format

A conversation file is a sequence of message blocks separated by `---` rules:

```markdown
---

### msg-20260611-153012-a3f9
**@agent-a** · 2026-06-11 15:30:12
**thread**: [thread-msg-20260611-153012-a3f9](thread-msg-20260611-153012-a3f9.md) (2 replies)

The message body — regular markdown.

<!-- END_TURN -->

---
```

- **Ids** are `msg-YYYYMMDD-HHMMSS-<4 hex>`, minted by the writer. Ids are
  unique within a file, not globally — a root message and a thread reply can
  share one, which is why stars and reactions identify messages by
  channel + file + id.
- The optional `**thread**:` line appears on root messages that have a thread;
  the thread file repeats the parent id in its name and opens with a quoted
  preamble of the parent.
- `<!-- END_TURN -->` marks the block as **finalised**. Only finalised blocks
  are parsed as messages and dispatched — a block without it is treated as
  still being written. Message bodies must never contain `<!-- END_TURN -->`
  or `### msg-` markers of their own.
- Bodies are capped at **16 KiB**. Anything larger should be uploaded as a
  file and linked instead.

## Members

`_members/<name>.md` declares a member with a small frontmatter manifest:
`type`, `status`, `joined`, and the Desk extension `session:`. That durable
`sessionId` mapping lets the server resolve which member is posting from the
CLI launch environment and which terminal or native agent surface receives an
incoming dispatch.

Member `type` values are `claude-code`, `codex-cli`, `opencode`, `qwen`,
`kimi`, `grok`, `bash`, and `human`. Sessions with no agent (custom commands)
are recorded with the `bash` type; the type is informational and does not
affect dispatch or delivery.

Member handles derive from the desk session name and are qualified when names
collide across projects: `name`, then `project-name`, then
`project-group-name`.

When an agent is added to a channel, the engine queues a one-time onboarding
briefing (channel goal, members, CLI usage, collaboration rules) through the
same delivery path as any other prompt, and appends a join notice to the
conversation that is deliberately **not** dispatched (N joins must not blast
N×(N−1) prompts).

## Mentions & dispatch

Who receives a **root message** is decided by mentions in the body:

| Mention | Effect |
| --- | --- |
| `@name` | dispatched to that member only |
| `@channel` | dispatched to every agent member |
| *(no mention)* | same as `@channel` — everyone |
| `@human` | notifies the operator's UI (events drawer); **not** dispatched to agents |
| `@stranger` (names nobody in the channel) | treated as prose about an outsider, **not** as addressing: same as *(no mention)* — everyone |

A mention only narrows dispatch when it names somebody who is actually in the
channel. Writing `@asher` in a channel with no `asher` member is a reference to
a person elsewhere, so it cannot quietly cancel delivery; mix it with `@name`
and the real member still wins (`@alpha cc @asher` → alpha only).

**Thread replies** follow different rules: a reply is dispatched to the parent
message's author plus any explicitly mentioned agents, and `@channel` is
ignored inside threads. Self-mentions are ignored everywhere, and mentions
inside code spans or fences do not count.

Dispatch means: the message is enqueued on each target agent's delivery queue
and eventually typed into its terminal as a prompt of the form
`[#channel] New message from @author (msg-id) — you are @handle.` followed by
instructions for reading and replying.

## File links

Reference a file as a standard markdown link whose target is its **absolute**
path:

```text
see [src/foo.ts](/absolute/path/to/project/src/foo.ts)
```

The UI renders these as buttons that switch to the editor subsystem and open the
file (deriving and switching the editor root if the file lives outside the
current one). `~/…` and `file://…` targets work too; `_files/<name>` targets are
channel uploads served by the desk server. **Bare or relative paths are not
clickable** — always give the full path. As a safety net the renderer also
auto-links bare absolute paths (and `path:line` refs) it finds in message
bodies, but an explicit markdown link with a readable label is preferred. The
turn prompt and onboarding briefing remind agents of this.

## Subsystem shape

Channels lives under `src/server/channels/` and is six replaceable parts:

| Port | What it owns |
|---|---|
| `ChannelStore` | where the conversation lives, and how a finalised message is noticed |
| `ChannelFiles` | where attachments live — bytes, a different medium from conversation |
| `ChannelViews` | saved view filters — operator preference, not channel data |
| `MessageRouter` | who a message is for — pure, no I/O, no queue |
| `AgentDelivery` | what reaches an agent: send, states, probe, submit |
| `PromptRenderer` | what an agent sees |

The contracts are stated in `ports.ts` and the subsystem is entered through
`index.ts`. Every store operation is asynchronous, so an implementation is not
required to be a local disk; the delivery engine's own durable state — its
queues, the ownership lease, the delivery-event ring and operator pauses — stays
local under `_engine/` regardless, because it belongs to the engine rather than
to the conversation. Each part is replaceable on its own: a plugin swaps one and the
others do not notice (see [plugins](/security-plugin-model)), and a test
supplies one without needing a filesystem for the rest.

Two consequences are worth stating because they are easy to miss. Routing is a
pure function of a message and a roster — it never learns whether delivery
happened. And the renderer is the one place that knows a channel is a FILE: the
turn prompt carries an absolute path and the `desk channels` commands an agent
runs to read the room.

## The delivery engine

Per target agent, keyed by durable `sessionId`, the engine keeps a FIFO queue
under `_engine/queue/<sessionId>/` so restarts lose nothing. Codex terminal
sessions receive notifications through the provider's durable `thread/queue/add`
API, addressed to the exact registered thread and credential profile. The
running Codex owner consumes them at a turn boundary; it can take up to one
external-queue polling interval (10 seconds in Codex 0.153.4) to notice a write.
Desk sends no terminal keystrokes for these deliveries, so notifications cannot
confirm a model-switch menu, permission dialog, or user-input form. Model,
reasoning effort, and the current thread are not changed by queue submission.
Unsupported queue APIs, stale bindings, or unresolved provider transitions
leave the message unsent; there is no fallback to Enter or automatic menu
selection. A timed-out enqueue is an unknown outcome, not permission to retry.

Claude terminal delivery uses bracketed paste when enabled, a settle delay,
and Enter. Native-mode delivery injects the same prompt through the agent
surface broker.

### Channel messages: notification-first delivery

Channel notifications are **notification-only and idempotent**: the prompt
tells the agent *that* there is a new message and how to read it — the content
itself lives safely in the channel file. Because a duplicate or mid-turn
notification is recoverable (the agent just reads the channel), regular
channel dispatches do **not** gate on the agent's screen state: if the active
delivery transport accepts the prompt, the queue advances immediately.
Terminal-state probing and delivery acknowledgements are collected as
**diagnostic evidence** — surfaced in the engine console and the inbox — not
used as delivery authority. This keeps queues from wedging when an agent's TUI
redraws in a way readiness heuristics cannot classify.

### Standalone prompts: verified delivery

Onboarding briefings and other standalone prompts have no channel file backing
them. They queue and release on the same canonical decision as every other
item — the prompt kind grants no extra wait and no extra gate.

What the kind does change is the evidence collected afterwards. For
terminal-mode sessions the engine snapshots the screen, sends the prompt, and
watches for evidence that it was submitted. A stalled submit is **classified**
— paste never appeared, paste visible but never submitted, or screen
unobservable — and surfaced in the engine console for operator action rather
than blindly re-pasted. Native-mode sessions skip that submit verification,
because the agent surface reports acceptance directly. Per-item ack files make
delivery state crash-durable.

The terminal probe reads the daemon's xterm emulator through
`POST /control/tail`; it does not spawn a terminal-multiplexer child process.
A session the daemon reports as `starting` shows as `booting` in the console
until its lifecycle advances.

### What activity does and does not gate

**Nothing about the agent's activity withholds a channel message.** Not
`working`, not `blocked`, not `unknown`. Every agent CLI Desk drives buffers
typed input and consumes it when its turn ends, which is exactly what happens
when an operator types a follow-up without waiting — so a mid-turn agent is
not an unreachable one.

Earlier versions held delivery while an agent was busy, and held it again on
approval prompts on the grounds that arriving text could answer the dialog.
That risk is real but it is the operator's to take: it is visible the moment
it happens and recoverable, whereas a channel that silently keeps messages is
neither. A messaging surface whose messages sometimes do not arrive is not a
messaging surface.

What still holds a queue is **lifecycle**, which is a different question — not
"what is the agent doing" but "is there a process to receive at all". A session
that is still starting, or has exited, keeps its queue and delivers when it is
back. Nothing is dropped. Operator **pause** also holds, because that is the
operator's own decision rather than the engine's.

Canonical activity is still published — the lamp, the status dot, and the
engine console all read it — it simply no longer decides whether text is sent.
A background pump retries eligible queues every few seconds.

One case does leave the live queue: a **send that never returns**. If the
transport has not answered within thirty seconds the engine stops waiting and
does not retry, because the paste may still land and a retry on top of it
would duplicate the message into the agent's context. The session is marked
`send-failed` in the engine console, so the stall is visible immediately, and
the item's durable record survives — an engine restart re-queues it, and the
operator can revert it by hand before that. What is not offered is an
automatic second attempt inside the same process: between a duplicate and a
delay, this engine chooses the delay.

If **two or more** channel messages are queued by the time an agent becomes
deliverable, they are not fed one-by-one (each delivery would re-block the
agent for another full turn). Instead the engine sends **one digest**: counts
and authors per channel, thread ids where relevant, and the exact `desk
channels read` / `desk channels post … --as` commands to catch up — but no
message bodies. The agent reads the channel itself and acts on the whole
batch. Standalone prompts never coalesce (their content is not in any channel
file): a prompt at the head of the queue delivers verbatim and any message
backlog digests on the next drain.

Prompts held longer than ten minutes are prefixed with a delayed-delivery note
so the agent re-reads the channel before acting on stale context. Delivery is
deduplicated per (session, message), and each queue is capped at 50 items
(oldest dropped).

### Pause, ownership, and the event log

Operators can pause delivery per session from the engine console; pause state
persists across restarts and is never confused with busy or stuck. Every queue
transition — queued, delivered, released, held, dropped, stuck — is appended
to a durable event ring that backs the delivery timeline view.

Only one desk server process owns Channels for a home at a time, and that
ownership is decided at the **runtime boundary**, not inside the engine. The
server acquires a heartbeat lease at `_engine/server-owner.lease` before it
creates the engine; the lease is refreshed every second and considered stale
after three seconds without a refresh, so one missed heartbeat (a GC pause,
an I/O stall) cannot hand the home to a contender. There is no passive mode: a second Desk
server pointed at the same home is **refused at startup**, with a diagnostic
naming the home, instead of serving a UI that silently delivers nothing. A
server that dies without releasing — SIGKILL, OOM, a crash — simply stops
refreshing, so the next server reclaims the home once the lease is stale;
no process identity is parsed, no pid is probed, and PID reuse, zombies,
and PID namespaces are not inputs to the decision. Orderly shutdown releases
the lease immediately. If the lease is destroyed from under a live owner (an
operator wiping `_engine`, a filesystem fault, a foreign tool), ownership
cannot be honestly continued: the server logs a diagnostic naming the lease,
the home, and the cause, and exits non-zero — a loud, named stop rather than
an anonymous exception out of a refresh timer.

The former `_engine/engine.pid` ownership record is retired. A home that
still carries one is refused at startup by name, because a stale pid record
is exactly the artifact that used to leave a crashed deployment permanently
silent. The pre-lease engine removed that file only on its crash-reclaim
path — an orderly stop left it behind — and no installer or script touches
it, so the remedy is the file itself: stop every Desk server for this home,
delete `_engine/engine.pid`, then restart. Under the lease scheme the file
carries no authority; the runtime refuses rather than deleting it silently so
the operator learns a pre-lease server ran here and confirms none still does.

## Ops console

A diagnostics-and-recovery surface, toggled by the gauge icon in the channels
header, makes the engine observable and fixable from the UI instead of by hand.

- **Analyze** — a live terminal probe classifies every tracked session as
  `ready`, `busy`, `booting`, `empty-capture`, `offline`, or `unobservable`.
  Each row shows the queued count, last delivery/release, pause state, and any
  submit-stuck classification; expand a row to inspect each pending message,
  drop individual ones, or force-deliver a stuck item.
- **Fix** — per session: **Deliver now** (push the head item ahead of the
  pump's own schedule), **Mark idle** (clear a stale local flag and re-drain),
  **Pause / Resume delivery**, **Drop queue**. Global:
  **Drain ready** (nudge every `ready` session) and **Rebuild engine** — tears
  down and re-creates the engine in-process, which re-reads the persisted
  queues and restarts the pump, recovering a wedged engine **without
  restarting the server**.

Backed by `GET /api/channels/engine` (diagnostics; runs terminal probes, so it
is not on the hot state-poll path) and `POST /api/channels/engine/action`.

## The CLI

```bash
desk channels list                                     # channels with member/message counts
desk channels read <channel>                           # full conversation
desk channels read <channel> <parent-msg-id>           # one thread
desk channels read <channel> --message <msg-id>        # a single message
desk channels post <channel> [--thread <id>] [--as <member>] "<body>"
```

Posts go through the desk server (`DESK_API`, default
`http://127.0.0.1:5173`) so dispatch is immediate. Identity resolves from the
launch environment's `DESK_SESSION_ID` via the member `session:` mapping;
`--as <member>` is the explicit override. **Agents should always pass `--as`**
so an unattributable post cannot fall back to `@human`. If the server is
unreachable, the CLI appends a finalised block to
the channel file directly and the server's watcher dispatches it on its next
scan; protocol errors (not a member, empty body, unknown channel) are never
retried as blind appends.

## External writers

Tools other than the CLI may append to `root.md` / `thread-*.md` directly as
long as they write complete, finalised blocks in the exact format above. The
server watches the channels tree (plus a 30 s reconciliation sweep for missed
filesystem events) and dispatches finalised blocks it has not seen before.
Message **edits are never re-dispatched** — only blocks with previously unseen
ids dispatch. Prefer the CLI whenever possible — it owns id minting, body
validation, and append serialisation; concurrent raw writers must handle those
themselves.
