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:
_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:
- 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:
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:~/… 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 undersrc/server/channels/ and is six replaceable parts:
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), 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 durablesessionId, 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 throughPOST /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. Notworking, 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, orunobservable. 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
readysession) 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.
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
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 toroot.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.