Skip to content

client.space

See the Messaging guide for usage. Channels are group spaces addressed by a human name through your app’s registry; each space seals messages once with a shared group key so the server can persist + replay them as history. Requires a configured app key.

createChannel(name: string, opts?: { historyPolicy?: "static" | "rotate"; private?: boolean }): Promise<Space>

Mint a new channel: create a fan-out space, register name → id in the app directory, and admit the app keeper so it’s joinable later. You become the first key-holder. Throws ChannelExistsError if the name is taken.

With private: true the channel is membership-gated: it’s hidden from listChannels, and only members the creator (or an existing member) has invited are admitted by the keeper. Public channels (the default) are joinable by any authenticated app user.

joinChannel(name: string, opts?: { timeoutMs?: number }): Promise<Space>

Resolve an existing channel name (or a raw space id) and join it — the keeper admits you and hands over the group key. Throws ChannelNotFoundError if no such channel is registered.

listChannels(): Promise<Array<{ name: string; spaceId: string }>>
resolveChannel(name: string): Promise<string | null> // space id, or null

The channel directory is app-public (readable with the app key); channel contents stay end-to-end encrypted.

createSpace(opts?) / joinSpace(id, opts?) / get(id)

Section titled “createSpace(opts?) / joinSpace(id, opts?) / get(id)”
createSpace(opts?: { historyPolicy?: "static" | "rotate" }): Promise<Space>
joinSpace(spaceId: string, opts?: { timeoutMs?: number }): Promise<Space>
get(spaceId: string, historyPolicy?: "static" | "rotate"): Promise<Space>

Lower-level: a space’s id is its encoded public key. createChannel / joinChannel are these plus the name registry. get returns a handle without connecting (handy for shard-only file reads).

historyPolicy is static (one key, full history — the default) or rotate (per-epoch keys; members read only the epochs they belonged to).

MemberSignatureDescription
idstringThe space’s encoded public key.
keyringSpaceKeyring | undefinedThe group-key ring backing this space (present once joined/created).
sendMessage(payload, opts?)(unknown, { channel?, contentType? }) => Promise<void>Seal once with the group key + fan out. Loops back to the sender.
onMessage(handler)(e: SpaceMessageEvent) => () => voidDecrypted messages. Returns an unsubscribe fn.
editMessage(handle, payload, opts?)(number, unknown, { channel?, contentType? }) => Promise<void>Replace a persisted message in place, addressed by its server handle. Re-seals the payload; the server authorizes against the original author.
deleteMessage(handle)(number) => Promise<void>Hard-delete a persisted message by its handle — the ciphertext is removed from storage.
onMessageEdited(handler)(e: SpaceMessageEvent) => () => voidAuthoritative in-place edits: new content at the same handle.
onMessageDeleted(handler)(e: MessageDeletedEvent) => () => voidAuthoritative deletions ({ handle }).
sendEphemeral(subject, data)(string, unknown) => voidBroadcast a never-persisted signal (no history). The generic primitive for typing indicators, presence, cursors — see below. No-op if disconnected.
onEphemeral(handler)(e: EphemeralEvent) => () => voidInbound ephemeral signals; e.from is the server-authenticated sender.
history(opts?)({ before?, limit? }) => Promise<{ messages, nextCursor }>Fetch + decrypt persisted history.
putFile(file, metadata)=> Promise<{ manifest, stat }>Encrypt + erasure-code + upload to space shards.
getFile(manifest)=> Promise<{ data: Uint8Array, stat }>Fetch + decode a shared file.
admit(memberId, identityEcdhPub)=> Promise<void>Wrap the group key for a member (key-holder action).
invite(username)(string) => Promise<void>Add a user to a private channel’s membership allowlist (member-only).
onJoinRequest(handler)(req: JoinRequest) => () => voidInbound join requests — for manual membership gating (autoAdmit: false).
rotate(roster?)=> Promise<number>Mint a new epoch + re-wrap to members (rotate spaces).
connect() / disconnect()Open / close the websocket.
on(event, handler) / off(...)Raw channel events (channel:raw_frame for roster, etc.).
peers() / isConnected()Roster ids / socket liveness.
interface SpaceMessageEvent {
from: string; // sender member id (server-stamped, signature-verified)
channel: string; // subject/topic within the space
epoch: number; // group-key epoch
contentType?: string;
handle: number; // server storage handle — see below
message: Message; // message.body is the (app-defined) payload
}
interface EphemeralEvent { from: string; subject: string; data: unknown }
interface MessageDeletedEvent { handle: number }

The handle is the message’s server-assigned storage key (a monotonic timestamp). It is stable across edits and is what you pass to editMessage / deleteMessage. Use it — not message.id — as a message’s identity for dedup and mutation: an edit re-seals a fresh Message (new message.id) under the same handle.

editMessage and deleteMessage are generic persisted-item operations — the server mutates the stored entry (it has no notion of what your payload means) and authorizes the request against the original author (it can read the cleartext envelope’s signed source, never the sealed body). An edit replaces the ciphertext in place at the same handle; a delete removes it entirely.

space.onMessage((e) => render(e.handle, e.message.body));
space.onMessageEdited((e) => render(e.handle, e.message.body)); // same handle
space.onMessageDeleted((e) => remove(e.handle));
// Send, then edit/delete by the handle you received:
await space.sendMessage({ contents: "hello" });
// …later, holding `handle` from the message's SpaceMessageEvent:
await space.editMessage(handle, { contents: "hello (fixed)" });
await space.deleteMessage(handle);

Whether an edited message is marked as edited, or a deleted one leaves a “message deleted” placeholder, is an application choice expressed in your message body shape (e.g. { contents, edited }) — the SDK and server stay domain-agnostic.

sendEphemeral(subject, data) broadcasts a signal the server relays to the space but never persists (it won’t appear in history). It’s the generic building block for presence-style features — typing indicators, “who’s online” pings, live cursors — which you layer on top by choosing a subject and payload:

// Typing indicator, built in the app on top of the generic primitive:
space.sendEphemeral("typing", { isTyping: true });
space.onEphemeral((e) => {
if (e.subject === "typing") setTyping(e.from, (e.data as { isTyping: boolean }).isTyping);
});

Every fan-out message is ECDSA-signed by the sender’s identity key over its {source, target, subject, epoch, iv, ciphertext}. Receivers verify the signature against the sender’s published key (from the member directory) and drop anything unsigned, signed by an unknown member, or mismatched — so a member can’t forge another member’s from, and a relay can’t relabel a message. The group key gives confidentiality + membership; the signature gives authorship.

Channels are joinable even when no human member is online because the app runs a keeper — a trusted, always-available member that holds the group key and re-issues it to newcomers. The keeper can read its channels; the relay + storage stay blind (they only ever see opaque ECIES-wrapped key blobs). createChannel admits the keeper for you.