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.
Channels
Section titled “Channels”createChannel(name, opts?)
Section titled “createChannel(name, opts?)”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, opts?)
Section titled “joinChannel(name, opts?)”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() / resolveChannel(name)
Section titled “listChannels() / resolveChannel(name)”listChannels(): Promise<Array<{ name: string; spaceId: string }>>resolveChannel(name: string): Promise<string | null> // space id, or nullThe channel directory is app-public (readable with the app key); channel contents stay end-to-end encrypted.
Spaces (no registry name)
Section titled “Spaces (no registry name)”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).
Invite links & roles
Section titled “Invite links & roles”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.
| Member | Signature | Description |
|---|---|---|
id | string | The space’s encoded public key. |
keyring | SpaceKeyring | undefined | The 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) => () => void | Decrypted 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) => () => void | Authoritative in-place edits: new content at the same handle. |
onMessageDeleted(handler) | (e: MessageDeletedEvent) => () => void | Authoritative deletions ({ handle }). |
sendEphemeral(subject, data, to?) | (string, unknown, string?) => void | Broadcast 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) => () => void | Inbound 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) => () => void | Inbound 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) => () => void | Where this member stands: "locked", "requesting", "keyed", "not-member", "identity-mismatch" or "integrity". |
gossipToPeers(data) / onPeerGossip(handler) | (Uint8Array) => void / ((from, data) => void) => () => void | Direct 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. |
SpaceMessageEvent
Section titled “SpaceMessageEvent”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.
Signed member list
Section titled “Signed member list”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.
| Member | Signature | Description |
|---|---|---|
membership() | => MemberView[] | null | The verified members, or null when the Space has no log. A MemberView is { id, role, ik, fingerprint, identityChanged }. |
onMembership(handler) | ((m: MemberView[] | null) => void) => () => void | Called 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.
Editing & deleting messages
Section titled “Editing & deleting messages”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 handlespace.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.
Offline
Section titled “Offline”cachedMessages()
Section titled “cachedMessages()”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.
Ephemeral signals
Section titled “Ephemeral signals”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);});Sender authenticity
Section titled “Sender authenticity”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.
The keeper (server-blind escrow)
Section titled “The keeper (server-blind escrow)”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.