Chatto client
@chatto/client is the client for Chatto 0.5 servers. It connects to one or
more servers, keeps their data current through the realtime API, and exposes
that data as reactive state. It also has the requests and the message loop
that bots and integrations use. It keeps server data only: state of a host's
UI belongs to the host. It has no dependency on a UI framework. The bundled
Chatto frontend, ChattoBot, and the Runling bot example use it.
Install with npm install @chatto/client. The npm package contains ES modules
and TypeScript declarations, including generated protocol types. Use Node.js
22 or later for bots. Svelte is optional; install Svelte 5 when you use
@chatto/client/svelte. Module paths can change before 1.0.
Clients and servers
A client is an isolated set of servers with their realtime transports and background work. Create one client for each independent host. A process can have several clients, and each client can connect several servers:
import { createClient } from '@chatto/client';
const client = createClient();
process.on('SIGTERM', () => client.close());
try {
const eu = client.connect({ serverUrl: 'https://eu.example', apiKey: euKey });
const us = client.connect({ serverUrl: 'https://us.example', apiKey: usKey });
await Promise.all([
eu.run((ctx) => ctx.reply(`Hello from EU, you said: ${ctx.message.body}`)),
us.run((ctx) => ctx.reply('Hello from US'))
]);
} finally {
// Close every server and stop all background work.
client.close();
}
connect() adds a server with a fixed bearer token, such as a bot API key,
and returns it. The token is kept only in memory: it is never renewed or
written to device storage. client.server(id) returns a server that the
client has. Both return the same Server type: the server's data, events, and
requests are one object.
server.reportPresence() shows the account as online while the server is
open: once the server accepts the key, the client sends a presence heartbeat
every 30 seconds. After close(), the account shows as offline within 60
seconds. It never changes the account's saved presence choice.
Answering messages
server.run(handler) waits until the server accepted the key, also while
the server is unreachable, then calls handler for each message addressed to
the viewer (the account of the key). By default, these are direct messages,
mentions of the viewer by name, and replies to the viewer's messages. Role,
@here, and @all mentions are separate reasons (role_mention,
here_mention, all_mention) that you enable with reasons. Messages are
handled in order. The handler receives a context:
await server.run(
async (ctx) => {
ctx.message; // the addressed message: id, roomId, threadRootId, authorId, body, reasons
const thread = await ctx.readThread(); // messages with `fromViewer`
await ctx.withTyping(async () => {
await ctx.reply(await answer(thread.messages));
});
await ctx.addReaction('eyes');
},
{
signal, // stops the loop; without it, the loop stops when the server closes
reasons: ['mention', 'direct_message'], // default: also 'reply'
onStatus: (status) => console.log(status.state),
onError: (error) => console.error(error) // keep running after a failure
}
);
ctx.signal aborts when the loop stops for any reason; pass it to
your own cancellable work. ctx.conversationKey scopes a conversation to the
viewer, room, thread, and sender. ctx.refreshTyping() refreshes the typing
indicator once.
run resolves when signal aborts or the server closes. It rejects when
the server rejects the key or does not support this client, and, without
onError, with the first failure of the handler.
Events, status, and state
server.consumeEvents({ signal, onEvent, onStatus })handles every realtime event in order.runuses it.onEvent(event, { signal })receives a signal that aborts when the loop stops for any reason, for example when the server ends the session. Up to 1000 received events wait for a slow handler; beyond that, they are dropped and a gap is reported.onStatusreceivesconnecting,ready, andreconnecting. Areadystatus withgap: truemeans that events can be missing, for example because the server could not resume the stream after a reconnect. Whilerunwaits for the server, aconnectingstatus with anerrorreports each failed attempt, for example an unreachable server.- A server that
connectadded receives events from its creation, also beforeready()resolves, and keeps them for the firstconsumeEventsorruncall. A later call reports a gap first. onEvent(listener)andonSnapshot(listener)receive events and new snapshots directly, without a loop.- The server's data is reactive: for example
roomList.rooms,notifications.occurrences,projection.users, androoms.messages(id)for a paged, live room timeline.
Live timelines and room stores
rooms.messages(roomId) and rooms.thread(roomId, rootId) return a live
timeline. The timeline reads its latest page when you first get it. Realtime
delivery then adds new messages and applies edits and deletions, so a host
does not read the thread again for each change.
isCurrentis true when the latest page loaded and no read or reconnect reload is pending.eventsthen has the latest messages.loadErrorhas the error of a failed read, or of lost room access. CallrestoreLatestWindow()to read again after a failed read.- By default, the client keeps the stores of the 10 most recently used rooms
(timeline, file list, pin list, and member list) and the 20 most recently
used threads.
A call to
rooms.messages,rooms.files,rooms.pins, orrooms.membersuses the room; a call torooms.threaduses only the thread. The client disposes the stores of older ones; the next call returns a new store, which reads again. Do not use more rooms or threads at the same time. To keep more, set the client optionkeepRecent, for examplecreateClient({ keepRecent: { threads: 50 } }). - A disposed timeline gets no more updates and is never current. If you keep
a timeline across a long wait, for example until
isCurrent, also stop whenisDisposedbecomes true, and get the timeline again.
Use readThread instead when you need one snapshot and no live updates.
rooms.members(roomId) returns a member store without starting a directory
scan. Call store.loadRoomMembers(roomId) when a consumer needs the full list;
the server store defers this request until room access is known. Use
members.searchMembers(query) for bounded suggestions without loading the full
list. An empty query returns a bounded page of suggestions. Recovery refreshes
only explicitly requested directories.
For rendered mentions, call members.retainMentions(handles) and release the
returned subscription when the content leaves the view. Read mentionMembers
for current profiles. These shared, room-scoped lookups search only matching
pages and do not request the full directory. Missing handles stay plain text.
Access and membership changes discard cached matches and fence older reads.
Readiness and failures
ready()rejects when the server rejects the key, when the server is unreachable or fails, or when the server release does not support this client. The server retries in the background; callready()again to wait for the next attempt.run()waits through these retries.sessionEndedbecomes true when the server rejects or revokes the key.realtimeUnsupportedbecomes true when the server does not support this client's realtime protocol. In both cases no events arrive, and the event loops reject; close the server.- A server that
connectadded keeps a persistent WebSocket, also in a client with one selected live server. server.close()removes the server from its client and stops its realtime delivery. Requests in flight fail, even when the server applied them, and no new requests are sent. When the last server thatconnectadded closes, the client stops its timers, so a Node host can exit.
Requests
A server and a stateless Api have the same request helpers. Each
request has a ten-second timeout and is not automatically retried; a failed write can
still have reached the server.
| Helper | Does |
|---|---|
getMessage({ roomId, messageId }) | Reads one message, including attachment metadata without download addresses |
createMessage(destination, body, { inReplyTo }) | Sends one message; returns its id |
prepareMessage(destination, body, { inReplyTo }) | Prepares one logical send for explicit retries |
postMessage(destination, body) | Sends text of any length, in parts of 8000 code points |
reply(message, body) | Posts in the message's thread with a reference to it |
readThread(location, { after, limit }) | Reads a thread; after reads only newer messages |
readAttachment({ roomId, attachmentId }, opts) | Reads a file; maxImageSize resizes images on the server |
refreshTyping(destination) | Refreshes the typing indicator once |
withTyping(destination, work) | Shows the typing indicator while work runs |
addReaction({ roomId, messageId }, emoji) | Reacts to a message |
addressedMessage(event, { reasons }) | Recognizes an event as a message to the viewer |
Request helpers take { signal } as their last argument. A prepared operation
takes it in send({ signal }). postMessage, reply,
and ctx.reply return the ids of the new messages. Other helpers are
conversationKey, replyDestination, withTyping and startTyping for
custom typing updates, and createDeliveryTracker for replay filters.
For a final reply, await withTyping(destination, work) before posting the
message. The helper stops its timer and waits for any pending refresh, with
a two-second request timeout. This prevents a successful refresh from arriving
after the message and restoring the typing indicator.
Message bodies can contain emoji shortcodes, such as Hello :wave:. The
server keeps this text. Clients choose how to display it. For custom renderers,
@chatto/client/messaging/emojiShortcodes exports resolveEmoji(name) for
case-sensitive built-in names and emojiShortcodes(text) for source ranges
with Unicode or unknown results. Tags beside Unicode letters, numbers, combining
marks, or underscores stay literal. emojiShortcodeQuery(textBeforeCursor)
reads a partial name with the same opening boundary for autocomplete. Parse
Markdown first and scan only prose; leave code and URLs unchanged. Custom emoji
assets are not supported yet.
Skin-tone shortcodes use Slack syntax: :thumbsup::skin-tone-4: displays as
👍🏽. Tones range from 2 (light) to 6 (dark). Supported mixed-tone pairs use two
numbers in one suffix, such as :couple::skin-tone-2-6:. Unsupported combinations
stay literal. Reaction APIs take the same name without the surrounding colons.
Older servers can reject these reaction names; no connection-floor increase is
required. Existing names and Unicode message content remain unchanged.
emojiNameFromUnicode(emoji) returns a stable reaction name for a selected
Unicode emoji, including its tone. Unknown sequences return undefined.
Use this helper for new selections; keep existing reaction names unchanged.
mise codegen-emoji builds the client tone map, server map, and converter map
(tools/emoji-map.json) from gemoji and the bundled Unicode Emoji 15.0 data.
Converters should look up complete names in this map rather than append a
modifier to a base glyph. The generated maps require no network calls at runtime.
For a send that needs retry safety, retain the prepared operation:
const send = server.prepareMessage(destination, 'Hello');
const { id } = await send.send({ signal });
// After a lost response, call send.send() again with the same operation.
Each prepared send allocates one UUID. Exact retries use that key and the same
arguments. The UI message API also exposes prepareMessage(input) and retains
uploaded attachment IDs. Prepared sends stay in memory. They stop retries at
30 minutes from the first post attempt and throw MessageSendExpiredError.
Check the original result before starting a new send. Retry safety requires
all serving replicas to support message-create idempotency; older servers can
ignore the key. postMessage and reply can send multiple parts; retrying the
whole helper starts new sends for those parts.
For a server with renewable credentials, a privacy reset invalidates retained
sends. Their next send() throws StaleResponseError without posting again.
Check the original result before starting a new send. Fixed-token sends remain
valid across privacy resets, but cannot send after the server is closed.
For any other request, service(Service) creates a typed Connect client with
the server's authentication. @chatto/client/types has the protocol's
messages and services, and @chatto/client/types/admin the administration
API:
import { UserService } from '@chatto/client/types';
const { user } = await server.service(UserService).getUser({
target: { case: 'userId', value: authorId }
});
Stateless requests
createApi makes requests without a realtime connection or retained state,
for example in a webhook handler, or for work that can continue after a
server closes:
import { createApi } from '@chatto/client';
const api = createApi({ serverUrl, apiKey });
const { viewerId } = await api.ready({ signal }); // read once, then reused
await api.reply(message, 'Done', { signal });
It uses Connect JSON, rejects redirects, and loads no stores. Pass
viewerId when the host already knows the account of the key, for example
from server.ready(); ready() then sends no request.
Debug output
Debug output is off outside browsers. Call setDebugLogging(true) to write it
to the console.
Privacy
Requests contact only the configured servers. Each server receives the host's IP address, its credentials, and the request data. The client does not log tokens or message content. The client removes its copies of private data at authentication, authorization, and privacy boundaries, and reports these boundaries to the host; see State of the host.
Applications with a UI
An application creates one client with device storage and the origin server, starts its runtime, and reports the server that the user looks at. Only that server keeps a persistent WebSocket; the others catch up by polling:
import { createClient } from '@chatto/client';
export const client = createClient({
storage: 'device', // restore and keep the server catalogue and sessions
originServer: true, // the page's own server uses its cookie session
liveServers: 'selected'
});
client.start(); // pair each start() with a stop(), for example on unmount
client.setActiveServer(serverId);
const server = client.server(serverId);
A process can have one client with device storage and one for the origin server.
If a host fetches discovery before registry.addServer, pass that response as
serverInfo in the third argument to avoid another startup request.
server.serverInfo.init() shares an in-flight discovery request. After it
settles, read server.serverInfo.discovery for the response, or null on
failure. A later call makes a fresh request.
Native storage and browser sign-in
Native hosts can pass persistence: ServerRegistryPersistence to createClient
instead of storage: 'device'. This adapter restores server registrations and
stores authentication separately. The latest authentication record overrides
the combined catalogue snapshot; null is a cleared-session record. Writes
must complete durably before returning. A failed write must throw or return
false from writeAuthentication. Read-back must return the committed value.
The host owns encryption, access control, and exclusive process access. Do not
let two processes renew the same saved session without shared coordination.
The SDK saves a refresh request ID before sending a renewal. On restart it reuses that ID, then saves and verifies the replacement credentials before returning the new access token. Renewal requests omit cookies and reject redirects. Keep all authentication fields, including the OAuth client ID and refresh request ID, in the durable record.
@chatto/client/auth/oauth provides createOAuthFlow, isOAuthCallback, and
exchangeOAuthCode for native hosts. Supply discovery's authorization URL, the
host's public OAuth client ID, and a literal loopback HTTP callback URL
or the built-in chatto://desktop/servers/callback?mode=popup callback. The
host opens the returned authorization URL in the system browser and receives
the callback. The helpers use S256 PKCE, transaction state, exact callback
validation, an optional issuer check, and a bounded token exchange. Keep the
flow and callback in memory. Never log their code or verifier. The host owns
the loopback listener, cancellation, consent UI, and client metadata document.
Pass returned credentials through persistedBearerSession, combine them with
emptyServerSession(), and supply the session to registry.addServer.
The SDK then owns renewal and realtime state.
Native password sign-in
passwordLogin(origin, { login, password }, { signal }) returns renewable human
credentials on servers that permit direct password login. Native hosts supply
their own secure password field. The request has a 15-second timeout and a
64 KiB response limit. It omits cookies, rejects redirects, and accepts HTTPS
or loopback HTTP origins without paths, queries, or fragments.
Pass the credentials through persistedBearerSession from
@chatto/client/auth/bearerSession, combine them with emptyServerSession()
from @chatto/client/server/sessions, and supply that session to
client.registry.addServer(registration, session). The SDK then owns token
renewal and realtime state. This function does not save passwords or sessions.
Errors expose a status and a fixed message, never the server response body.
Remote signOutServer requests also omit cookies and reject redirects.
State of the host
The client keeps server data. A host keeps its own state, such as sidebar grouping, search results, call media, or a query cache, next to each server:
- Derive state from the server where possible. Derived state follows the server and needs no cleanup.
- A host that copies server data must clear the copy at the server's
boundary events:
onReset,onRoomAccessLost,onRoomAccessRestored,onUserDeleted,onAuthorityChanged,onPermissionsChanged,onSessionEnded, andonDispose.onUpdatereports every applied event and resource. The server emits each event synchronously, after it removed its own copies. - A listener that throws at a privacy boundary fails the private-data
cleanup: the server then does not report its projection as current. The
server waits for a promise that an
onPermissionsChangedlistener returns. registry.watchStores(setup)callssetupfor each server when the registry creates it, before the server receives realtime data.
Svelte
Import @chatto/client/svelte once, before a component reads a server.
Svelte then tracks reads in components, $derived, and $effect:
<script lang="ts">
import '@chatto/client/svelte';
import { client } from '$lib/client';
let { serverId } = $props();
const server = $derived(client.server(serverId));
</script>
{#each server?.roomList.rooms ?? [] as room (room.id)}
<p>{room.name}</p>
{/each}
Other frameworks can use setReadHook and subscribe from
@chatto/client/reactivity in the same way.
Voice calls
A server tracks its active calls in projection.activeCalls. The client
contains no media implementation. A host that joins calls implements the
media, reads call permissions from the projected rooms, and leaves a call at
onRoomAccessLost and onDispose.
Reactivity
The package has a small signal library in @chatto/client/reactivity:
signal, computed, effect, effectRoot, batch, untrack,
subscribe, ReactiveMap, and ReactiveSet. Computed values are lazy.
Effects run synchronously when the outermost write ends.
Development
mise test-chatto-client # type checks, lint, and tests
src/types.ts and src/types/admin.ts are generated from
@chatto/api-types; mise codegen-proto updates them, and the package
check fails when they are out of date.
See MAINTAINING.md for package checks and release instructions.
The client and bundled generated protocol code use Apache-2.0;
the npm package includes its license and a NOTICE file.