Chatpack
Core Concepts

Server API

Call the chat engine directly from server code with chat.api.* - every method, and when to use it over HTTP.

chat.api.* is the same domain logic the HTTP routes use, callable directly from your server code. Every method takes an explicit userId and enforces the same permissions.

These are the only methods on chat.api - do not invent others. In particular there is no chat.api.getOrCreateDirectConversation: that name is a low-level storage adapter method. Never call the adapter directly.

Methods

MethodWhat it does
api.getOrCreateConversationFind or create the 1:1 conversation for a user pair
api.createGroupConversationCreate a group with the caller as its first admin - always a new one, never find-or-create
api.listConversationsList a user's conversations (DMs and groups), most recent first
api.getConversationFetch one conversation (read-permission checked)
api.updateConversationRename a group (or clear its name with null), and/or set its visibility / joinPolicy (admin only)
api.addParticipantsAdd members to a group as member (admin only); idempotent
api.removeParticipantRemove a member, or leave by passing your own id (admin, or self); idempotent
api.setParticipantRolePromote to admin or demote to member (admin only)
api.sendMessageSend a text message, optionally quote-replying to another or mentioning participants (write-permission checked; message hooks apply)
api.listMessagesPaginate history, newest-first
api.searchMessagesSearch the caller's conversations, relevance-ranked (throws SEARCH_UNSUPPORTED when storage has no search capability)
api.editMessageEdit your own message
api.deleteMessageSoft-delete your own message
api.forwardMessageCopy a message into another conversation (read on the source, write on the target); the copy is a new message, sent by you
api.addReactionReact as userId (write-permission checked); idempotent
api.removeReactionRemove one of your own reactions; idempotent
api.markReadUpdate durable read-state (lastReadMessageId); monotonic - marking an older message is a silent no-op
api.listMessagesAfterMessages after a seq (SSE reconnect gap-fill)
api.createInviteMint a shareable invite link for a group (canInvite, admin by default)
api.listInvitesA group's invites, newest-first, spent ones included (admin only)
api.revokeInviteDelete an invite; revoking an unknown code is a silent no-op (admin only)
api.getInvitePreviewWhat a link admits you to - an InvitePreview, not a conversation
api.acceptInviteRedeem a link: joins, or files a request when the link requires approval
api.requestToJoinAsk to join a group by id; no permission needed, but not if you're already in
api.listJoinRequestsThe moderation queue, pending by default (admin only)
api.resolveJoinRequestApprove or deny one user's request (admin only)
api.listPublicConversationsBrowse the public-channel directory as ChannelPreviews, most recently active first
api.joinConversationJoin a public channel by id: admitted instantly, or filed as a request

Moderation lives in its own namespace, chat.api.moderation.*:

MethodWhat it does
moderation.blockUserBlock another user; idempotent, self-service
moderation.unblockUserLift your own block; idempotent
moderation.listBlockedUsersWho you've blocked, cursor-paginated
moderation.muteConversationMute a conversation for yourself - a UI hint, not a server-side filter
moderation.unmuteConversationUnmute; idempotent
moderation.listMutedConversationsYour mutes, cursor-paginated
moderation.reportReport a user, message, or conversation; reuses an open/triaged report for the same target
moderation.listReportsThe report queue, filterable by status and target type (canModerate)
moderation.getReportOne report with its immutable evidence (canModerate)
moderation.updateReportMove a report to triaged / resolved / dismissed, with a note (canModerate)
moderation.listBansBans, active-only or the whole audit history (canModerate)
moderation.banUserBan a user, permanently or until expiresAt; returns the existing active ban if there is one (canModerate)
moderation.unbanUserRevoke a ban, keeping the row for audit (canModerate)

The four group-management methods work on type: "group" conversations only; calling one with a DM's id throws NOT_GROUP_CONVERSATION. All four return the full updated conversation.

The eight invite methods need an optional storage capability and throw INVITES_UNSUPPORTED when the configured adapter lacks it - both first-party adapters have it, a custom one may not. They are group-only too.

The two channel methods need a second optional capability and throw CHANNELS_UNSUPPORTED without it - as does any attempt to set a non-default visibility or joinPolicy through createGroupConversation / updateConversation. That last part is deliberate: dropping the field silently would create a channel no directory could ever find. Passing the defaults explicitly still works on an adapter without the capability. An "approval" channel routes its joiners into the invite queue, so it needs the invites capability too.

The thirteen moderation methods need a third optional capability and throw MODERATION_UNSUPPORTED without it. The seven moderator-only ones additionally need chatpack({ moderation: { canModerate } }) and throw NOT_MODERATOR otherwise. Configuring moderation at all is also what switches on ban enforcement - an active ban then throws USER_BANNED from every chat.api.* call and 403s every route; see Permissions.

Which API do I call?

The same task, from both sides - chat.api.* in server code, the REST route from a browser or HTTP client:

I want to...Server (chat.api.*)HTTP
Start a chat with someonegetOrCreateConversationPOST /conversations
Start a groupcreateGroupConversationPOST /conversations/group
Show the inbox / sidebarlistConversationsGET /conversations
Open one conversationgetConversationGET /conversations/:id
Rename a groupupdateConversationPATCH /conversations/:id
Add membersaddParticipantsPOST /conversations/:id/participants
Remove a member / leaveremoveParticipantDELETE /conversations/:id/participants
Promote or demotesetParticipantRolePATCH /conversations/:id/participants
Load history / scroll backlistMessagesGET /conversations/:id/messages
Search messagessearchMessagesGET /search/messages?q=...
Send a messagesendMessagePOST /conversations/:id/messages
Edit / delete my messageeditMessage, deleteMessagePATCH / DELETE /messages/:id
Forward a messageforwardMessagePOST /messages/:id/forward
React / un-reactaddReaction, removeReactionPOST / DELETE /messages/:id/reactions
Mark a conversation readmarkReadPOST /conversations/:id/read
Mint an invite linkcreateInvitePOST /conversations/:id/invites
List / revoke inviteslistInvites, revokeInviteGET / DELETE /conversations/:id/invites
Show a link's landing pagegetInvitePreviewGET /invites/:code
Join via a linkacceptInvitePOST /invites/:code/accept
Ask to join a grouprequestToJoinPOST /conversations/:id/join-requests
Work the approval queuelistJoinRequests, resolveJoinRequestGET / PATCH /conversations/:id/join-requests
Publish a group as a channelupdateConversationPATCH /conversations/:id
Browse public channelslistPublicConversationsGET /channels
Join a channeljoinConversationPOST /conversations/:id/join
Block / unblock a usermoderation.blockUser, unblockUserPOST / DELETE /moderation/blocks
Mute / unmute a conversationmoderation.muteConversation, unmuteConversationPOST / DELETE /moderation/mutes
Report abusemoderation.reportPOST /moderation/reports
Work the report queuemoderation.listReports, updateReportGET /moderation/reports, PATCH /moderation/reports/:id
Ban / unban a usermoderation.banUser, unbanUserPOST /moderation/bans, DELETE /moderation/bans/:id
Get live updates in the browser- (server-sent events)GET /stream via EventSource

Usage

// find-or-create a 1:1 conversation between two users
const conversation = await chat.api.getOrCreateConversation({
  userId: "alice",
  otherUserId: "bob",
});

// send a message
const message = await chat.api.sendMessage({
  userId: "alice",
  conversationId: conversation.id,
  body: "hey bob!",
});

// quote-reply to it - a flat pointer, not a thread
const reply = await chat.api.sendMessage({
  userId: "bob",
  conversationId: conversation.id,
  body: "hey alice!",
  replyToMessageId: message.id,
});
reply.replyTo; // { id, senderId, excerpt: "hey bob!", deleted: false }

// react (idempotent both ways; returns the message with its whole set)
const reacted = await chat.api.addReaction({
  userId: "bob",
  messageId: message.id,
  emoji: "👍",
});
reacted.reactions; // [{ emoji: "👍", count: 1, userIds: ["bob"] }]
await chat.api.removeReaction({ userId: "bob", messageId: message.id, emoji: "👍" });

// mention participants - ids you supply, never parsed out of the body
const mentioning = await chat.api.sendMessage({
  userId: "alice",
  conversationId: conversation.id,
  body: "@bob can you look?",
  mentions: ["bob"], // must be current participants, else MENTION_NOT_PARTICIPANT
});
mentioning.mentions; // ["bob"] - a set; treat the order as unspecified

// forward it somewhere else - a COPY, with you as sender
const forwarded = await chat.api.forwardMessage({
  userId: "alice",
  messageId: message.id,
  toConversationId: "conv_7", // any conversation alice can write to
});
forwarded.forwardedFrom; // { messageId, conversationId, senderId } - frozen, three ids
// Editing or deleting `message` now changes nothing about `forwarded`.

// search participant conversations (case-insensitive, canonical token matching, ranked)
const search = await chat.api.searchMessages({
  userId: "bob",
  query: "hello",
  limit: 50,
});
// Throws SEARCH_UNSUPPORTED when storage adapter has no search capability.

// paginate history (newest first)
const { messages, nextCursor } = await chat.api.listMessages({
  userId: "bob",
  conversationId: conversation.id,
  limit: 50,
});

// durable read-state
await chat.api.markRead({
  userId: "bob",
  conversationId: conversation.id,
  messageId: message.id,
});

Groups differ only in how they are created and how membership changes; messages, read-state, search and reactions are identical:

// alice becomes the group's first admin; bob and carol join as members.
// Calling this twice creates TWO groups - store the id you get back.
const group = await chat.api.createGroupConversation({
  userId: "alice",
  userIds: ["bob", "carol"],
  name: "Launch",
});
group.type; // "group" - and pairKey is null, since only DMs have one

// Membership writes are admin-only and idempotent; each returns the whole
// conversation, so overwrite your cached copy rather than merging.
await chat.api.addParticipants({
  userId: "alice",
  conversationId: group.id,
  userIds: ["dave"],
});
await chat.api.setParticipantRole({
  userId: "alice",
  conversationId: group.id,
  targetUserId: "bob",
  role: "admin",
});

// Anyone can remove themselves - that is "leave", and needs no admin rights.
await chat.api.removeParticipant({
  userId: "dave",
  conversationId: group.id,
  targetUserId: "dave",
});

// Rename, or pass null to clear the title.
await chat.api.updateConversation({
  userId: "alice",
  conversationId: group.id,
  name: "Launch week",
});

A group always keeps at least one admin: removing or demoting the last one throws LAST_ADMIN_REMAINING rather than silently promoting someone, because choosing a successor is a product decision Chatpack shouldn't make for you.

Invite links are the way to add someone whose user id you don't have - or who doesn't have an account yet:

// Mint a link. Every option is optional: no arguments beyond the ids means a
// link that never expires and admits unlimited people.
const invite = await chat.api.createInvite({
  userId: "alice",
  conversationId: group.id,
  expiresInSeconds: 86_400,
  maxUses: 5,
});
invite.code; // 43 URL-safe chars - build your own /join/:code page around it

// The landing page. Deliberately NOT getConversation: a non-member may call
// this, so it carries a participant count and never any user ids.
const preview = await chat.api.getInvitePreview({ userId: "erin", code: invite.code });
preview.alreadyParticipant; // false - render "Join", not "Open"

// Redeeming. Branch on `status`, not on which field came back null.
const result = await chat.api.acceptInvite({ userId: "erin", code: invite.code });
if (result.status === "joined") {
  result.conversation; // the group, now including erin
} else {
  result.joinRequest; // requiresApproval was true - an admin must resolve it
}

// Clicking the link twice is harmless and costs the invite nothing: erin gets
// the conversation back, even once the link is out of uses.

An approval-gated link (requiresApproval: true) routes through the same queue as a user asking directly - which anyone may do for a group id they know:

await chat.api.requestToJoin({
  userId: "frank",
  conversationId: group.id,
  message: "I'm on the design team", // optional note for the admins
});
// Already a participant? Throws ALREADY_PARTICIPANT - there is no join request
// that honestly represents "you're already in".

// The queue defaults to pending. No event fires when a request arrives, so
// admins poll this.
const queue = await chat.api.listJoinRequests({ userId: "alice", conversationId: group.id });

// Resolved by user id, not request id: one request per user per group.
const { joinRequest, conversation } = await chat.api.resolveJoinRequest({
  userId: "alice",
  conversationId: group.id,
  targetUserId: "frank",
  decision: "approve", // "deny" leaves conversation null and keeps the row
});

Approving publishes the same participant.added event an admin-initiated addParticipants does, so existing subscribers need no new code. A denied user may ask again - denial records a decision, it is not a block.

A channel is the third way in: a group with visibility: "public", listed in a directory anyone signed in can browse. There is no third conversation type - the same Conversation, two extra fields.

// Publish an existing group, or pass the same two fields to
// createGroupConversation. Admin-only (canManage, not canInvite) - loosening
// who may invite must not also decide who may expose the group to everyone.
const channel = await chat.api.updateConversation({
  userId: "alice",
  conversationId: group.id,
  visibility: "public",
  joinPolicy: "open", // omit and it stays "approval" - the recoverable default
});

// The directory. Thin previews, like an invite preview and for the same
// reason: strangers read this, so it carries a participant COUNT, never ids.
const { channels, nextCursor } = await chat.api.listPublicConversations({
  userId: "grace",
  limit: 20, // most-recently-active first, same keyset paging as listConversations
});
channels[0]?.alreadyParticipant; // render "Open" / "Pending" / "Join" from these
channels[0]?.requestPending;

// Joining. Same discriminated union as acceptInvite - branch on `status`.
const result = await chat.api.joinConversation({
  userId: "grace",
  conversationId: channel.id,
  message: "found you in the directory", // only used on an "approval" channel
});
if (result.status === "joined") result.conversation;
else result.joinRequest; // lands in the same queue, with inviteCode: null

Public means discoverable, not readable: browsing grants no read access, so getConversation and listMessages still throw FORBIDDEN_READ for a non-member. The permission layer is untouched by this feature - to read a channel you join it. An invite still overrides the channel's policy, because the policy lives on whatever the joiner presents: a link minted without requiresApproval walks straight into an "approval" channel.

First-party adapters use the same Unicode NFKC, case-insensitive, punctuation-separated token matching. All unique query terms must be present; term occurrence count determines relevance before createdAt and message id tie-breaks. Tombstones are excluded. Core still applies canRead after the participant scope, so non-participant dynamic access is not supported yet.

Validating that a user exists

Chatpack never owns a users table, so by default otherUserId and group userIds are opaque strings it stores without question. That is usually what you want - until a typo or a stale id creates a conversation with somebody who does not exist, which nobody can ever open.

Pass userExists to close that gap without handing Chatpack your identity data:

export const chat = chatpack({
  storage,
  auth,
  // Your identity store, your query. Return false for "no such user".
  userExists: async (userId) =>
    Boolean(
      await db.query.users.findFirst({
        where: eq(users.id, userId),
      }),
    ),
});

Core calls it before creating a direct conversation, before seeding a group, and before adding participants. A false answer throws USER_NOT_FOUND (HTTP 404). Omit the option and nothing changes from previous versions.

Three things are worth knowing, because they decide how hard this hits your database:

  • The acting user is never checked. Your auth hook already vouched for them; re-querying would be a second lookup for an answer you have.
  • Ids are deduplicated first, and the acting user's own id is dropped from a group's member list, so a repeated id is never a repeated query.
  • Checks run in bounded batches, not one after another. Seeding a 50-member group costs a handful of round trips rather than 50, and a 256-member group still cannot open 256 simultaneous connections. If you want one query for many ids, do the batching inside your own hook.

This validates existence, not permission. "Bob exists" and "Alice is allowed to message Bob" are separate questions - the second one belongs to permissions and moderation.

Return shapes: bare objects, no envelopes

Server-side methods return the bare object (Conversation, Message, ...). The HTTP layer wraps responses in envelopes ({ conversation }, { message }, { messages, nextCursor }) - that envelope is HTTP-only and intentional (room to add sibling fields without breaking clients). Don't reuse HTTP-response types for chat.api.* calls or vice versa.

Conversation-returning methods (getOrCreateConversation, listConversations, getConversation) return ConversationWithUnread - the conversation plus the calling user's unreadCount (messages newer than their read-state, excluding their own). See Conversations & messages.

Every message-returning method (sendMessage, listMessages, searchMessages, editMessage, deleteMessage, addReaction, removeReaction, forwardMessage, listMessagesAfter) returns MessageWithDetails - the stored Message plus decorations: replyTo (the quoted parent's preview, or null), reactions (grouped by emoji), mentions (the ids named in it), and forwardedFrom (three ids, or null). replyTo and reactions are not stored at all: core computes them from batched adapter calls, one per page, which is why an edited parent's excerpt is never stale. mentions comes from its own batched call over stored rows, and forwardedFrom is assembled from three columns on the message itself - both are durable, so unlike replyTo they cannot change after the fact. Storage adapters and permission hooks only ever see the bare Message.

Timestamps are real Date instances on the server, ISO strings over HTTP.

Errors: thrown, never null

All failures throw ChatpackError with a stable code - methods never return null for missing resources. api.getConversation throws CONVERSATION_NOT_FOUND; don't confuse it with the storage adapter's getConversation, which returns Conversation | null (core is the layer that turns a null into the domain error).

import { ChatpackError } from "@chatpack/core";

try {
  await chat.api.getConversation({ userId, conversationId });
} catch (err) {
  if (err instanceof ChatpackError && err.code === "CONVERSATION_NOT_FOUND") {
    // handle the missing conversation
  } else {
    throw err;
  }
}

See Error handling for the full code list.

On this page