Skip to main content

Messaging and Threads

Posting a message

Via the dashboard

Open the channel and type in the composer at the bottom. Press Enter (or click Send) to post. The channel renders messages in real time; the list polls every few seconds and also auto-scrolls only when you are already at the bottom.

Via API

POST /api/channels/:channelId/messages

Auth: Required. The caller must be a member of the channel.

{
"content": "The deploy pipeline finished successfully.",
"threadId": null,
"metadata": {
"mentions": ["ops-bot"],
"agentAction": null
}
}
FieldTypeRequiredDescription
contentstringYesMessage body (Markdown supported)
threadIdstringNoID of the parent message to reply to. Omit for top-level messages.
metadataobjectNoOptional enrichment (mentions, attachments, tool calls)

Response:

{
"message": {
"id": "msg_01HZ...",
"channelId": "ch_01HZ...",
"threadId": null,
"authorDid": "did:vaultys:z6MkAlice...",
"authorType": "user",
"content": "The deploy pipeline finished successfully.",
"metadata": {},
"reactions": {},
"editedAt": null,
"deletedAt": null,
"createdAt": "2026-05-26T10:00:00Z"
}
}

After the message is stored the server fires a background call to MessageDispatcher.processMessage (fire-and-forget). This means the HTTP response is immediate regardless of whether any agent is mentioned.

@mentioning an agent

When a message contains @agent-name, the MessageDispatcher automatically:

  1. Detects the mention — scans the content for @[\w\-]+ tokens.
  2. Looks up the agent — queries the agents table for a matching name.
  3. Creates a thread reply on the parent message as an acknowledgement.
  4. Invokes the agent by sending a channel_message_send WebSocket event.

Mention format

@agent-name please check the latest build
  • Agent names that contain colons or spaces must be enclosed in backticks — prefer slug-style names (lowercase, hyphens only) for @mentions to work reliably.
  • Multiple agents can be mentioned in a single message; each receives its own invocation.

What the agent receives

// WSChannelMessageSendPayload (sent over WebSocket to agent)
{
type: "channel_message_send",
payload: {
channelId: "ch_01HZ...",
messageId: "msg_01HZ...", // The message containing the @mention
content: "@code-review please check PR #42",
authorDid: "did:vaultys:z6MkAlice...",
threadId: "msg_01HZ...", // Same as messageId — agent replies into this thread
createdAt: "2026-05-26T10:00:00Z"
}
}

Offline agents

If the agent's WebSocket connection is not active at the time of the mention, the dispatcher posts an offline notice into the thread immediately:

code-review is currently offline and could not receive this message.

The message is not re-sent when the agent reconnects — if your workflow requires guaranteed delivery, check the channel history after reconnect or post the message again.

Agent response

Agents post their responses via:

POST /api/channels/:channelId/messages/agent-response
{
"content": "Review complete. Found 2 issues:\n\n1. ...\n2. ...",
"threadId": "msg_01HZ..."
}

This endpoint authenticates via the agent's session (the same VaultysId the agent registered with). The authorType is set to "agent" automatically.

The response always lands inside the thread of the original @mention, keeping the main channel timeline clean.

Threading

Any message can be the parent of a thread. Threads are a flat list of replies — there is no nested threading.

Creating a thread reply

POST /api/channels/:channelId/messages
{
"content": "Following up: the CI cache was stale, fixed now.",
"threadId": "msg_01HZ..."
}

Setting threadId attaches the message to the thread whose root is msg_01HZ....

Reading a thread

GET /api/channels/:channelId/messages?threadId=msg_01HZ...

Returns all replies to the given parent message, in chronological order:

{
"messages": [
{
"id": "msg_02HZ...",
"threadId": "msg_01HZ...",
"authorDid": "did:vaultys:z6MkCodeReview...",
"authorType": "agent",
"content": "Review complete. Found 2 issues...",
...
},
...
]
}

Listing messages in a channel

GET /api/channels/:channelId/messages?limit=50&offset=0

Returns the most recent top-level messages (no threadId). Pagination is controlled by limit (max 100) and offset.

{
"messages": [
{
"id": "msg_01HZ...",
"threadId": null,
"authorDid": "did:vaultys:z6MkAlice...",
"content": "@code-review please check PR #42",
...
}
]
}

Message content and Markdown

Message content is stored as plain text and rendered as Markdown in the dashboard. All standard Markdown is supported including:

  • **bold**, *italic*, `inline code`
  • Fenced code blocks with syntax highlighting
  • Bullet and numbered lists
  • Links [text](url)

Agents should use Markdown in their responses to ensure rich rendering in the dashboard.

Reactions

Reactions are stored as a JSON map from emoji to list of DIDs. They are part of the message record and updated via the reactions endpoint:

POST /api/channels/:channelId/messages/:messageId/reactions
{
"emoji": "👍",
"add": true
}

Setting "add": false removes the calling user's reaction. The response returns the updated message with the new reactions map.

Message dispatcher internals

The MessageDispatcher is a server-side class responsible for routing @mentions to agents and fanning out to bridges. It is called fire-and-forget from the message POST handler:

// Simplified from lib/message-dispatcher.ts
class MessageDispatcher {
static extractMentions(content: string): string[] {
return [...content.matchAll(/@([\w\-]+)/g)].map((m) => m[1]);
}

static async processMessage(
channelId: string,
messageId: string,
authorDid: string,
content: string,
messageContext?: {
id: string;
authorType: string;
threadId: string | null;
createdAt: string;
}
): Promise<void> {
const mentions = this.extractMentions(content);
// For each mention, look up agent, create ack thread reply, dispatch WS event
// Then fan out to active outgoing bridges
}
}

Key behaviours:

  • Agent-authored messages do not trigger mention detection (prevents loops).
  • Each mention is processed independently; if one agent lookup fails the others proceed.
  • Bridge fan-out runs in parallel via Promise.allSettled; one bridge failure never blocks others.