Chatpack
Client

Chatpack client

One typed client for Chatpack REST, SSE, cache, and plugins.

Install the framework-agnostic client:

pnpm add @chatpack/client

Create one instance. The default path is /api/chat; baseURL is only needed when the API is on another origin.

import { createChatClient } from "@chatpack/client";

export const chatClient = createChatClient({
  baseURL: "http://localhost:3000",
  credentials: "include",
});

const result = await chatClient.conversations.create({ otherUserId: "bob" });
if (result.error === null) {
  await chatClient.messages.send({ conversationId: result.data.id, body: "Hello" });
}

Replies and reactions

messages.send takes an optional replyToMessageId, and messages.react / messages.unreact add and remove one of your own reactions:

const sent = await chatClient.messages.send({ conversationId, body: "Hello" });
if (sent.error === null) {
  await chatClient.messages.send({
    conversationId,
    body: "Hi back",
    replyToMessageId: sent.data.id,
  });

  const reacted = await chatClient.messages.react({ messageId: sent.data.id, emoji: "👍" });
  reacted.data?.reactions; // [{ emoji: "👍", count: 1, userIds: ["bob"] }]
  await chatClient.messages.unreact({ messageId: sent.data.id, emoji: "👍" });
}

Both reaction calls are idempotent and return the message with its complete reaction set, which the client writes straight into the cache. Every cached message carries replyToMessageId, a read-only replyTo preview ({ id, senderId, excerpt, deleted }) for the quote bar, reactions, mentions, and forwardedFrom. An emoji is any non-empty string up to 32 characters; "" or longer comes back as INVALID_INPUT, and an unknown messageId as MESSAGE_NOT_FOUND - as results, not throws.

Mentions

messages.send and messages.edit take a mentions array of user ids you supply - the client sends them through, and every message in the cache carries the stored set back:

const sent = await chatClient.messages.send({
  conversationId,
  body: "@bob take a look",
  mentions: ["bob"],
});
sent.data?.mentions; // ["bob"]

Chatpack never parses body for @, so build the array from your own picker - populated from conversation.participants, not an org-wide user list, because a non-participant id fails the whole call with MENTION_NOT_PARTICIPANT (a result, not a throw). Read mentions as a set: it comes back sorted, not in the order you sent it.

messages.edit treats an absent mentions differently from an empty one, and the client preserves that distinction on the wire - omit the field and the stored set is left alone, pass [] and it's cleared:

await chatClient.messages.edit({ messageId, body: "fixed a typo" }); // mentions untouched
await chatClient.messages.edit({ messageId, body: "never mind", mentions: [] }); // cleared

Forwarding

messages.forward copies a message into another conversation and resolves with the new message there, not the source:

const forwarded = await chatClient.messages.forward({
  messageId: sent.data.id,
  toConversationId: otherConversationId,
});
forwarded.data?.senderId; // you - the forwarder is the sender
forwarded.data?.forwardedFrom; // { messageId, conversationId, senderId } - frozen

The destination is toConversationId in the client input even though the wire field is a plain conversationId: the route already names the source in its path, so an unqualified name would read either way in your code. Optional role, mentions, and metadata apply to the copy - nothing travels from the original, and mentions is validated against the target.

The client echoes the forward into the cache exactly as it echoes a send: the copy appears in the target thread and that conversation moves to the top of the list, because sending into a conversation is what makes it recently active. Editing or deleting the original later changes nothing about the copy.

Groups

Group conversations come back from conversations.list and conversations.get like any other - with type: "group", a name, and participants carrying role - and messages, read-state and reactions work identically. The five group mutations are wrapped as typed methods (client 0.5.0+):

// NOT find-or-create: every call makes a new group. You become its first admin.
const group = await chatClient.conversations.createGroup({
  name: "Standup",
  userIds: ["bob", "carol"],
});

await chatClient.conversations.addParticipants({
  conversationId: group.data.id,
  userIds: ["dave"],
}); // admin only; already-present ids are no-ops
await chatClient.conversations.setParticipantRole({
  conversationId: group.data.id,
  userId: "bob",
  role: "admin",
}); // admin only
await chatClient.conversations.update({
  conversationId: group.data.id,
  name: null,
}); // rename; null clears the title (admin only)
await chatClient.conversations.removeParticipant({
  conversationId: group.data.id,
  userId: "me",
}); // your own id = leave; removing the last admin is 409 LAST_ADMIN_REMAINING

The membership SSE events (participant.added, participant.removed, conversation.updated) update the cache on their own: renames and role changes land in place without reordering the list, being added to a group fetches and prepends it, and being removed drops the conversation from the cache - your own participant.removed is the only signal you get, since any later request would be FORBIDDEN_READ. A polling client converges on its next tick instead, including the removal case (the poll's FORBIDDEN_READ triggers the same drop). Like reactions, these events are not gap-filled after a reconnect - refetch conversations when the stream reopens.

Invites, join requests, and channels

The imperative client wraps invite links, join-request moderation, and public channel discovery. Group creation and updates accept visibility and joinPolicy so a channel needs no raw request:

const channel = await chatClient.conversations.createGroup({
  name: "Engineering",
  visibility: "public",
  joinPolicy: "approval",
});

const directory = await chatClient.channels.list();
if (directory.error === null) {
  const preview = directory.data.channels[0];
  if (preview !== undefined && !preview.requestPending && !preview.alreadyParticipant) {
    const result = await chatClient.channels.join({
      conversationId: preview.conversationId,
      message: "I work on this project",
    });
    if (result.error === null && result.data.status === "pending") {
      console.log("Request pending", result.data.joinRequest.id);
    }
  }
}

invites.accept and channels.join return status: "joined" with a conversation or status: "pending" with a join request. Repeated pending requests return the existing request. The client preserves INVITE_NOT_FOUND (404), INVITE_EXPIRED (410), JOIN_REQUEST_NOT_FOUND (404), INVITES_UNSUPPORTED (501), and CHANNELS_UNSUPPORTED (501) as structured errors.

Moderation

chatClient.moderation.* wraps all thirteen moderation routes. Blocking, muting, and reporting are self-service; the report queue and bans need the server's moderation.canModerate hook and otherwise return NOT_MODERATOR (403).

await chatClient.moderation.blockUser({ targetUserId: "bob" });
await chatClient.moderation.muteConversation({ conversationId });
await chatClient.moderation.report({
  targetType: "message", // "user" | "message" | "conversation"
  targetId: messageId,
  reason: "harassment",
});

// additional self-service actions
await chatClient.moderation.unblockUser({ targetUserId: "bob" });
await chatClient.moderation.listBlockedUsers({ limit: 20 });
await chatClient.moderation.unmuteConversation({ conversationId });
await chatClient.moderation.listMutedConversations();

// moderators only
const queue = await chatClient.moderation.listReports({ status: "open" });
await chatClient.moderation.getReport({ reportId });
await chatClient.moderation.updateReport({ reportId, status: "triaged" });
await chatClient.moderation.listBans({ activeOnly: true });
await chatClient.moderation.banUser({ targetUserId: "troll", reason: "spam" });
await chatClient.moderation.unbanUser({ banId });

Blocks and mutes are idempotent, and a repeated report for the same target returns the existing one. A mute is a hint for your own UI - unread counts and SSE delivery are unchanged, so read listMutedConversations and suppress notifications yourself. A banned caller gets USER_BANNED (403) from every route including /stream, which means a live subscription is closed at the next heartbeat rather than instantly; treat that code as "stop retrying and show a blocked screen". Adapters without the capability return MODERATION_UNSUPPORTED (501) as a structured error. Missing reports and bans return REPORT_NOT_FOUND and BAN_NOT_FOUND; malformed successful envelopes return INVALID_RESPONSE. Expected failures never throw.

None of the moderation actions update the query cache. Refetch conversations or messages yourself after a mutation that should change what the user sees.

Search all conversations visible to the signed-in participant with the imperative API or the React hook:

import { useMessageSearch } from "@chatpack/client/react";

const page = await chatClient.messages.search({ query: "release ready", limit: 20 });
const search = useMessageSearch(chatClient, { query: "release ready", limit: 20 });
await search.loadMore();

Matching is case-insensitive, whole-token, and AND-based. Core and the adapter own participant checks, tokenization, ranking, tombstone exclusion, and cursor encoding. An adapter without search returns a structured 501 SEARCH_UNSUPPORTED result.

Search pages are ranked snapshots. New messages are not inserted or re-ranked; edits and tombstones patch loaded hits in place, and losing conversation access removes its hits. Call refetch() to recompute matches and rank. An empty hook query stays idle. Debounce text input before passing it to useMessageSearch; the client retains at most ten normalized query entries per instance.

Methods return a discriminated { data, error } result. Expected HTTP failures do not throw. Network failures use NETWORK_ERROR; server error codes remain available, such as UNAUTHENTICATED, FORBIDDEN_WRITE, and MESSAGE_DELETED.

The client never owns authentication. Same-origin cookies work by default; cross-origin cookie sessions need credentials: "include". Native EventSource cannot send custom headers, so bearer tokens are not placed in the stream URL.

The client keeps a small per-instance cache. REST results and durable SSE events update that cache — both the open thread and the conversations list, which reorders and updates unreadCount on incoming messages (see Client realtime). A single lazy realtime connection covers all conversations. Ephemeral plugin events are dispatched but never persisted in message history.

Where SSE can't work — serverless function timeouts, buffering proxies, React Native — the client falls back to polling on its own, so a serverless deploy works unconfigured. Tune or opt out with realtime:

createChatClient({
  realtime: { mode: "auto", intervalMs: 5000 }, // "auto" (default) | "sse" | "poll"
});

Typing, presence and receipts are unavailable while polling — see Polling fallback.

Pass userId if you know the signed-in user's id. It is an optional cache hint that stops the viewer's own messages from counting as unread; it is never used for authentication.

On this page