@kispi/chat Version 0.5.1

Rendered 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.

View on npm

@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_.

sh
npm install @kispi/chat

This 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.

ts
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.

ts
// 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.

  • typing is also delivered to the sender. If you render "X is typing", skip your own userId.

  • typing carries no name. Look the name up in room.presence.users — whoever is watching the room is there. Collecting names from message senders misses anyone who has not spoken yet. If users is absent (a room over 100), fetch it once with presenceList(), and fall back to a placeholder if the name is still missing.

    ts
    room.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, unreact and markRead skip the backoff when the state is connecting/reconnecting, wait for the connection, and send. If the socket drops before the ack, the same frame is sent once more — the server dedups on clientMessageId, and reactions and reads are idempotent, so nothing lands twice. Waiting plus the ack is capped at 15 seconds, then timeout. closed now comes only from a stopped client (close(), an auth refusal, or never connect()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 ChatError from token() 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.

ts
// 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.

ts
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.

ts
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.

ts
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.

ts
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

ts
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.