Chatpack
Open-source chat infrastructure for developers.
Install a package, wire up your database and auth, and get a production-ready chat 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 the
full REST reference. (Source in apps/docs; run locally with
pnpm --filter @chatpack/docs dev.)
A project by DanielCH and DavidCH ยท principal author Yeabsra Habtu ยท all contributors
Status:
0.x- v0 MVP + real-time plugins + unread counts + browser client + reactions + search + group chats + mentions + forwarding, live on npm. The v0 MVP (core engine, HTTP handler, real-time SSE, Postgres adapter) plus the opt-in real-time plugins -typing(),presence(), andreceipts(), all shipping today inside@chatpack/coreunder the@chatpack/core/pluginssubpath (see Real-time plugins) - are published and installable now, along with the first-party@chatpack/client, which provides the matching typed REST, SSE, and React client. The API is young - expect minor breaking changes 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 and roles, 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 gets
live messages with automatic reconnection and missed-message backfill - no
WebSocket 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 ยท your own
โ
โผ
Your database
Quickstart
Prefer learning from a complete app?
examples/messengeris a full 1:1 messenger - sidebar, live messages, read receipts - in vanilla HTML+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-memoryBun note: if Bun's supply-chain guard (
minimumReleaseAge) is enabled, versions published in the last 24 h are skipped and Bun silently resolves an older 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 at least{ id: string }(extra fields are allowed and ignored), ornullfor unauthenticated requests. Returning a bare string is treated as unauthenticated and every request will get a401.Prefer cookie-based sessions over
Authorizationheaders: the browser sends cookies automatically on every request - including the SSE stream in step 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 browsers silently drop
SameSite=Laxcookies - the app 401s in the preview pane but works 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),
});No direct Postgres connection string? Platforms that only expose a database client (Supabase's JS client, Convex, and most AI-builder clouds) are supported through a custom
StorageAdapter. The full guide - reference schema, 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-framework mount recipes, preview-iframe cookie recipe, verification steps). It also ships inside every@chatpack/*npm package asllms.txt- point your agent atnode_modules/@chatpack/core/llms.txt.Using a coding agent (Claude Code, Cursor, Codex)? Install the Chatpack agent skill into your app's repo so the agent follows the correct workflow automatically:
npx skills add chddaniel/chatpack
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, reads
better):
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 a singleapp/api/chat/route.tswould 404 everything but the root.Never hand-write your own message or stream routes. The one handler already serves every route - conversations, messages, read-state, plugins, and the SSE stream. Custom
/api/messages-style routes split state and break 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) and
GET/POST/PATCH/DELETE/PUT/fetch are all the same function - the
method 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)); // ElysiaTanStack Start (src/routes/api/chat.$.ts catch-all) and Express recipes
live in @chatpack/core's README and
llms.txt. For plain Node, see
examples/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)to validate direct-chat targets and new group participants against your own identity store. Without the optional hook, previous opaque-id behavior is preserved.
{
"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 (messages
newer than their read-state, excluding their own) - the badge number comes
from the API, no client-side counting.
Groups are created, never found - a separate route, because two groups with the 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 the
conversation 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 a
role (PATCH โฆ/participants). Groups hold 1-256 participants and always keep at
least 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, never
the member list, since a non-member can call it - and
POST /invites/:code/accept redeems it. Add "requiresApproval": true and
redeeming files a join request for an admin to approve instead, which is the
same queue any user lands in by asking directly
(POST /conversations/:id/join-requests). Either way, joining publishes the
existing participant.added event, so live clients need no new code.
When you want people to find the room themselves, publish the group as a
public channel - a group with visibility: "public", not a third
conversation 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, returning
thin 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 a
stranger in a queue is recoverable and a stranger in the room isn't).
Discoverable is not readable: browsing grants nothing, so reading the
transcript 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 are
self-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 existing history readable, and does nothing inside a shared group. A mute is a hint for your own UI - unread counts and SSE delivery don't change. The report queue and the 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 banned
user gets 403 USER_BANNED on every route including /stream. Configuring
moderation at all is what switches that enforcement on, so an app that doesn't
use bans pays no per-request lookup; add enforceBans: true if ban rows are
written 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 is
the 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 parent
and the quote bar follows. Reaction routes are idempotent and always return the
message with its complete reaction set
([{ emoji, count, userIds }]). These are quote-replies, not threads, and a
reaction is not a message: it has no seq and never reorders the conversation
list.
Mentions are ids you supply, not text Chatpack parses - it has no users table
to resolve a name against, and body stays opaque. Forwarding copies a
message 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 looks
exactly like a notification that fired. On edit, omitting mentions leaves the
stored set alone and [] clears it. Chatpack notifies nobody and keeps no mention
inbox: afterMessageMutation hands you mentions next to recipientIds, which is
where a push integration belongs.
A forward is a copy, never a live pointer: a new message in the target with
your id as sender, its own seq, and forwardedFrom
({ messageId, conversationId, senderId }) frozen at forward time. Editing or
deleting the original changes nothing about the copy. One hop, like replies -
and deliberately no excerpt and no source conversation name, since whoever reads
the 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 is case-insensitive, punctuation-separated, relevance-ranked, and excludes tombstones:
curl '/api/chat/search/messages?q=hello&limit=50'The response is { "messages": [...], "nextCursor": null }. Core applies
canRead to the participant-scoped results. Dynamic access to conversations
where 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/409
for domain errors:
{ "error": { "code": "FORBIDDEN_READ", "message": "โฆ" } }The full endpoint reference (every route, request/response shapes, error
codes) 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 typed REST methods, one managed SSE connection, a small shared cache, and React hooks:
npm install @chatpack/client reactCreate 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 auth
hook. It does not implement login, sessions, or user lookup. Same-origin
cookies work by default; use credentials: "include" for cross-origin cookie
sessions. Native EventSource cannot send custom headers, so cookie auth is
also required for browser realtime unless you provide a custom EventSource.
Where SSE can't work - serverless function timeouts, buffering proxies, React Native - the client falls back to refetching on an interval by itself, so a serverless deploy needs no frontend change. Typing, presence and receipts are unavailable 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 keep
the 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 joins
return either a joined conversation or a pending request; expected HTTP failures
remain structured client results. chatClient.moderation wraps all thirteen
moderation calls the same way - note that none of them touch the query cache, so
refetch the lists you show after a block or a mute.
messages.send and messages.edit take mentions, and messages.forward
copies a message into another conversation - resolving with the copy and echoing
it into the target thread just like a send. The destination is
toConversationId in the client input even though the wire field is a plain
conversationId, 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 and
Chatpack 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 replace your cached conversation rather than patching it. CompareaffectedUserIdsagainst your own id to tell "I was removed" (drop it; it's the last event you'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 reactions have noseq- so their frames carry noid:(emitting one would rewindLast-Event-ID) and they are not gap-filled. A reaction applied while the client was offline appears on the next refetch of that conversation. - Browser auth must be cookie-based for SSE -
EventSourcecan't send custom headers, so yourauthhook needs to resolve the user from a session cookie (sent automatically same-origin). Bearer-token headers work for the REST 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 runs inside 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 transport fans out inside a single process, so with 2+ app servers a message sent on one node never reaches a stream on another - drop in@chatpack/transport-redis(one line) to relay events between nodes. On serverless/edge (Workers, Lambda) each isolate has its own memory - use a database adapter there and poll for new messages; SSE is a poor fit regardless of transport, since the function lifetime is the blocker.@chatpack/clientfalls back to polling on its 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 for
the group-management methods including publishing a channel, and canInvite for
minting links - the last two default to admins only, and browsing or joining a
public channel is gated by neither). Platform-wide moderators are a separate
hook, moderation: { canModerate }, because being an admin of one conversation
shouldn't open the report queue for all of them. Need content
rules (length caps, profanity filters) or post-send side-effects? Add
hooks: { beforeMessageSend, afterMessageMutation } - block or rewrite a
message before it persists, react after send/edit/delete persistence (see @chatpack/core's
README).
8. Bonus: chat with an AI assistant
To Chatpack, an AI assistant is just another participant - pick a
synthetic 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 support
needed, and the same permissions apply (drop the same id into a group's
userIds 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 any
non-empty string, make sure your auth/validation layer prevents real users
from 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 already
have: fire-and-forget signals that are never stored and never replayed on
reconnect (miss a typing ping and it's gone - that's correct; durable state
like 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 once every few seconds; the other side clears the indicator if no ping arrives within ~5s. Send{ "isTyping": false }to clear it eagerly. In a group the ping 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 the
heartbeat. Multi-tab safe; a short grace period (default 5s,
presence({ offlineDelayMs })) stops the online dot from blinking duringEventSourceauto-reconnects. Snapshots viaGET /presenceonly reveal users 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 stream receives the message;receipt.readfires when someone else calls mark-read. Ticks are at-least-once - dedupe bypayload.messageId. Each tick is per-user, so in a group collectsenderIds rather than treating one tick as "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 and
ADR 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) |
| 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, reusable UI packages, and true message threads have not shipped. Multi-node presence is available through the shared Redis presence store. Replies are flat pointers, not threads. 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-memory |
In-memory storage (demos, tests) |
@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 |
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-memory are just adapters.
- Durable-first real-time - a message is persisted before anyone is notified 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 random
per-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 audit
exactly what leaves your server.
Opt out any time - either works:
chatpack({ storage, telemetry: false });CHATPACK_TELEMETRY=0Failures are silently ignored and the flush timer never keeps your process alive. 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 for repo layout, dev workflow, and the adapter contract.
Credits
Chatpack is a project by DanielCH and DavidCH, who own and maintain it.
The library itself was written by Yeabsra Habtu โ the core engine and permission model, the HTTP handler, the storage adapter contract and both its memory and Drizzle/Postgres implementations, the real-time SSE transport and the ephemeral plugin trio, and the first-party browser client.
Ikem Peter builds Chatpack alongside him โ the moderation suite, the client's message search and its invite, join-request and channel wrappers, and the CLI refresh. DavidCH contributes 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 the
contributor graph
or run git shortlog -sne in a clone.
Citing Chatpack in a paper or writeup? See CITATION.cff, or
use the "Cite this repository" button in the GitHub sidebar.