Messages
Channel messages are the core durable object. Every message is Markdown text with optional attachments. Threads are modelled as messages with a non-null parent_message_id (see threads.md). Inline quote-replies live on the same row via quoted_message_id and friends, documented in replies.md.
#Endpoints
GET /api/channels/{channel_id}/messages?after_seq=&before_seq=&around_seq=&mode=&topic_id=&limit=
POST /api/channels/{channel_id}/messages
POST /api/channels/{channel_id}/read
GET /api/messages/by-nonce?workspace_id=...&nonce=...
GET /api/messages/{message_id}
PATCH /api/messages/{message_id}
DELETE /api/messages/{message_id}
GETreturns root messages only (parent_message_id IS NULL) for thePOST /messagesacceptsGET /api/messages/by-noncelets the authenticated author reconcile aPOST /readaccepts{seq}and updates the caller's monotonic read pointerGET /api/messages/{message_id}returns a single message visible to thePATCHaccepts{body}and only the original author can edit. SetsDELETEis a soft delete — setsdeleted_at, keeps the row and the
channel, ordered by channel_seq ascending. after_seq and before_seq are exclusive cursor windows; around_seq returns context around a target sequence. Cursor params are mutually exclusive; mode=latest explicitly selects the newest window and cannot be combined with a cursor. limit is clamped to 1..200 (default 100). Optional topic_id restricts every cursor mode and page-metadata check to one active topic available in the channel. Every returned root includes thread_state, including a zero-reply state, so clients can render thread activity without fetching each thread.
{body, quoted_message_id?, nonce?, topic_id?, kind?, turn_id?}. Empty bodies are rejected. nonce is an optional client idempotency key; replaying the same nonce with the same body, quote, and topic returns the existing message with HTTP 200 instead of creating a duplicate.
durable create after an interrupted request. It returns the matching message, including attachments, or a capability-marked 404 when no message exists. The X-ClickClack-Message-Nonce: supported response header distinguishes that result from an older server that does not implement the endpoint.
for the channel. The server caps seq to the channel's current last root message sequence.
current user. DM messages require direct conversation membership.
edited_at.
channel_seq slot so cursors stay valid. Authors can delete their own messages. Workspace owners can also delete messages in workspace channels, while direct messages remain author-only.
Message create, edit, delete, and read updates emit durable events: message.created, message.updated, message.deleted, channel.read. Read events are private to the user who advanced the pointer.
#Editing in the web app
The message action menu exposes Edit message only for the original author. It is available for channel messages, direct messages, thread roots, and thread replies in both the full app and authenticated channel/thread embeds.
Editing is inline and preserves the exact draft sent to PATCH; the server continues to own boundary-whitespace normalization. Ctrl+Enter or Command+Enter saves, and Escape cancels. Empty normalized bodies are rejected before a request is sent. A draft is retained when its channel or direct-message view is temporarily unmounted, including virtualized timeline rows, while closing a thread explicitly discards an unsaved thread-surface draft.
Each conversation can have one active editor. Starting another edit in the same conversation reveals and focuses the existing editor instead of discarding its draft. Drafts in other conversations are retained for a bounded number of recent views. Realtime updates refresh an untouched draft; if the user already changed it, the editor keeps that draft and warns that the message changed elsewhere.
#Durable agent activity
Channel and DM create endpoints accept two bot-only activity kinds: agent_commentary for narration and agent_tool for tool execution. Activity rows use the normal durable message sequence and realtime fan-out, and rows sharing a turn_id render as one collapsible preamble in the web app. They do not increment unread counts, trigger notifications, or appear in full-text search.
Publishing activity requires bot-token authentication plus the explicit agent_activity:write scope, which is intentionally excluded from every bot:* bundle. Ordinary messages default to kind: "message" and reject a turn_id; human sessions cannot publish either activity kind. Create an activity-capable bot token with a scope list such as bot:write,agent_activity:write.
#Conversation organization and attention
Conversation organization stays attached to the channel timeline, while attention is a per-user choice. Topics label and filter messages without creating nested rooms. Related channel-wide tools, such as pins and mention attention, use the same message metadata and rendering surfaces rather than forking the conversation model.
#Topics
Topics are optional labels for channel messages. They are useful for deploys, incidents, customer threads, or other lightweight organization without turning the channel model into nested rooms.
GET /api/workspaces/{workspace_id}/topics
POST /api/workspaces/{workspace_id}/topics
POST /topics accepts {name, channel_id?}. A topic without channel_id can be used by any channel in the workspace. A channel-scoped topic can only be used when posting to that channel. Message responses include topic_id when a topic was supplied. The web channel composer lists the active topics available to that channel. Root-message topic labels can be clicked to filter the timeline; clearing the visible filter returns to the unfiltered channel.
#Channel attention
Each channel has one server-backed notification preference per member: all, mentions, or muted. The channel header controls that preference for both browser/desktop alerts and Pushover delivery. Mention-only delivery uses the resolved mentioned_user_ids event metadata; it does not create a separate highlight or appearance setting. Resolvable handles are always marked neutrally in timelines and threads; a mention of the current user receives the stronger attention style only while the channel preference is all or mentions. Muted channels keep the neutral resolved-handle treatment. See channel notifications for the API and delivery rules.
#Pinned messages
Pins are shared, channel-wide references rather than per-user bookmarks. The pinned panel is independent of the current topic filter and retains topic badges and mention rendering. Each channel can hold at most 100 pins. See pinned messages for the API and event contract.
#Sequence numbers
Every channel message gets a per-channel channel_seq assigned inside the insert transaction:
SELECT COALESCE(MAX(channel_seq), 0) + 1
FROM messages
WHERE channel_id = ? AND parent_message_id IS NULL
That sequence is what clients page by, what the realtime event carries, and what reconnect uses to backfill. It is monotonic per channel but not globally. Thread replies use a separate thread_seq instead.
#Body format
Bodies are stored as Markdown text. The body_format column is hard-coded to markdown in V1 and exists so a future format (rich text, plain) can be added without a migration. The frontend renders a sanitized subset.
The web composer is a Slack-like message well with a format bar for bold, italic, inline code, code blocks, links, attachments, and GIF insertion. The GIF picker inserts standard Markdown image syntax, so no provider-specific durable schema is required for V1.
#Attachments
Messages carry zero or more attachments via the message_attachments join table. Hydration happens in hydrateAttachments and surfaces as the attachments field on Message. See uploads.md for the two-step upload-then-attach flow.
The web client renders image, video, audio, PDF, and text attachments as compact preview cards where safe, and links other attachments as authenticated download cards. Clicking an inline image attachment, or an image inside rendered Markdown, opens an in-app image viewer with an Open original link. Markdown image URLs, including animated GIF URLs, render inline through the same sanitized Markdown path.
Giphy-backed Markdown GIF images play briefly, then swap to a still preview with a small replay button in the lower-right corner. Pressing replay reloads the animated GIF and repeats the same play-once behavior. Other GIF URLs render normally when no still preview is available.
#Author hydration
ListMessages and GetThread join users and populate Message.author so clients don't need a second round-trip. Avatar URLs are passed through as-is.
#What is intentionally missing
- Hard delete. The soft-delete row stays for cursor stability.
- Per-user message bookmarks. Channel pins are shared workspace state.
- Per-message permissions beyond author edit/delete and workspace-owner channel
deletion.