@kispi/chat Version 0.5.1Rendered from the README of the published package, so it matches exactly what npm serves.
Examples that use the chat-server CLI are for engine operators. As a consumer, create apps and keys in the console.
@kispi/chat
Client SDK for the chat server. Many rooms over one socket, history in the right order. Zero runtime dependencies, both ESM and CJS, type declarations included.
There are two entry points. @kispi/chat is the client for the browser and Node;
@kispi/chat/server is for the consumer backend that holds the sk_.
npm install @kispi/chatThis document covers the entire surface. Anything not here is internal and changes without notice.
@kispi/chat — client
createChatClient(options): ChatClient
| Option | Type | |
|---|---|---|
key |
string |
Required. A pk_. It is public, so it is fine to put in the browser |
token |
() => string | Promise<string> |
Required. A user token signed by your backend. Called again on every connect and whenever REST gets a 401, so an expired one is never reused. A throw is retried with backoff; throwing a ChatError means do not retry (see below) |
WebSocket |
new (url) => WebSocketLike |
Implementation to use instead of the global |
fetch |
typeof fetch |
Same as above |
Do not create token in the browser. Signing requires the sk_, and if the sk_
is in the browser, the whole app is open.
ChatClient
| Property | |
|---|---|
state |
'connecting' | 'open' | 'reconnecting' | 'closed' |
user |
The connected user. {id, name, avatar?} — the identity the server accepted from the token (what hello carried). Use this instead of parsing your token response. Set once connect()/reconnect() resolves |
connectionId |
Identifier used for support requests and forced disconnects |
hello |
Everything the server sent on connect: limits (effective), unread ({total, mentions, roomsCapped?} — absent is not zero, it means the count could not be read, so recover with unread()), warnings, serverTime, heartbeatMs |
| Method | |
|---|---|
connect() |
Connects. If already connected or connecting, it joins that attempt. Rejects on failure, but retries continue in the background (see below) |
close() |
Disconnects. This one does not reconnect. Call connect() to come back |
reconnect() |
Re-authenticates: calls token() again and replaces the socket. Room handles, subscriptions and messages are kept, and the state goes open → reconnecting → open (never closed). Use it after a nickname or avatar change (below) |
room(roomId) |
Room handle. Same id, same object |
roomByKey(key) |
Handle for a room opened by key. The server tells you the id when you subscribe. Does not create the room — for a key that doesn't exist, subscribe() rejects with not_found. Rooms are created by the backend with rooms.ensure() |
rooms.list({cursor?, limit?}) |
Rooms I'm a member of + unread + last message |
rooms.discover({type?, cursor?, limit?}) |
Browse public rooms |
rooms.members(roomId).list/add/remove |
Member management |
unread() |
{total, mentions, roomsCapped?, rooms} — the same field names as chat.hello?.unread |
updateMe(meta) |
My meta. name and avatar come from the token claims |
| Event | |
|---|---|
state |
The four values above |
error |
The client stopped at closed on its own, and why (a ChatError): an authentication refusal, or a ChatError from token(). Not emitted for close() |
notification |
A new message arrived in a room I'm a member of, but I'm not viewing that room |
user.presence |
Presence changes of my other connections |
frame |
Every received frame. For debugging |
on returns an unsubscribe function.
const off = chat.on('state', s => setStatus(s))
off()Room
| Property | |
|---|---|
messages |
Ascending by seq. The SDK handles ordering and gap filling. A new array on every change. Thread replies are included (rows with a threadId) — to draw the main timeline only, keep the rows without one |
id / key |
Room identifiers. If opened by key, id is absent until you subscribe |
lastSeq |
The room's last seq as reported by the server |
presence |
{count, users?, capped?} — who is in this room now. The SDK folds the subscribe ack and the increments of presence events into it. users is there only for rooms of 100 or fewer; larger, or unknown, it is absent — use presenceList() then |
hasOlder |
Whether loadOlder() has more past to read. Lets you decide whether to draw "load earlier" before the first loadOlder(). The same test as loadOlder()'s hasMore; false while the list is empty. Re-read it on messages |
| Method | |
|---|---|
subscribe() |
Turns on the live feed and reads the latest 100. Does nothing if already caught up. Fine to call before connecting — it waits until connected (rejects with closed on close()) |
unsubscribe() |
Stops viewing this room. It is not resubscribed on reconnect |
send({text, attachments?, entities?, replyTo?, threadId?, meta?}, {clientMessageId?}) |
Publish. Acked with {messageId, seq}. If a subscription is in progress, it waits for it. While reconnecting it waits for the connection, then sends (below) |
loadOlder({limit?}) |
Reads the page before the oldest message (default 100) and prepends it to messages. Returns {messages, hasMore}. Use it when scrolling up. If the list is empty (before subscribing), it reads nothing |
history({before?, after?, limit?, view?}) |
Reads the past directly. Independent of messages. view is 'main', 'all', or {thread: id} |
reload() |
Re-reads the latest page |
react(messageId, emoji) / unreact(...) |
Reactions. Idempotent. Updates that row's myReactions before the frame goes out, and rolls it back if the server refuses. The count is not moved optimistically; it is set to the server's value when the reaction.* event arrives (below) |
reactionsOf(messageId, {emoji?, cursor?, limit?}) |
Who reacted. Each row is a (user, emoji) pair, so one person can appear on several rows. limit defaults to 50, max 100; the last page's cursor is an empty string |
markRead(seq, {threadId?}) |
Read cursor |
reads({userId?, cursor?, limit?}) |
Read cursors, paged. {reads: [{userId?, seq, threadId?, withdrawn?}], cursor}. userId narrows to one person -- reads({userId: chat.user.id}) recovers your own per-thread cursors. Stop when cursor is '' |
typing({threadId?}) |
Typing indicator. Does not throw. Pass threadId while writing a thread reply |
join() / leave() |
Membership |
presenceList() |
Who is in this room right now |
Event names are exactly the wire names, and the payloads are in the type declarations.
| Event | Payload |
|---|---|
message.created / message.updated |
Message. On created, thread is absent and reactions is empty |
message.deleted |
{id, seq}. seq is the one the message got when it was published |
reaction.added / reaction.removed |
{messageId, emoji, count, userId?}. The SDK has already updated that row's reactions count in messages (and myReactions if it was yours). Rows outside the list, such as ones read with history(), are the consumer's to update |
thread.updated |
{rootId, count, lastSeq?, lastAt?} |
presence |
{count, joined?, left?, users?, capped?}. joined/left are incremental, users replaces |
typing |
{userId, threadId?} -- threadId is the thread root id, absent for the main timeline |
read |
{userId, seq, threadId?} |
member.joined / member.left |
{userId, role?} |
room.updated |
The room object (unknown — the server's own representation) |
room.deleted |
{roomId} |
custom |
Exactly the object rooms.custom(roomId, payload) sent |
The SDK emits three more that have no wire counterpart.
| Event | When |
|---|---|
messages |
Every time the list changes. A new array each time; an emitted array is never touched again |
error |
Reading history or filling a gap failed. The room resubscribes itself. The retry button for a person is subscribe() |
reset |
The cursor was older than the retention period, so the list was discarded and refilled. You must discard what you rendered |
Things to know
messages is a new array every time, and only changed rows are new objects. Put it
straight into state that compares by reference.
// Svelte 5
let messages = $state.raw<Message[]>([])
room.on('messages', m => (messages = m))
// React
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => room.on('messages', setMessages), [room])
// Vue
const messages = shallowRef<Message[]>([])
room.on('messages', m => (messages.value = m))Don't ignore
reset. If you keep appending to the old list, you're left with a gap that nobody fills.Attach the past to the same list with
loadOlder().history()is a direct read that doesn't touch the list, so merging and deduplicating fall to you.typingis also delivered to the sender. If you render "X is typing", skip your ownuserId.typingcarries no name. Look the name up inroom.presence.users— whoever is watching the room is there. Collecting names from message senders misses anyone who has not spoken yet. Ifusersis absent (a room over 100), fetch it once withpresenceList(), and fall back to a placeholder if the name is still missing.tsroom.on('typing', ({ userId }) => { if (userId === chat.user?.id) return const name = room.presence?.users?.find(u => u.id === userId)?.name showTyping(userId, name ?? 'Someone') // clearing it after a few seconds is yours to do })The connection comes back on its own. It reconnects with exponential backoff, so don't write your own retry loop.
What you send while reconnecting waits, then goes out.
send,react,unreactandmarkReadskip the backoff when the state isconnecting/reconnecting, wait for the connection, and send. If the socket drops before the ack, the same frame is sent once more — the server dedups onclientMessageId, and reactions and reads are idempotent, so nothing lands twice. Waiting plus the ack is capped at 15 seconds, thentimeout.closednow comes only from a stopped client (close(), an auth refusal, or neverconnect()ed).typing()does not wait; it is dropped.The SDK handles token expiry. When REST gets a 401 it calls
token()again and retries once. There's no need to reconnect periodically.Throwing a
ChatErrorfromtoken()stops the client. That is how you say a failure, like a ban, is not worth retrying.After a nickname or avatar change,
reconnect(). The server reads those values from the token claims once, when the socket authenticates.
text is untrusted plain text
Message text, and user-supplied display names/nicknames (name), are plain text
the engine does not HTML-escape or sanitize. This is by design — escaping depends on
the output context you render into (an HTML text node, an attribute, some other
format), which the engine cannot decide on your behalf. The engine only validates
(size, shape).
So consumers must render it as text — textContent, or your framework's text binding
(React's {text}, Svelte's {text}, Vue's {{ text }}). Never pass it straight into
innerHTML or v-html. If you add linkification or markup, don't build an HTML
string with a regex replace — build DOM from text nodes instead.
// Don't — builds an HTML string with a regex
el.innerHTML = text.replace(urlRegex, '<a href="$1">$1</a>')
// Do — builds DOM from text nodes
for (const part of splitByUrl(text)) {
el.appendChild(part.isUrl ? makeAnchor(part.text) : document.createTextNode(part.text))
}Bad-word filtering and similar moderation is not escaping — it's a tenant policy.
The engine only opens the mechanism (the before_publish hook); the consumer decides
(see the before_publish section in docs/protocol.md).
Using it in the browser
You must add the page's origin to the pk_'s allowed origins. That's the only thing —
the server emits CORS headers itself, so there's no proxy to touch. Origins are edited on the
key screen in the console.
Scheme, host and port must match exactly. https://example.com and
https://www.example.com, http://localhost:3000 and http://127.0.0.1:3000 are different
origins. Add development origins separately.
From an origin that isn't on the list it isn't just REST that fails — the connection itself
is a 401. An authentication refusal is a failure the SDK doesn't retry, so the client stops
at closed, and the reason arrives on chat.on('error').
Errors
Failures are ChatError.
try {
await room.send({ text })
} catch (err) {
if (err instanceof ChatError && err.code === 'rate_limited') {
retryAfter(err.retryAfterMs)
}
}| Field | |
|---|---|
code |
A union of the known values (ChatErrorCode), so a typo fails to compile. From the server: unauthorized, forbidden, not_found, invalid, too_large, rate_limited, banned, moderation_denied, cursor_too_old, internal. From the SDK: timeout, closed. A code the server adds later still type-checks |
retryAfterMs |
Wait time reported by the server when you hit a limit |
appCode |
Consumer-side code attached by a before_publish webhook when rejecting |
status |
HTTP status, if the error came from a REST call |
@kispi/chat/server — backend
Uses the sk_. Never put it in the browser.
import { createChatServer } from '@kispi/chat/server'
const chat = createChatServer({
secretKey: process.env.CHAT_SECRET_KEY!, // sk_…
webhookSecret: process.env.CHAT_WEBHOOK_SECRET,
guestSecret: process.env.CHAT_GUEST_SECRET, // only if you use guest()
})chat.token(input): string
A local signature that doesn't touch the network.
| Field | |
|---|---|
userId |
Required. Becomes the JWT sub. Your service's user id |
name |
Required. This value wins on every connect |
avatar, meta |
Optional |
ttlSeconds |
Default 1 hour, max 24 hours. Exceeding it is rejected, not truncated |
chat.guest({credential?, name, avatar?, meta?, ttlSeconds?})
Identity for a visitor who isn't logged in. Returns {userId, credential, token, created}.
Uses neither the network nor storage.
app.post('/api/chat-token', (req, res) => {
if (req.user) return res.json({ token: chat.token({ userId: req.user.id, name: req.user.name }) })
const g = chat.guest({ credential: req.cookies.chat_guest, name: 'Guest' })
res.cookie('chat_guest', g.credential, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 400 * 864e5 })
res.json({ token: g.token })
})Never sign a userId handed back by the browser as-is. guest() has the browser hold a
credential instead of the id — if it verifies, it signs with the id inside it; if it's
missing or invalid, it signs a new guest (created: true). Keep it in an httpOnly cookie
where you can.
guestSecret |
At least 32 characters. Keep it on the backend only. Don't derive it from the sk_ |
| Rotation | Pass an array: [new, old]. Signs with the first and verifies with all. A credential verified with an old one is re-signed with the same userId and returned, so it migrates on the next visit |
Guest ids start with g_. As long as logged-in users' ids don't start with g_, the two
spaces don't overlap.
The rest
| Group | Methods |
|---|---|
rooms |
list({type?, cursor?, limit?}), ensure({key?, type, name?, meta?, members?}), ensureDM([a, b]), get, update, delete, custom(roomId, payload), presence(roomId, {full?}), members.list/add/remove |
messages |
send(roomId, {sender, text?, kind?, attachments?, appMeta?, replyTo?, threadId?, clientMessageId?}), list, delete |
users |
list({cursor?, limit?}), update(userId, {name?, avatar?, meta?}), withdraw(userId, {purgeMessages?}), purgeMessages(userId), ban(userId, {until, reason?}), unban(userId), revokeTokens(userId), disconnect(userId, {connectionId?, reason?}) |
events |
list({after?, limit?}) — catches up on missed webhook events |
webhooks |
verify(headers, rawBody, {now?, tolerance?}) — tolerance is in milliseconds |
rooms.list returns the app's rooms, newest first. With no type it is the public listing
(public and channel); pass e.g. type: ['private', 'dm'] for private rooms and DMs. limit
defaults to 50 (max 100), an empty cursor is the last page, and the cursor does not carry
type — start over when the filter changes.
rooms.ensure is get-or-create — it returns the room as-is if it exists, so it's fine to call
on every page load.
rooms.presence(roomId) is {count}; with {full: true} it adds users (at most 1000,
capped: true when cut). An empty room is {count: 0}, not a 404.
rooms.custom(roomId, payload) is a control signal that is not stored. payload is a JSON
object and arrives as-is at room.on('custom', ...). It leaves no history, no unread and no
webhook, so send a signal that must survive a reconnect with
messages.send(roomId, {kind:'system', ...}) instead.
users.ban's until is unix milliseconds and required. For an indefinite ban, pick a
far-future time yourself.
users.purgeMessages(userId) clears that user's messages without withdrawing them. It does
a bounded amount per call, so call again while purgeCapped is true, and for spam ban first.
await chat.users.ban(userId, { until: Date.now() + 7 * 86_400_000, reason: 'spam' })
while ((await chat.users.purgeMessages(userId)).purgeCapped) {}users.list is newest first, limit defaults to 50 and maxes at 100, and cursor is
an opaque string where an empty string marks the last page. Withdrawn users are included too,
and deletedAt marks them. users.* works only after the person has connected at least
once — users come into existence by connecting.
Webhooks
app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
const { event, delivery, data } = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
if (seen.has(delivery)) return res.sendStatus(200) // it's at-least-once
res.sendStatus(200) // acknowledge first
void handle(event, data) // process asynchronously
})It must be the raw body. Something parsed and re-serialized is a different string and fails verification.
Keep three things. verify checks the signature and the timestamp (default 5 minutes),
deduplication is on you (by delivery), and you must respond with a 2xx within 10 seconds.
Going over counts as a failure and backoff begins.
The signature spec for other languages is in the webhooks guide.