Rooms
A room is an end-to-end encrypted group conversation: one admin manages membership, every member can send and read. The encryption enforces membership cryptographically — new members can't read history, removed members can't read the future. The concepts are covered in Rooms & Group Messaging, and the cryptography behind the broadcast encryption in Group Encryption (BGW); this section is the practical SDK guide.
The clients
| Client | Obtained via | Role |
|---|---|---|
RoomAdminClient | client.createRoom(...) / client.roomAdmin(...) / RoomAdminClient.Create/CreateManual/Load | Create the room, add/remove members, publish key-rotation headers |
RoomClient | client.room({ id }) / RoomClient.Load(...) | Reader side: membership, epoch-key recovery, entry point to messages |
RoomMessagesClient | room.messages() | Send messages, load buckets, page through history |
RoomMessageClient | room.message(ref) or returned from loads | Single message: fetch, decrypt, resolve external content |
All four are exported from xpkt-sdk.
Prerequisites
- A Photon-compatible RPC — rooms are built on ZK compression, like threads and inboxes.
- A BGW params artifact — the public material for the room broadcast encryption. The SDK ships with a built-in default (a public, capacity-1,048,576 artifact hosted on Arweave at
https://arweave.net/erEnI-MC4igmd9SpXuWmoVRiz6zfK-tVqyz5yGjmF8M), so rooms work with no configuration. To use your own artifact (e.g. a different capacity or host), configure it once at startup — see BGW params:
import { configureDefaultBgwParams } from "xpkt-sdk";
// Arweave-style base URL (manifest.json at the base, chunk i at `${base}/${i}`)
configureDefaultBgwParams({ baseUrl: "https://arweave.net/<txid>" });
// or a local directory with manifest.json + chunks/*.bin
configureDefaultBgwParams({ dir: "/path/to/params" });
- A crypto identity on every member's client — members decrypt their per-member room secret with it (see Member keys):
client.useSolanaCryptoKeypair(keypair); // server / CLI (raw keypair)
Quickstart — create, add, send, read
The shortest end-to-end path. The admin wallet creates the room, adds two members, then one of them sends a message and another reads it.
Two roles are at play, and they map to two clients:
- The admin uses a
RoomAdminClientfor membership only (create, add, remove). The admin is not automatically a member. - A member uses a
RoomClient(fromclient.room({ id })) to send and read. Each member has their ownPacketClientbound to their own keypair, with a crypto identity set (client.useSolanaCryptoKeypair(keypair)) and the BGW params configured.
import { RoomAdminClient } from "xpkt-sdk";
// adminClient, aliceClient, bobClient: separate PacketClients, each with its own
// keypair + useSolanaCryptoKeypair + photonRpc + BGW params configured.
// 1. Admin creates the group
const roomId = crypto.getRandomValues(new Uint8Array(32));
const seed = crypto.getRandomValues(new Uint8Array(32)); // room master secret — store it safely
const { client: adminRoom } = await RoomAdminClient.CreateManual({
client: adminClient,
params: { roomId, seed },
});
// 2. Admin adds members (each add publishes a new epoch automatically)
await adminRoom.addMember({ member: alicePublicKey });
await adminRoom.addMember({ member: bobPublicKey });
// 3. Alice sends — through her own RoomClient
const aliceRoom = await aliceClient.room({ id: roomId });
await aliceRoom.messages().send({ text: "welcome to the room" });
// 4. Bob reads + decrypts — through his own RoomClient
const bobRoom = await bobClient.room({ id: roomId });
const messages = await bobRoom.loadMessages({ limit: 50 });
for (const msg of messages) {
const result = await msg.decrypt(); // already done by loadMessages; cached
if (result.status === "decrypted") {
console.log(`#${msg.Message.globalSeq.toString()}: ${result.text}`);
}
}
That is the whole happy path. Server, CLI, and agent wallets use raw keypairs and need no key registration; browser-wallet members must register a packet key before being added — see Member keys. The rest of this page is depth.
If the admin also wants to send and read, the admin adds their own wallet with
addMember({ member: adminPublicKey }), then loads aRoomClientwithadminClient.room({ id })like any other member.
The same workflow is available outside the SDK:
- CLI:
packet room create→packet room add <room> <member>→packet room send <room> "..."→packet room read <room>. See CLI rooms. - MCP:
packet_room_create→packet_room_add_member→packet_room_send_message→packet_room_read_messages. See MCP rooms.
Quickstart — admin
Browser-wallet admins use client.createRoom({ signMessage }) instead of CreateManual — the room secret is derived from a wallet signature, so there is no seed to store:
const { client: adminRoom } = await client.createRoom({
signMessage: (message) => wallet.signMessage(message),
});
addMember returns the assigned slot and the covering header:
const added = await adminRoom.addMember({ member: alicePublicKey });
console.log(`alice -> slot ${added.slot}, epoch ${added.header.epoch.toString()}`);
Full details — seed model, recovery, removals, key rotation — are in the admin guide.
Quickstart — member
A member on another machine needs nothing but their wallet, the RPC config, and the room id:
const room = await client.room({ id: roomId }); // 32-byte roomId, PublicKey, or base58 string
// my membership + decrypted per-member secret
const me = await room.myMember();
console.log(`I am slot ${me.member.slot}`);
// send
await room.messages().send({ text: "hello room" });
// read the latest messages (decrypted by default)
const messages = await room.loadMessages({ limit: 50 });
for (const msg of messages) {
const result = await msg.decrypt(); // cached — already done by loadMessages
if (result.status === "decrypted") {
console.log(`#${msg.Message.globalSeq.toString()}: ${result.text}`);
} else {
console.log(`#${msg.Message.globalSeq.toString()}: [${result.status}]`); // e.g. "locked" history
}
}
"locked" is a normal outcome, not an error: it means this wallet is not a recipient of that message's crypto epoch (sent before they joined, or while they were removed).
Discovering rooms
Rooms have no central registry; you find them by scanning on-chain accounts for a wallet. Two questions, two helpers on PacketClient (both default to the connected wallet):
// Rooms you are a member of (active by default).
const memberships = await client.roomMemberships();
for (const m of memberships) {
console.log(`room ${m.room.toBase58()} — slot ${m.slot}, status ${m.status}`);
const room = await client.room({ id: m.room });
// …read/send…
}
// Include rooms you were removed from:
const all = await client.roomMemberships({ activeOnly: false });
// Rooms you administer (created):
const admined = await client.roomsByAdmin();
for (const room of admined) {
console.log(`admin of ${room.address.toBase58()} — epoch ${room.currentEpoch.toString()}`);
}
// For another wallet, pass it explicitly:
await client.roomMemberships({ owner: somePubkey });
await client.roomsByAdmin(somePubkey);
roomMemberships scans compressed RoomMember accounts by owner (each result carries room, slot, status); roomsByAdmin lists Room PDAs by their admin field. The underlying functions getRoomMembershipsForOwner(...) and GetRoomsByAdmin(...) are also exported if you need them without a PacketClient instance.
Where to next
- Managing a room — create/recover the admin, add/remove members, rotate keys
- Reading & sending — epoch keys, buckets, caching, decryption results
- BGW params — what the artifact is, hosting it, generating it
- Member keys — registered packet keys vs wallet-derived keys
- Troubleshooting — indexer lag, header gating, concurrent sends