Messaging
There are two messaging surfaces, both riding the same shared-space transport:
client.message— lightweight realtime: plaintext pub/sub fan-out and end-to-end-encrypted direct messages (Double Ratchet — forward-secret, ephemeral, no history; the platform cannot read them).client.space— fan-out group channels with persisted history. This is what group chat is built on.
Pub/sub
Section titled “Pub/sub”Fan out plaintext events to everyone subscribed to a subject. Good for presence, cursors, live counters, notifications. It is not encrypted — don’t publish anything private.
const sub = client.message.subscribe("presence:lobby", (e) => { console.log(e.from, e.data); // e: { subject, from, data }});
await client.message.publish("presence:lobby", { type: "cursor", x: 12, y: 40 });
sub.unsubscribe();Direct messages (end-to-end encrypted)
Section titled “Direct messages (end-to-end encrypted)”send("user:<id>", payload) encrypts to the recipient with the Double Ratchet.
The recipient receives DMs by subscribing to their own id:
// Recipient (ada) listens for her inbox:client.message.subscribe("user:ada", (e) => { console.log("DM from", e.from, ":", e.data);});
// Sender (grace):await client.message.send("user:ada", { text: "Hello 👋" });Both parties must be online with the same app for the ratchet handshake to complete and the message to be delivered.
Group channels (client.space)
Section titled “Group channels (client.space)”For group chat with history, use channels. A channel is a fan-out space addressed by a human name through your app’s registry. One symmetric group key seals each message once, so the server can persist + replay it — and the app’s keeper admits new members, so joining works even if no one else is online.
A channel name is 1 to 32 characters with no /, and names like __x__ are
reserved. Create vs join is explicit: createChannel mints + registers a new channel
(you become its first key-holder); joinChannel resolves an existing name and
throws ChannelNotFoundError if it doesn’t exist.
// Create a channel (or join one you already made):const space = await client.space.createChannel("project-x");
// Join an existing channel by name (admitted by the keeper):const space = await client.space.joinChannel("project-x");
space.onMessage((e) => addMessage(e.handle, e.from, e.message.body)); // decrypted, liveawait space.sendMessage("Hi everyone"); // fan-out to all
// Backfill persisted history (Double Ratchet DMs have none):const { messages } = await space.history({ limit: 100 });
// Discover the app's channels:const channels = await client.space.listChannels(); // [{ name, spaceId }]You can also create a channel without a name (createSpace) and share its raw
id; joinChannel accepts either a registered name or a raw space id.
Editing & deleting
Section titled “Editing & deleting”Messages can be edited or deleted by their handle (the server storage key
on each SpaceMessageEvent). These are author-only (enforced server-side) and
mutate the persisted entry directly — an edit replaces it in place; a delete is
a hard delete (gone from history).
space.onMessageEdited((e) => replace(e.handle, e.message.body)); // same handlespace.onMessageDeleted((e) => remove(e.handle));
await space.editMessage(handle, "fixed typo");await space.deleteMessage(handle);Whether to show “(edited)” or a deletion placeholder is an app choice — encode
it in your own message body shape (e.g. { contents, edited }); the SDK and
server treat the body as opaque. See the chat example.
Private channels & membership
Section titled “Private channels & membership”createChannel(name, { private: true }) makes a channel that is hidden from
listChannels and admits only people on its member list. Add them with a
shareable invite link (client.space.createInviteLink) or, on a Space with a
signed member list, space.add(username, { role }). See the
client.space reference. Someone the
list does not include sees space.admission === "not-member".
Ephemeral signals
Section titled “Ephemeral signals”sendEphemeral(subject, data) broadcasts a signal the server relays but
never persists — the generic primitive for presence-style features (typing,
online pings, cursors). Pick a subject and payload; receivers filter on it:
space.sendEphemeral("typing", { isTyping: true });space.onEphemeral((e) => { if (e.subject === "typing") setTyping(e.from, (e.data as { isTyping: boolean }).isTyping);});A third argument, a member id, sends the signal to that member only:
space.sendEphemeral("ping", data, memberId).
Files in a channel
Section titled “Files in a channel”A space carries encrypted, erasure-coded file storage. putFile doesn’t need
the socket open; reading a shared file never opens one. (The manifest holds the
file’s keys, so a manifest sent as a channel message is readable by the platform
like any other message.) Ship the manifest as a
normal message so members can fetch + decrypt it:
const { manifest } = await space.putFile(file, { name: file.name, type: file.type });await space.sendMessage({ _t: "file", manifest });
// On the receiving side, detect the envelope and fetch:const { data } = await space.getFile(manifest); // Uint8Array, decoded + decryptedSee the encrypted chat example for a full implementation and
the client.space reference for signatures.