Cariosan

@cariosan/client

TypeScript client SDK for browsers, Node, and React Native.

The TypeScript SDK for browsers, Node 20+, and React Native. Zero runtime dependencies — uses native fetch and WebSocket.

terminal
pnpm add @cariosan/client

Setup

client.ts
import { Cariosan } from "@cariosan/client";
 
const client = new Cariosan({
  apiUrl: "https://chat.example.com",
  token: "<jwt issued by your backend>",
  reconnect: {
    enabled: true,
    initialDelayMs: 1000,
    maxDelayMs: 30000,
  },
});
 
await client.connect();

Never persist the JWTtoken is the JWT your backend issues with the Go SDK's IssueUserToken. The client SDK stores it in memory only. Persisting JWTs to localStorage exposes them to XSS.

Channels

Get a channel handle (cached — the same externalId always returns the same object):

channel.ts
const channel = client.channel("order_123");

Send

send.ts
await channel.sendMessage({ text: "Halo!" });

Reply (quote)

Quote a message by passing its id as parentId:

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

The reply carries parent_id and a quoted_message preview ({ id, user_id, user_name, content, type, deleted }) on the send response, in history, and on the live message event. The parent must be in the same channel or the call rejects with VALIDATION_ERROR. See Quoted replies.

Mention

Pass channel members' external_ids in mentions:

mention.ts
await channel.sendMessage({ text: "ping @Andi", mentions: ["user_andi"] });

The message gains a resolved mentions array ([{ user_id, external_id, name }]). Each mentioned member is pinged on their own connection, surfaced as a mention event on both the Cariosan instance (for a global badge) and the relevant Channel:

mention-events.ts
client.on("mention", ({ channel_id, message_id, by_user_id }) => { /* global badge */ });
channel.on("mention", ({ message_id, by_user_id }) => { /* per-channel badge */ });

Mentioning a non-member rejects the send with VALIDATION_ERROR. See Mentions.

Receive

receive.ts
channel.on("message", (msg) => {
  console.log(`${msg.user.name}: ${msg.content}`);
});

Channel events you can listen for:

  • message — fires for every message in the channel
  • message.updated — a message was edited ({ id, content, edited_at })
  • message.deleted — a message was unsent ({ id, deleted_at }) — render a tombstone
  • reaction.add / reaction.remove — an emoji reaction changed ({ message_id, user_id, emoji })
  • mention — the current user was @-mentioned here ({ channel_id, message_id, by_user_id }); also emitted on the Cariosan instance
  • typing — typing indicators (start / stop)
  • presence — member joins and leaves (channel-scoped; global presence comes from Cariosan#on("presence.update", ...))

Tip

Listeners are automatically unsubscribed when the channel is closed.

History

history.ts
const page = await channel.getMessages({ limit: 50 });
// Load older:
const older = await channel.getMessages({ before: page.next_cursor ?? undefined });

Relevance-ranked full-text search. Scope to one channel, or search across every channel you belong to via the top-level client:

search.ts
const inChannel = await channel.searchMessages("brown fox", { limit: 20 });
const everywhere = await client.searchMessages("brown fox");
// Same page shape as getMessages; ranked by relevance then recency.

Supports websearch syntax ("quoted", OR, -exclude). Cross-channel search is member-scoped and excludes deleted messages. See Search.

Reactions, editing & unsending

mutations.ts
// Reactions (idempotent):
await channel.addReaction(messageId, "👍");
await channel.removeReaction(messageId, "👍");
 
// Edit / unsend — author-only:
const updated = await channel.editMessage(messageId, "fixed a typo");
await channel.deleteMessage(messageId); // soft-delete tombstone, content blanked
 
// React to other members' changes:
channel.on("reaction.add",    ({ message_id, user_id, emoji }) => {});
channel.on("reaction.remove", ({ message_id, user_id, emoji }) => {});
channel.on("message.updated", ({ id, content, edited_at }) => {});
channel.on("message.deleted", ({ id, deleted_at }) => {});

Messages from getMessages carry their current state: reactions ([{ emoji, count, reacted_by_me }]), edited_at, and deleted_at. A non-author edit/delete returns 403 NOT_AUTHOR; past an optional server-configured window, 422 EDIT_WINDOW_EXPIRED / UNSEND_WINDOW_EXPIRED.

Typing and read markers

typing.ts
channel.startTyping();   // throttled to once per 2s; implicit stop after idle
channel.stopTyping();    // explicit stop
await channel.markRead(); // sets last_read_at on the server

Read & delivery receipts

WhatsApp-style ticks. The SDK auto-acks delivery (sends channel.delivered on every inbound message) and markRead() advances the read cursor; both broadcast so senders see ticks flip live. Seed the member cursors once with getReceipts(), then read per-message state with receiptState():

receipts.ts
await channel.getReceipts(); // seed cursors for accurate ticks on load
channel.on("receipt.update", () => {
  render(channel.receiptState(myMessage)); // "sent" | "delivered" | "read"
});

receiptState(message) compares every other member's cursor against the message's created_at: all delivered → delivered, all read → read, else sent. Read receipts can be disabled per workspace (read_receipts_enabled), in which case channel.read is never broadcast — delivery still is. See Read & delivery receipts.

Push notifications

Register a device's FCM token to receive push for new messages (delivered when the workspace's server has Firebase configured — see Push):

push.ts
await client.registerDevice(fcmToken, "android"); // "ios" | "android" | "web"
await client.unregisterDevice(fcmToken);          // on logout

Registration is idempotent on the token. Payloads are minimal (title only) — fetch content after the app opens using the channel_id/message_id in the notification's data.

Blocking

Hide another user's messages from your history + search (workspace-wide):

block.ts
await client.blockUser("user_spammer");
const blocked = await client.listBlocked(); // [{ user_id, external_id, name }]
await client.unblockUser("user_spammer");

History and search are filtered server-side; use listBlocked() to also drop live WebSocket messages from blocked users client-side. See Blocking & muting.

Presence

presence.ts
const { members } = await channel.getPresence();
// members: [{ user_id, external_id, name, role, status, last_seen_at }]
// role is "admin" | "member" for group channels.

Live updates arrive via the top-level Cariosan#on("presence.update", ...) handler.

Group admin management

Group channels expose channel.avatarUrl, a channel.lastMessage preview (on listChannels()), and admin-only management. The acting user must be a channel admin (created_by at create time makes someone admin) — non-admins get a 403. Removing yourself is allowed for anyone (leave group).

group-admin.ts
const channel = client.channel("order_123");
 
await channel.updateChannel({ name: "Order #123 — VIP", avatarUrl: "https://cdn/g.png" });
await channel.addMembers(["user_support_2"]);
await channel.setMemberRole("user_support_2", "admin"); // or "member" to demote
await channel.removeMember("user_support_1");           // kick (admin)
await channel.removeMember(myExternalId);               // leave (any member)
 
channel.avatarUrl;   // group icon URL (undefined for direct)
channel.lastMessage; // { user_name, content, type, created_at } | undefined

See Roles & group admins for the full hybrid (backend + client) model.

Attachments

upload.ts
const file: File = /* from <input type="file"> */;
const { publicUrl } = await channel.uploadAttachment(file);
await channel.sendMessage({ text: "", attachmentUrl: publicUrl });

The SDK handles the presign → PUT → public URL flow for you.

Lifecycle events

lifecycle.ts
client.on("connected", () => console.log("WS open"));
client.on("disconnected", ({ willReconnect }) => console.log("WS closed, retrying:", willReconnect));
client.on("error", (err) => {
  if (err.code === "UNAUTHORIZED") {
    // Token expired or was rejected. Fetch a new one from your
    // backend and reconnect.
  }
});

Error handling

Every thrown error is a CariosanError with a stable code, an optional status, and a retryable: boolean hint:

errors.ts
import { isAuthError } from "@cariosan/client";
 
try {
  await channel.sendMessage({ text: "hi" });
} catch (err) {
  if (isAuthError(err)) { /* refresh token */ }
  else { /* surface to UI */ }
}

Bundle size — ESM min+gzip is around 3 KB. No external runtime dependencies; tree-shakes cleanly. Pair with the React peer (@cariosan/react, ~2.6 KB) for drop-in UI.

Was this page helpful?

On this page