Chatpack
Open-source chat infrastructure for developers.
Install a package, wire up your database and auth, and get a production-readychat backend - 1:1 and group conversations, messages, permissions, read-state,and real-time delivery - without rebuilding it from scratch.
Documentation → docs.chatpack.dev -quickstart, concepts, real-time, storage adapters, framework guides, and thefull REST reference. (Source in apps/docs; run locally withpnpm --filter @chatpack/docs dev.)
A project by DanielCH andDavidCH · principal authorYeabsra Habtu ·all contributors
Status:
0.x- v0 MVP + real-time plugins + unread counts + browserclient + reactions + search + group chats + mentions + forwarding, live onnpm. The v0 MVP (core engine,HTTP handler, real-time SSE, Postgres adapter) plus the opt-in real-timeplugins -typing(),presence(), andreceipts(), all shipping todayinside@chatpack/coreunder the@chatpack/core/pluginssubpath (seeReal-time plugins) - arepublished and installable now, along with the first-party@chatpack/client, which provides the matching typedREST, SSE, and React client. The API is young - expect minor breakingchanges before1.0. Follow along or contribute.
Why
Every app that needs messaging ends up rebuilding the same things: conversations,messages, permissions, read receipts, real-time delivery, group membership androles, and countless edge cases.
Chatpack removes that repetition - the same way BetterAuth did for authentication.You bring your auth and your frontend; Chatpack gives you a small,well-designed chat backend that just works.
Real-time comes built in: your frontend opens one EventSource and getslive messages with automatic reconnection and missed-message backfill - noWebSocket server, no Socket.IO, no reconnect code to write.
How it fits together
Your frontend ── fetch("/api/chat/…") + EventSource("/api/chat/stream")
│
▼
chat.handler() one Web-standard handler (Request → Response)
│
├── auth hook your session → { id: userId } (you own users)
▼
chat.api.* domain logic, permissions (also callable directly)
│
▼
StorageAdapter memory · Drizzle/Postgres · Drizzle/SQLite · Turso/libSQL · Supabase · your own
│
▼
Your database
Quickstart
Prefer learning from a complete app?
examples/messengeris a full 1:1 messenger - sidebar, live messages, read receipts - in vanillaHTML+JS with a step-by-step tutorial README.
1. Install
Both packages are needed for the quickstart - @chatpack/core is the engine,@chatpack/adapter-memory is the storage it plugs into:
# pick your package manager
npm install @chatpack/core @chatpack/adapter-memory
pnpm add @chatpack/core @chatpack/adapter-memory
bun add @chatpack/core @chatpack/adapter-memory
Bun note: if Bun's supply-chain guard (
minimumReleaseAge) is enabled,versions published in the last 24 h are skipped and Bun silently resolves anolder release. If you get an unexpectedly old version right after a release,that's the guard - not a broken package. Check withnpm view @chatpack/core dist-tags.
2. Create your chat server
// lib/chat.ts
import { chatpack } from "@chatpack/core";
import { memoryAdapter } from "@chatpack/adapter-memory";
export const chat = chatpack({
storage: memoryAdapter(),
// resolve the current user from a request - the ONLY auth touchpoint.
// Concrete example with a session cookie (works with any auth library):
auth: async (req) => {
const session = await getSessionFromCookie(req.headers.get("cookie"));
return session ? { id: session.userId } : null;
},
});
The
authhook must returnChatpackUser | null- an object with atleast{ id: string }(extra fields are allowed and ignored), ornullfor unauthenticated requests. Returning a bare string is treated asunauthenticated and every request will get a401.Prefer cookie-based sessions over
Authorizationheaders: the browsersends cookies automatically on every request - including the SSE stream instep 6, where custom headers are impossible.The hook receives a raw Web-standard
Request- there is norequest.cookieshelper. Parse thecookieheader yourself:// demo auth: a plain cookie naming the user (swap for your auth library) auth: (request) => { const cookie = request.headers.get("cookie") ?? ""; const id = /(?:^|;\s*)demo_user=([^;]+)/.exec(cookie)?.[1] ?? null; return id ? { id: decodeURIComponent(id) } : null; },Setting the demo cookie in an embedded preview (Lovable, v0, Bolt, ...)?Those editors show your app inside a cross-site iframe, where browserssilently drop
SameSite=Laxcookies - the app 401s in the preview pane butworks in a real tab. Set demo cookies with iframe-proof attributes:document.cookie = "demo_user=alice; Path=/; Max-Age=86400; SameSite=None; Secure; Partitioned";
For production, swap the storage line for Postgres -@chatpack/adapter-drizzle:
import { drizzle } from "drizzle-orm/node-postgres";
import { drizzleAdapter } from "@chatpack/adapter-drizzle";
export const chat = chatpack({
storage: drizzleAdapter(drizzle(process.env.DATABASE_URL!)),
auth: async (req) => getSessionUser(req),
});
If your application already uses Prisma ORM 7, use the server-only@chatpack/adapter-prisma adapter. Copy itsPrisma models and migration into your app, generate your own client, and passit to prismaAdapter(client). Verified provider is PostgreSQL 16; Prisma 8and other providers are not claimed compatible.
No direct Postgres connection string? Platforms that only expose adatabase client (Supabase's JS client, Convex, and most AI-builder clouds)are supported through a custom
StorageAdapter. The full guide - referenceschema, invariants, skeleton, and a verification checklist - is Part 2 ofllms.txt.Building with an AI assistant or app builder?
llms.txtis the single-fetch integration guide (hard rules, wiring, per-frameworkmount recipes, preview-iframe cookie recipe, verification steps). It alsoships inside every@chatpack/*npm package asllms.txt- point youragent atnode_modules/@chatpack/core/llms.txt.
For Turso/libSQL, install @chatpack/adapter-turso, @libsql/client, anddrizzle-orm, then run its exported migration statements before creating theChatpack instance. See the Turso adapter guide.
For MySQL 8, install @chatpack/adapter-mysql, mysql2, and drizzle-orm,create a server-side transaction-capable pool, and run its exported migrationstatements before creating the Chatpack instance. See the MySQL adapterguide. This package does not claimMariaDB, PlanetScale, Aurora, serverless/HTTP drivers, or edge-runtime support.
Using a coding agent (Claude Code, Cursor, Codex)? Install theChatpack agent skill into your app's repo so the agent followsthe correct workflow automatically:
npx skills add chddaniel/chatpack
For MCP clients, @chatpack/mcp provides read-only tools fordocumentation search, framework guides, and React UI source. See its README forlocal setup and client configuration.
3. Mount the API (Next.js App Router)
// app/api/chat/[...chatpack]/route.ts
import { chat } from "@/lib/chat";
export const { GET, POST, PATCH, DELETE, PUT } = chat.handler();
Or, with the @chatpack/next helper (same result, readsbetter):
import { toNextRouteHandlers } from "@chatpack/next";
import { chat } from "@/lib/chat";
export const { GET, POST, PATCH, DELETE, PUT } = toNextRouteHandlers(chat);
The route file must be a catch-all (
[...chatpack]in Next.js) -Chatpack serves many sub-paths underbasePath(default/api/chat), so asingleapp/api/chat/route.tswould 404 everything but the root.Never hand-write your own message or stream routes. The one handleralready serves every route - conversations, messages, read-state, plugins,and the SSE stream. Custom
/api/messages-style routes split state andbreak live delivery.
Your chat backend is now live at /api/chat - find-or-create conversations,send/list/edit/delete messages, read-state, and a live SSE stream at/api/chat/stream, with your auth enforced on every request.
Not on Next.js? The handler is Web-standard (Request → Response) andGET/POST/PATCH/DELETE/PUT/fetch are all the same function - themethod names only exist so they can be re-exported from a Next.js route file.Any of them serves every route, including /stream:
const handler = chat.handler();
Bun.serve({ fetch: handler.fetch }); // Bun / Deno / Cloudflare Workers
app.all("/api/chat/*", (c) => handler.fetch(c.req.raw)); // Hono
app.all("/api/chat/*", ({ request }) => handler.fetch(request)); // Elysia
TanStack Start (src/routes/api/chat.$.ts catch-all) and Express recipeslive in @chatpack/core's README andllms.txt. For plain Node, seeexamples/node-server.
4. Call it over HTTP
Find-or-create a conversation (the authenticated user + otherUserId):
curl -X POST /api/chat/conversations \
-H 'content-type: application/json' \
-d '{"otherUserId": "bob"}'
Chatpack never owns a users table. Configure
userExists(userId)tovalidate direct-chat targets and new group participants against your ownidentity store. Without the optional hook, previous opaque-id behavior ispreserved.
{
"conversation": {
"id": "conv_1",
"pairKey": "alice:bob",
"createdAt": "2026-07-22T19:47:47.945Z",
"metadata": {},
"participants": [
{ "conversationId": "conv_1", "userId": "alice", "joinedAt": "…", "lastReadMessageId": null },
{ "conversationId": "conv_1", "userId": "bob", "joinedAt": "…", "lastReadMessageId": null }
],
"unreadCount": 0
}
}
Every conversation object carries the viewer's unreadCount (messagesnewer than their read-state, excluding their own) - the badge number comesfrom the API, no client-side counting.
Groups are created, never found - a separate route, because two groups withthe same members are still two different groups:
curl -X POST /api/chat/conversations/group \
-H 'content-type: application/json' \
-d '{"name": "Standup", "userIds": ["bob", "carol"]}'
The caller becomes an admin, everyone in userIds a member, and theconversation comes back with type: "group", pairKey: null, and the name.Managing it afterwards is four admin-only routes - rename(PATCH /conversations/:id), add (POST /conversations/:id/participants),remove (DELETE, and any member may pass their own id to leave), and change arole (PATCH …/participants). Groups hold 1-256 participants and always keep atleast one admin.
For the people whose user ids you don't have, mint an invite link instead:
curl -X POST /api/chat/conversations/conv_2/invites \
-H 'content-type: application/json' \
-d '{"expiresInSeconds": 86400, "maxUses": 5}'
You get back a 43-character code to build your own /join/:code page from.GET /invites/:code previews what it admits to - a participant count, neverthe member list, since a non-member can call it - andPOST /invites/:code/accept redeems it. Add "requiresApproval": true andredeeming files a join request for an admin to approve instead, which is thesame queue any user lands in by asking directly(POST /conversations/:id/join-requests). Either way, joining publishes theexisting participant.added event, so live clients need no new code.
When you want people to find the room themselves, publish the group as apublic channel - a group with visibility: "public", not a thirdconversation type:
curl -X PATCH /api/chat/conversations/conv_2 \
-H 'content-type: application/json' \
-d '{"visibility": "public", "joinPolicy": "open"}'
GET /channels is then a browsable directory for any signed-in user, returningthin previews - a name, a participant count, and two viewer-relative flags -and POST /conversations/:id/join gets them in: instantly when the policy is"open", or as a join request when it's "approval" (the default, because astranger in a queue is recoverable and a stranger in the room isn't).Discoverable is not readable: browsing grants nothing, so reading thetranscript still means joining first.
Letting strangers in needs the other half too, so /moderation/* covers blocks,mutes, reports, and bans. Blocking, muting, and filing a report areself-service:
curl -X POST /api/chat/moderation/blocks \
-H 'content-type: application/json' \
-d '{"targetUserId": "bob"}'
A block stops new DMs and direct writes both ways while leaving the existinghistory readable, and does nothing inside a shared group. A mute is a hint foryour own UI - unread counts and SSE delivery don't change. The report queue andthe ban routes are for your moderators, so they need a hook:
chatpack({
storage,
auth,
moderation: { canModerate: ({ user }) => user.role === "staff" },
});
Without it, GET /moderation/reports and every ban route answer 403 NOT_MODERATOR. With it, an active ban is checked before routing - a banneduser gets 403 USER_BANNED on every route including /stream. Configuringmoderation at all is what switches that enforcement on, so an app that doesn'tuse bans pays no per-request lookup; add enforceBans: true if ban rows arewritten outside Chatpack.
Send a message - note the field is body:
curl -X POST /api/chat/conversations/conv_1/messages \
-H 'content-type: application/json' \
-d '{"body": "hey bob!"}'
{
"message": {
"id": "msg_1",
"conversationId": "conv_1",
"senderId": "alice",
"body": "hey bob!",
"role": "user",
"seq": 1,
"createdAt": "2026-07-22T19:48:06.416Z",
"editedAt": null,
"deletedAt": null,
"metadata": {},
"replyToMessageId": null,
"replyTo": null,
"reactions": [],
"mentions": [],
"forwardedFrom": null
}
}
Quote-reply by passing replyToMessageId, and react with a POST (removing isthe same route with DELETE; the emoji travels in the body, not the path):
curl -X POST /api/chat/conversations/conv_1/messages \
-H 'content-type: application/json' \
-d '{"body": "hey alice!", "replyToMessageId": "msg_1"}'
curl -X POST /api/chat/messages/msg_1/reactions \
-H 'content-type: application/json' \
-d '{"emoji": "👍"}'
A reply carries a read-only replyTo preview({ id, senderId, excerpt, deleted }) hydrated per request - edit the parentand the quote bar follows. Reaction routes are idempotent and always return themessage with its complete reaction set([{ emoji, count, userIds }]). These are quote-replies, not threads, and areaction is not a message: it has no seq and never reorders the conversationlist.
Mentions are ids you supply, not text Chatpack parses - it has no users tableto resolve a name against, and body stays opaque. Forwarding copies amessage into another conversation:
curl -X POST /api/chat/conversations/conv_2/messages \
-H 'content-type: application/json' \
-d '{"body": "@carol ship it", "mentions": ["carol"]}'
curl -X POST /api/chat/messages/msg_1/forward \
-H 'content-type: application/json' \
-d '{"conversationId": "conv_2"}'
Every mentioned id must be a current participant, or the whole call is 400 MENTION_NOT_PARTICIPANT - never a silent drop, because a drop nobody sees looksexactly like a notification that fired. On edit, omitting mentions leaves thestored set alone and [] clears it. Chatpack notifies nobody and keeps no mentioninbox: afterMessageMutation hands you mentions next to recipientIds, which iswhere a push integration belongs.
A forward is a copy, never a live pointer: a new message in the target withyour id as sender, its own seq, and forwardedFrom({ messageId, conversationId, senderId }) frozen at forward time. Editing ordeleting the original changes nothing about the copy. One hop, like replies -and deliberately no excerpt and no source conversation name, since whoever readsthe copy may have no access to where it came from. Reactions, the reply pointer,mentions, metadata and role don't travel.
List history (newest first, keyset-paginated):
curl '/api/chat/conversations/conv_1/messages?limit=50'
{ "messages": [{ "id": "msg_1", "body": "hey bob!", "seq": 1, "…": "…" }], "nextCursor": null }
Search participant conversations across message bodies. Search iscase-insensitive, punctuation-separated, relevance-ranked, and excludestombstones:
curl '/api/chat/search/messages?q=hello&limit=50'
The response is { "messages": [...], "nextCursor": null }. Core appliescanRead to the participant-scoped results. Dynamic access to conversationswhere the user is not a participant is not supported by this initial design.
Errors are JSON with a stable machine-readable code and a mapped HTTP status -401 when auth returns null, 400 for invalid input, 403/404/409for domain errors:
{ "error": { "code": "FORBIDDEN_READ", "message": "…" } }
The full endpoint reference (every route, request/response shapes, errorcodes) lives in @chatpack/core's README.
5. Use the first-party client (optional)
The server setup above remains the same. Add the client when you want typedREST methods, one managed SSE connection, a small shared cache, and Reacthooks:
npm install @chatpack/client react
Create one shared client instance in its own module:
// lib/chat-client.ts
import { createChatClient } from "@chatpack/client/react";
import { typingClient, presenceClient, receiptsClient } from "@chatpack/client/plugins";
export const chatClient = createChatClient({
// Omit baseURL when the client and handler share an origin.
baseURL: "http://localhost:3000",
credentials: "include",
plugins: [typingClient(), presenceClient(), receiptsClient()],
});
Then read with hooks and write with actions - every action returns{ data, error } instead of throwing:
// components/messages.tsx
"use client";
import { chatClient } from "../lib/chat-client";
export function Messages({ conversationId }: { conversationId: string }) {
const result = chatClient.useMessages({ conversationId, limit: 50 });
async function send() {
const sent = await chatClient.messages.send({
conversationId,
body: "hey bob!",
});
if (sent.error) console.error(sent.error.message);
}
return (
<>
<ul>
{result.data?.messages.map((message) => (
<li key={message.id}>{message.body}</li>
))}
</ul>
<button onClick={send}>Send</button>
</>
);
}
The client uses the authenticated identity resolved by the server's authhook. It does not implement login, sessions, or user lookup. Same-origincookies work by default; use credentials: "include" for cross-origin cookiesessions. Native EventSource cannot send custom headers, so cookie auth isalso required for browser realtime unless you provide a custom EventSource.
Where SSE can't work - serverless function timeouts, buffering proxies, ReactNative - the client falls back to refetching on an interval by itself, so aserverless deploy needs no frontend change. Typing, presence and receipts areunavailable while polling, since ephemeral events are never stored.
Group management is wrapped too (client 0.5.0+): conversations.createGroup,addParticipants, removeParticipant (your own id = leave),setParticipantRole, and update for renames - and membership events keepthe cache in sync, including dropping a conversation you were removed from.Invites, join requests, and channels are wrapped by chatClient.invites,chatClient.joinRequests, and chatClient.channels. Invite and channel joinsreturn either a joined conversation or a pending request; expected HTTP failuresremain structured client results. chatClient.moderation wraps all thirteenmoderation calls the same way - note that none of them touch the query cache, sorefetch the lists you show after a block or a mute.
messages.send and messages.edit take mentions, and messages.forwardcopies a message into another conversation - resolving with the copy and echoingit into the target thread just like a send. The destination istoConversationId in the client input even though the wire field is a plainconversationId, because the route already names the source.
See @chatpack/client for the framework-agnostic API,React hooks, the polling fallback, and client plugin usage.
6. Go live in the browser
const events = new EventSource("/api/chat/stream");
// TypeScript: custom event names fall outside EventSourceEventMap, so the
// listener parameter is typed `Event` - cast to MessageEvent for `.data`.
events.addEventListener("message.created", (e) => {
const { message } = JSON.parse((e as MessageEvent).data);
// render it - reconnection & missed-message backfill are automatic
});
events.addEventListener("reaction.added", (e) => {
const { message } = JSON.parse((e as MessageEvent).data);
// message.reactions is the COMPLETE set after the change - replace, don't merge
});
events.addEventListener("participant.removed", (e) => {
const { affectedUserIds, conversation } = JSON.parse((e as MessageEvent).data);
// If affectedUserIds includes YOUR id, you were removed - drop the
// conversation. Otherwise replace your cached copy with `conversation`.
});
// participant.added and conversation.updated (rename / role change) match.
events.onerror = () => {
if (events.readyState === EventSource.CLOSED) {
// Fatal (e.g. 401 from your auth hook): the browser will NOT retry.
// Re-authenticate, then create a new EventSource.
}
// Otherwise it's a dropped connection: EventSource retries automatically
// and sends Last-Event-ID - no action needed.
};
If the connection drops, EventSource reconnects with Last-Event-ID andChatpack replays whatever was missed from storage - durable-first delivery,no lost messages.
Four things to know before going live:
- Membership changes are live too, and also not replayed.
participant.added/participant.removed/conversation.updatedcarry{ actorId, affectedUserIds, conversation }- a complete snapshot, so replaceyour cached conversation rather than patching it. CompareaffectedUserIdsagainst your own id to tell "I was removed" (drop it; it's the last eventyou'll see for that conversation) from "someone else was". - Reactions are live but not replayed.
reaction.added/reaction.removedare stored, unlike ephemeral plugin events, but reactionshave noseq- so their frames carry noid:(emitting one would rewindLast-Event-ID) and they are not gap-filled. A reaction applied while theclient was offline appears on the next refetch of that conversation. - Browser auth must be cookie-based for SSE -
EventSourcecan't sendcustom headers, so yourauthhook needs to resolve the user from a sessioncookie (sent automatically same-origin). Bearer-token headers work for theREST routes but not/stream- if your app uses them, write theauthhook to accept either (header first, cookie fallback); worked example in@chatpack/core's README. If the app runsinside an embedded preview iframe (AI-builder editors), the cookie needsSameSite=None; Secure- see the quickstart note in step 2. - SSE +
memoryAdapterneed one long-lived process. The default transportfans out inside a single process, so with 2+ app servers a message sent on onenode never reaches a stream on another - drop in@chatpack/transport-redis(one line) to relayevents between nodes. On serverless/edge (Workers, Lambda) each isolate hasits own memory - use a database adapter there andpoll for new messages; SSE is a poor fit regardless of transport, since thefunction lifetime is the blocker.@chatpack/clientfalls back to polling onits own, so a serverless deploy needs no frontend change. Details in@chatpack/core's README.
7. Or call it straight from server code
// find-or-create a 1:1 conversation between two users
const conversation = await chat.api.getOrCreateConversation({
userId: "alice",
otherUserId: "bob",
});
// send a message
await chat.api.sendMessage({
userId: "alice",
conversationId: conversation.id,
body: "hey bob!",
});
// read the history
const { messages } = await chat.api.listMessages({
userId: "bob",
conversationId: conversation.id,
});
// react to a message (idempotent - returns the full reaction set)
await chat.api.addReaction({ userId: "bob", messageId: messages[0].id, emoji: "👍" });
await chat.api.removeReaction({ userId: "bob", messageId: messages[0].id, emoji: "👍" });
Groups use a different first call - createGroupConversation always creates,and everything after it is the same API:
const group = await chat.api.createGroupConversation({
userId: "alice", // becomes the group's first admin
userIds: ["bob", "carol"], // joined as members
name: "Standup",
});
await chat.api.addParticipants({ userId: "alice", conversationId: group.id, userIds: ["dave"] });
await chat.api.setParticipantRole({
userId: "alice",
conversationId: group.id,
targetUserId: "bob",
role: "admin",
});
await chat.api.removeParticipant({
userId: "carol", // passing your own id = leaving; no admin needed
conversationId: group.id,
targetUserId: "carol",
});
That's it. Only participants can read or write - enforced by default,customizable via the permissions hooks (canRead, canWrite, canManage forthe group-management methods including publishing a channel, and canInvite forminting links - the last two default to admins only, and browsing or joining apublic channel is gated by neither). Platform-wide moderators are a separatehook, moderation: { canModerate }, because being an admin of one conversationshouldn't open the report queue for all of them. Need contentrules (length caps, profanity filters) or post-send side-effects? Addhooks: { beforeMessageSend, afterMessageMutation } - block or rewrite amessage before it persists, react after send/edit/delete persistence (see @chatpack/core'sREADME).
8. Bonus: chat with an AI assistant
To Chatpack, an AI assistant is just another participant - pick asynthetic user id (any string you'll never issue to a real user, e.g.ai:assistant) and have your backend send its replies. No special AI supportneeded, and the same permissions apply (drop the same id into a group'suserIds for a shared assistant):
const ASSISTANT_ID = "ai:assistant";
// find-or-create the user's conversation with the assistant
const conversation = await chat.api.getOrCreateConversation({
userId: user.id,
otherUserId: ASSISTANT_ID,
});
// the user's message arrives (via your route or the REST API)...
await chat.api.sendMessage({
userId: user.id,
conversationId: conversation.id,
body: userText,
});
// ...your backend calls your LLM of choice with your own keys...
const reply = await generateReply(userText); // OpenAI, Anthropic, Gemini, ...
// ...and sends the answer as the assistant participant
await chat.api.sendMessage({
userId: ASSISTANT_ID,
conversationId: conversation.id,
body: reply,
role: "assistant", // "user" | "assistant" | "system" - stored & returned as-is
});
Chatpack stores, orders, and delivers the messages; the LLM call is yours(model, keys, prompts, streaming). role is a plain label for your UI -core never behaves differently based on it. Since otherUserId accepts anynon-empty string, make sure your auth/validation layer prevents real usersfrom registering ids in your synthetic namespace (e.g. reserve the ai:prefix).
Real-time plugins: typing, presence, read ticks
The "feels alive" features are opt-in plugins that ship inside@chatpack/core - no extra install:
import { chatpack } from "@chatpack/core";
import { typing, presence, receipts } from "@chatpack/core/plugins";
export const chat = chatpack({
storage: memoryAdapter(),
auth: async (req) => getSessionUser(req),
plugins: [typing(), presence(), receipts()],
});
They publish ephemeral events on the same /stream connection you alreadyhave: fire-and-forget signals that are never stored and never replayed onreconnect (miss a typing ping and it's gone - that's correct; durable statelike lastReadMessageId stays in core). Listen exactly like message events:
events.addEventListener("typing.started", (e) => {
const { senderId, conversationId } = JSON.parse((e as MessageEvent).data);
// show "… is typing" - and hide it if no new ping arrives within ~5s
});
events.addEventListener("presence.online", (e) => {
/* light up the dot */
});
events.addEventListener("receipt.read", (e) => {
const { payload } = JSON.parse((e as MessageEvent).data);
// mark everything up to payload.messageId as ✓✓
});
What each plugin adds:
| Plugin | Routes | Events published |
|---|---|---|
typing() |
POST /conversations/:id/typing |
typing.started, typing.stopped |
presence() |
GET /presence?userIds=a,b |
presence.online, presence.offline |
receipts() |
- (hooks into send + mark-read) | receipt.delivered, receipt.read |
Notes that keep the design honest:
- Typing is stateless: while the user types,
POST …/typingat most onceevery few seconds; the other side clears the indicator if no ping arriveswithin ~5s. Send{ "isTyping": false }to clear it eagerly. In a group theping goes to every other participant, so key your indicator bysenderId-several people can be typing at once. - Presence needs no heartbeat endpoint - the SSE connection is theheartbeat. Multi-tab safe; a short grace period (default 5s,
presence({ offlineDelayMs })) stops the online dot from blinking duringEventSourceauto-reconnects. Snapshots viaGET /presenceonly revealusers the caller shares a conversation with. - Receipts are instant ✓/✓✓ pings while both sides are online:
receipt.deliveredfires to the sender the moment a recipient's streamreceives the message;receipt.readfires when someone else calls mark-read.Ticks are at-least-once - dedupe bypayload.messageId. Each tick isper-user, so in a group collectsenderIds rather than treating one tickas "everyone read it". The durable truth is stilllastReadMessageId. - Plugin state is in-memory by default. For several long-lived app servers,
@chatpack/transport-redisrelays events andredisPresenceStore()shares presence leases across nodes.
Want to write your own plugin? The seam is public - see ChatpackPlugin in@chatpack/core andADR 0008.
What's in v0
| Feature | Status |
|---|---|
| 1:1 conversations (find-or-create) | ✅ Done (M1) |
| Text messages: send, list, edit, delete | ✅ Done (M1) |
| Participant-only permissions + hooks | ✅ Done (M1) |
Durable read-state (last_read) |
✅ Done (M1) |
| In-memory storage adapter | ✅ Done (M1) |
| HTTP handler (Next.js App Router) | ✅ Done (M2) |
| Real-time delivery (SSE) | ✅ Done (M3) |
| SSE reconnect gap-fill | ✅ Done (M3) |
| Drizzle/Postgres adapter | ✅ Done (M4) |
| Turso/libSQL adapter | ✅ Done (v1.next) |
| Launch polish + npm release | ✅ Done (M5) |
| Typing / presence / read-tick plugins | ✅ Done (v0.next) |
Unread counts (unreadCount) |
✅ Done (v0.next) |
| Redis transport (multi-node SSE) | ✅ Done (v0.next) |
| Browser client + React hooks | ✅ Done (v0.next) |
| Client polling fallback | ✅ Done (v0.next) |
| Reactions + quote-replies | ✅ Done (v0.next) |
| Mentions (validated, supplied ids) | ✅ Done (v1.next) |
| Message forwarding (copy + provenance) | ✅ Done (v1.next) |
| Participant-scoped message search | ✅ Done (v0.next) |
| Post-persistence message mutation hook | ✅ Done (v0.next) |
@chatpack/cli init + starter templates |
✅ Done (v1.next) |
| Group chats: membership, roles, admin | ✅ Done (v0.next) |
File attachments (@chatpack/file) |
✅ Done (v0.next) |
| Invite links + join requests | ✅ Done (v0.next) |
| Public channels (browsable directory) | ✅ Done (v0.next) |
| Moderation: blocks, mutes, reports, bans | ✅ Done (v1.next) |
| Multi-node presence | ✅ Done (v1.next) |
Push notification providers and true message threads have not shipped.Reusable UI blocks are available through @chatpack/ui. Multi-node presence isavailable through the shared Redis presence store. Replies are flat pointers, notthreads. See docs/MVP.md for the full scope and reasoning.
Packages
| Package | Description |
|---|---|
@chatpack/core |
The chat engine: domain logic, permissions, API |
@chatpack/adapter-drizzle |
Drizzle/Postgres storage (production) |
@chatpack/adapter-prisma |
Prisma 7/Postgres storage (server-only) |
@chatpack/adapter-mysql |
MySQL 8 storage via Drizzle/mysql2 (server-side) |
@chatpack/adapter-turso |
Turso/libSQL storage via Drizzle |
@chatpack/adapter-sqlite |
Drizzle/SQLite storage (local, single node) |
@chatpack/adapter-memory |
In-memory storage (demos, tests) |
@chatpack/adapter-supabase |
Supabase/Postgres storage (server-side) |
@chatpack/next |
Next.js App Router integration |
@chatpack/client |
Typed REST, SSE, React hooks, and client plugins |
@chatpack/cli |
Project setup and full starter CLI |
@chatpack/transport-redis |
Redis pub/sub transport (multi-node SSE) |
@chatpack/file |
Filepack-backed message attachments |
@chatpack/ui |
Reusable React chat UI blocks |
Examples
| Example | What it shows |
|---|---|
examples/messenger |
A complete 1:1 messenger - vanilla HTML+JS, tutorial |
examples/next-backend |
The quickstart, runnable: Next.js App Router + SSE |
examples/node-server |
Plain Node http server, in-memory or Postgres storage |
Design principles
- Developers bring their own auth - Chatpack never owns a users table.
- Adapter-driven - storage is an interface; Postgres, MySQL, or in-memoryare just adapters.
- Durable-first real-time - a message is persisted before anyone isnotified about it.
- Small surface, no magic - every feature must justify its existence.
Read more in docs/ARCHITECTURE.md.
Telemetry
Chatpack ships anonymous, opt-out telemetry: aggregate counters only.Twice a day (at most) it POSTs a small JSON body - counter deltas(messagesSent, conversationsCreated), the library version, and a randomper-process id that is never persisted. Never message bodies, user ids,conversation ids, or hostnames. The payload shape is a documented public type(TelemetryPayload) so you can auditexactly what leaves your server.
Opt out any time - either works:
chatpack({ storage, telemetry: false });
CHATPACK_TELEMETRY=0
Failures are silently ignored and the flush timer never keeps your processalive. Details in docs/MVP.md §12.
Community
- Discord — chat with the team and other developers
- GitHub Discussions — questions, show-and-tell, and feedback
- X — releases and updates
- Docs — the full documentation site
- npm — every
@chatpack/*package - Open an issue — bugs and feature requests
If you've built something with Chatpack, got stuck installing it, or have opinions about the API — we want to hear from you. The team reads everything.
Contributing
Contributions are very welcome - see CONTRIBUTING.md forrepo layout, dev workflow, and the adapter contract.
Credits
Chatpack is a project by DanielCH andDavidCH, who own and maintain it.
The library itself was written byYeabsra Habtu — the core engine andpermission model, the HTTP handler, the storage adapter contract and both itsmemory and Drizzle/Postgres implementations, the real-time SSE transport and theephemeral plugin trio, and the first-party browser client.
Ikem Peter builds Chatpack alongside him — themoderation suite, the client's message search and its invite, join-request andchannel wrappers, and the CLI refresh. DavidCHcontributes to the code as well as co-owning the project.
| Role | |
|---|---|
| DanielCH | Project co-owner, maintainer |
| DavidCH | Project co-owner, contributor |
| Yeabsra Habtu | Principal author, maintainer |
| Ikem Peter | Contributing developer, maintainer |
Who wrote what is verifiable rather than asserted — see thecontributor graphor run git shortlog -sne in a clone.
Citing Chatpack in a paper or writeup? See CITATION.cff, oruse the "Cite this repository" button in the GitHub sidebar.
License
MIT