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 JWT — token 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