Cariosan

Messages

The unit of conversation — text, images, or system events.

8 min readUpdated May 31, 2026

Messages are chat content inside a channel. Cariosan ships three message types out of the box, with explicit limits enforced server-side so a misbehaving client can't degrade the workspace.

  • text — the default. Up to CARIOS_MAX_MESSAGE_SIZE_BYTES (8 KiB default).
  • image — an attachment uploaded via presigned S3 URL. The content field still holds the caption.
  • system — server-generated notifications. Reserved for future features.

Sending

send.ts
await channel.sendMessage({ text: "Halo!" });
 
// With an attachment:
const { publicUrl } = await channel.uploadAttachment(fileBlob);
await channel.sendMessage({ text: "", attachmentUrl: publicUrl });

The server validates every send:

  • Membership — non-members get 403 NOT_A_MEMBER.
  • Size — over-limit content is rejected with 413.
  • Attachment URL — must start with CARIOS_S3_PUBLIC_URL so users can't post arbitrary third-party URLs as attachments.
  • Rate limit — per-user, CARIOS_RATE_LIMIT_MESSAGES_PER_MINUTE (60/min default). Over-limit returns 429 with a Retry-After header.

Idempotency

Pass an idempotency_key (any client-unique string) to make sendMessage retryable. The server returns the same message ID on re-send, so a flaky network won't double-post.

History

history.ts
const page = await channel.getMessages({ limit: 50 });
// page.messages — newest first
// page.has_more — true if a next page exists
// page.next_cursor — pass as `before` on the next call

Cursor pagination by created_at DESC, id DESC. Max limit is 100 (default 50).

Reactions

Members react to a message with any emoji. Reactions are aggregated per emoji (a count plus whether you reacted) and fan out over WebSocket in realtime.

reactions.ts
await channel.addReaction(messageId, "👍");
await channel.removeReaction(messageId, "👍");
 
// Live updates for every member:
channel.on("reaction.add", ({ message_id, user_id, emoji }) => { /* … */ });
channel.on("reaction.remove", ({ message_id, user_id, emoji }) => { /* … */ });

Each message in history carries its aggregated reactions:

{
  "id": "…",
  "content": "Halo!",
  "reactions": [{ "emoji": "👍", "count": 3, "reacted_by_me": true }]
}

Adding the same emoji twice is idempotent; removing one you never added is a no-op.

Editing

The author can edit their own message. The edit fans out as message.updated and the message gains an edited_at timestamp.

edit.ts
await channel.editMessage(messageId, "fixed a typo");
channel.on("message.updated", ({ id, content, edited_at }) => { /* … */ });
  • Author-only — others get 403 NOT_AUTHOR.
  • Optional edit window (server config; unlimited by default). Past the window: 422 EDIT_WINDOW_EXPIRED.

Unsending (delete-for-everyone)

The author can unsend their message. The content and attachment are blanked server-side — a true unsend, the original text is not retained — deleted_at is set, and a message.deleted tombstone fans out.

unsend.ts
await channel.deleteMessage(messageId);
channel.on("message.deleted", ({ id, deleted_at }) => { /* render a tombstone */ });
  • Author-only (channel-admin moderation arrives with the moderation phase).
  • Idempotent; optional unsend window (422 UNSEND_WINDOW_EXPIRED past it).
  • History still returns the row with blanked content and deleted_at set — render it as “This message was deleted”.

Realtime over REST

Reactions, edits, and unsends are issued over REST and fan out to connected members over WebSocket automatically (Redis pub/sub). See the WebSocket protocol for the event payloads. These helpers are available in the TypeScript client + React kit today; other languages can call the REST endpoints directly.

Quoted replies

Reply to a message by passing parentId — the id of the message you're quoting. The server validates the parent lives in the same channel and embeds a truncated quoted_message preview on the reply, so clients render the quote without a second fetch.

reply.ts
const original = await channel.sendMessage({ text: "Is the order shipped?" });
 
await channel.sendMessage({
  text: "Yes — tracking is on the way.",
  parentId: original.id,
});

Every reply carries parent_id plus a quoted_message preview — on the send response, in history, and on the live message.new event:

{
  "id": "…",
  "content": "Yes — tracking is on the way.",
  "parent_id": "01HX…",              // the quoted message's id
  "quoted_message": {
    "id": "01HX…",
    "user_id": "…",
    "user_name": "Andi",
    "content": "Is the order shipped?",  // truncated to 180 characters
    "type": "text",
    "deleted": false                     // true once the quoted message is unsent
  }
}
  • An unknown parentId, or one belonging to a different channel, is rejected with 400 VALIDATION_ERROR.
  • The preview is re-resolved on every history fetch, not frozen at send time: an edited parent shows its latest content, and an unsent parent surfaces deleted: true (render it as "Original message was deleted").
  • @cariosan/react's MessageList renders the quote above the reply automatically; the message.created webhook also carries parent_id + quoted_message.

Mentions

@-mention channel members by passing their external_ids in mentions when you send. Mentions are explicit ids from the client — Cariosan never parses @handles out of your text, so there's no ambiguity about who was mentioned.

mention.ts
await channel.sendMessage({
  text: "Can you take this one, @Andi?",
  mentions: ["user_andi"], // external_ids of channel members
});

The server validates every mentioned id is a member of the channel (a non-member or unknown id rejects the whole send with 400 VALIDATION_ERROR), then resolves them into a mentions array on the message:

{
  "id": "…",
  "content": "Can you take this one, @Andi?",
  "mentions": [
    { "user_id": "…", "external_id": "user_andi", "name": "Andi" }
  ]
}

Each mentioned member is also pinged in realtime on their own WebSocket topic (notification.mention), so they're alerted even if they aren't currently viewing the channel — wire it to an in-app badge or a notification. The message.created webhook carries the mentions array too, and a dedicated message.mentioned webhook fires once per mentioning message.

  • Unread mentionsGET /v1/me/channels returns mention_unread_count per channel alongside unread_count, so you can badge channels with unread mentions on load.
  • @cariosan/react's MessageInput ships an @ autocomplete over channel members; MessageList highlights mention tokens; useMentions() tracks live mention counts for badges.

Read markers

Read state is per-user, per-channel — not per-message. The next GET /v1/me/channels response includes an unread_count for every channel, computed as "messages with created_at > last_read_at, excluding ones you authored".

read.ts
await channel.markRead();
// Sets `last_read_at` to now.

Read & delivery receipts

WhatsApp-style ticks, cursor-based — one last_delivered_at + last_read_at per member, not a row per message, so receipts stay cheap regardless of volume. For each of your sent messages:

  • ✓ sent — persisted.
  • ✓✓ delivered — every other member's last_delivered_at ≥ the message's created_at.
  • ✓✓ blue read — every other member's last_read_at ≥ the message's created_at.

The SDK acks delivery automatically (it sends channel.delivered on every inbound message), and markRead() advances the read cursor. Both broadcast in realtime so senders see ticks flip live:

receipts.ts
await channel.getReceipts();              // seed member cursors once
channel.on("receipt.update", () => {
  const state = channel.receiptState(message); // "sent" | "delivered" | "read"
});

GET /v1/channels/:id/receipts returns the per-member cursors for an initial render; live channel.read / channel.delivered events keep them current. In @cariosan/react, pass currentUserId to MessageList for automatic ✓/✓✓/✓✓-blue ticks on your own bubbles, or use useReadReceipts().

Privacy toggle

Read receipts honour a workspace setting read_receipts_enabled (default true, configurable from the Cloud dashboard). When off, the server stops broadcasting channel.read (no blue ticks, either direction) — delivery receipts (✓✓ grey) still flow, and unread counts are unaffected.

Full-text search over message content, ranked by relevance. Search within one channel, or across every channel the caller belongs to:

search.ts
// Within a channel:
const page = await channel.searchMessages("brown fox", { limit: 20 });
 
// Across all your channels:
const all = await client.searchMessages("brown fox");
// page.messages — ranked by relevance, then recency; same shape as history

The query accepts websearch syntax"quoted phrases", OR, and -exclude. Matching uses Postgres' simple text-search config (language-agnostic — no stemming), a deliberate choice for a mixed Indonesian + English corpus where a single-language stemmer would mangle the other.

  • Member-scoped — cross-channel search only ever returns messages from channels you're a member of; it never leaks other channels.
  • Excludes deleted — unsent (tombstoned) messages drop out of the index automatically.
  • Paginated?limit= (max 100, default 50) and ?before=<RFC3339> to window to older matches; the response carries has_more + next_cursor like history.

REST: GET /v1/channels/:external_id/messages/search?q=… and GET /v1/search/messages?q=…. In @cariosan/react, the useSearch(channelId?) hook wraps both (the search-box UI is left to you to style).

Attachments

Uploads are direct-to-S3 via a presigned PUT URL. Bytes never proxy through the Cariosan server, which keeps the server tiny even with image-heavy chat.

upload.ts
const up = await channel.uploadAttachment(file);
// up.publicUrl — ready to pass to sendMessage
await channel.sendMessage({ text: "", attachmentUrl: up.publicUrl });

Under the hood:

  1. Client POSTs metadata (filename, mime, size) to /v1/attachments/presign.
  2. Server returns a short-lived (15 min) presigned URL plus the public URL.
  3. Client PUTs the file body directly to S3.
  4. Client passes the public URL in sendMessage.

Allowed types

image/jpeg, image/png, image/webp, image/gif. Max size CARIOS_MAX_ATTACHMENT_SIZE_BYTES (10 MB default). Reject other types client-side too so you don't waste a presign round-trip.

Realtime delivery

Every message inserted over REST fans out over WebSocket to every connected channel member on every Cariosan instance (via Redis pub/sub). See the WebSocket protocol reference for the wire format.

Delivery is at-least-once

Clients should de-dupe messages by id when reconciling REST history with live WebSocket events — reconnects may replay messages briefly while the gateway recovers state. The SDK handles this automatically; only relevant if you build a custom client.

Was this page helpful?

On this page