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.

A channel name is 1 to 32 characters and cannot contain /. A name written __like_this__ (double underscore at both ends) is reserved; the server answers 400 for it. If the keeper cannot be admitted (a warning is logged) the channel still works — members admit one another — but it is not joinable while nobody holding the key is online.

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"; private?: boolean; messaging?: boolean }): Promise<Space>
joinSpace(spaceId: string, opts?: { timeoutMs?: number }): Promise<Space>
get(spaceId: string, historyPolicy?: "static" | "rotate", opts?: { messaging?: boolean }): 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).

private: true makes the space membership-gated, as for a private channel. messaging: false makes a files-only space: no group keyring, no identity and no websocket, so it opens whether or not the session is unlocked and a file write never waits on a socket. Use it for a space that only holds files — each chunk carries its own key in its manifest. Without it, createSpace throws IdentityLockedError while the identity is locked.

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

A private space (createChannel(name, { private: true })) admits only allowlisted members. Rather than inviting one username at a time, mint a shareable capability link anyone can redeem — the keeper admits them on redemption. Members carry a role (viewer / editor) the owner controls; apps use roles to gate delegated management.

// Owner — mint / list / revoke invite links (optionally bounded + with a role).
createInviteLink(spaceId, opts?: { expiresInSec?; maxUses?; role?: "viewer" | "editor" }): Promise<InviteLink>
listInviteLinks(spaceId): Promise<InviteLink[]>
revokeInviteLink(spaceId, token): Promise<void>
// Recipient — redeem a link: admitted by the keeper, group key wrapped to them.
joinByInvite(spaceId, token, opts?: { timeoutMs? }): Promise<Space>
// Owner — members + roles.
members(spaceId): Promise<Array<{ memberId: string; role: string }>>
setMemberRole(spaceId, username, role): Promise<void>
roster(spaceId): Promise<RosterMember[]>
const { token } = await client.space.createInviteLink(space.id, { role: "viewer" });
const url = `https://yourapp.com/join?space=${space.id}&token=${token}`;
// …recipient opens the link…
await client.space.joinByInvite(space.id, token);
// …owner promotes them…
await client.space.setMemberRole(space.id, "alice", "editor");

Minting a link and changing roles are owner-only (the space creator); listing and revoking links need the creator or an existing member. joinByInvite only needs a signed-in session and a valid token.

On a Space with a signed member list, the unsigned invite and createInviteLink routes are refused (a SpaceTrustError); manage membership with space.add / remove / setRole instead.

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, to?)(string, unknown, string?) => voidBroadcast a never-persisted signal (no history). With a member id as to, the server delivers it to that member only. 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, opts?)(file, metadata, { onProgress?, backgroundUpload? }) => Promise<{ manifest, stat }>Encrypt + erasure-code + upload to the global shard store, and meter the bytes to your app. Pass onProgress(completed, total) (called per chunk) to drive an upload progress bar. backgroundUpload: true returns once the shards are cached locally and announced to peers, and uploads to origin in the background (browser with offline support only; ignored otherwise). Does not need the socket.
getFile(manifest)=> Promise<{ data: Uint8Array, stat }>Fetch + decode a shared file into one buffer. Fine for small files.
getFileStream(manifest)=> AsyncGenerator<Uint8Array>Async-iterate a file’s decrypted chunks in order, never holding the whole file in memory. Use for multi-GB reads (a single Uint8Array can’t hold them) — e.g. piping to a temp file. Same peer/origin sourcing as getFile.
commitFile(manifest) / releaseFile(manifest)(FileManifest) => Promise<void>Take or drop a reference to a manifest’s shards (metering and garbage collection). putFile commits for you; use these to register a manifest you received, or to release one you stop using.
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). Throws SpaceTrustError on a Space with a signed member list.
admission / onAdmission(handler)AdmissionState / (s) => () => voidWhere this member stands: "locked", "requesting", "keyed", "not-member", "identity-mismatch" or "integrity".
gossipToPeers(data) / onPeerGossip(handler)(Uint8Array) => void / ((from, data) => void) => () => voidDirect peer-to-peer broadcast that bypasses the server. Needs the opt-in p2p option and a browser; otherwise a no-op.
connect() / disconnect()Open / close the websocket.
cachedMessages()=> Promise<SpaceMessageEvent[]>Locally cached messages — see Offline.
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.

A private Space can carry a signed member list: an append-only log of membership changes, each entry signed by the member who made it, which every client verifies. The server relays it but cannot forge a member in. space.logEnabled says whether this Space has one.

MemberSignatureDescription
membership()=> MemberView[] | nullThe verified members, or null when the Space has no log. A MemberView is { id, role, ik, fingerprint, identityChanged }.
onMembership(handler)((m: MemberView[] | null) => void) => () => voidCalled each time the log is re-synced.
add(username, opts?)(string, { role?: "viewer" | "editor" }) => Promise<{ seq: number }>Add a member by exact username (default role viewer). The owner, or an editor for viewers.
remove(memberId)(string) => Promise<{ seq: number }>Remove a member. This cuts them off at the relay; it does not rotate the group key.
setRole(memberId, role)(string, "viewer" | "editor") => Promise<{ seq: number }>Change a member’s role.
leave()=> Promise<{ seq: number }>Remove yourself.

These throw SpaceTrustError (code: "log-refused") on a Space with no log. A user the log turns away gets admission of "not-member" (or "identity-mismatch" if the owner added a different identity for them), and the socket is not reopened until the log admits them. See Errors for SpaceTrustError and AdmissionDeniedError.

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.

cachedMessages(): Promise<SpaceMessageEvent[]>

Decode and return the locally-cached message log, in handle order — works offline. The cache is populated by live frames and history(). Call it on boot to paint instantly, then subscribe to onMessage for live updates. Returns [] when offline support is off.

Messages sent while disconnected are applied optimistically and queued; on reconnect the Space pages a forward delta from its last-seen handle to catch up. See the Offline guide.

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 is run by the platform, so Muhkoo can read channel messages — the keeper holds the group key. What the relay and storage see on their own is ciphertext, plus opaque ECIES-wrapped key blobs. Channel contents are therefore private from other apps and from the open internet, not from the platform. Zero-knowledge sign-in, direct messages (client.message.send) and client.kv are the stores the platform cannot read. createChannel admits the keeper for you.