Reading & Sending
The reader side of a room is the RoomClient plus its messages() sub-client. A member needs nothing local: their wallet (with a crypto identity set), a Photon-compatible RPC and the room id are enough to rebuild everything — membership, epoch keys, full history.
Loading a room
const room = await client.room({ id }); // 32-byte roomId, PublicKey, or base58 address
// equivalent explicit form:
import { RoomClient } from "xpkt-sdk";
const room = await RoomClient.Load({ client, id, /* bgwParams? */ });
BGW params resolve from the per-call bgwParams override or the process-wide default configured with configureDefaultBgwParams (see BGW params).
RoomClient member | Description |
|---|---|
Room | The room account (counters, current epoch, message count) |
address / roomId | Room PDA / 32-byte id |
refresh() | Re-fetch the room account |
myMember(force?) | This wallet's membership + decrypted BGW user secret (cached) |
recoverEpochKey(epoch?) | Recover a message epoch key (cached, chain-aware) |
messages() | The RoomMessagesClient (cached) |
message(ref) / loadMessages(opts) | Pass-throughs to messages() |
My membership
const me = await room.myMember();
me.member.slot; // 1-based member slot
me.member.status; // 0 = active, 1 = removed
me.userSecret; // decrypted BGW user secret (used internally for key recovery)
myMember() fetches this wallet's RoomMember account and decrypts the member-secret envelope with the client's crypto identity — so the identity must match what the admin encrypted to (raw keypair via useSolanaCryptoKeypair, or the registered key's identity; see Member keys). The result is cached; pass myMember(true) to force a refetch (e.g. right after being added or removed).
Recovering epoch keys
Every message records the epoch it was encrypted under (cryptoEpoch). recoverEpochKey returns a typed result — not being a recipient is an expected outcome, not an error:
const result = await room.recoverEpochKey(); // no argument = the room's current epoch
if (result.status === "ok") {
result.key; // 32-byte message epoch key
result.chainStartEpoch; // first epoch of the key-chain segment
} else {
// result.status === "not-recipient"
// this wallet is not in the recipient set of that epoch's header
// (removed member at a newer epoch, or new member at an older epoch)
}
It throws only on real failures: not a member at all, header chain verification failure (RoomRecipientChainError — fail closed, never guesses keys), or persistent indexer lag.
Caching and chain derivation. Recovered keys are cached per epoch on the RoomClient. Within one segment, the key of an older epoch is derived from any cached newer key with cheap hash steps — no second BGW decapsulation. Reading a whole segment of history therefore costs one decapsulation total. Keep one RoomClient instance alive per room to benefit.
Sending
const result = await room.messages().send({ text: "hello" });
result.receipt; // tx signatures
result.clientMsgId; // random 64-byte id — the message's address seed
result.address; // derived compressed message address
result.message; // fetched RoomMessageData, or null if the indexer hasn't caught up yet
Requirements: the wallet must be an active member covered by the latest header. The content is encrypted with the current epoch's key before it goes on-chain.
For non-text payloads, pass content bytes and a MessageType — the same storage guidance as threads applies (keep inline payloads small, point to off-chain storage for anything big):
import { MessageType } from "xpkt-sdk";
await room.messages().send({
content: new TextEncoder().encode(irysUrl),
messageType: MessageType.Irys,
});
RoomSendMessageParams is a union: { text } (sent as MessageType.Text) or { content, messageType }.
One send at a time per wallet. Two simultaneous in-flight sends from the same member conflict on-chain (each send updates the sender's member account). Serialize your own sends; different members sending concurrently is fine. See Troubleshooting.
Reading
Messages live in buckets of 100 by global sequence number; each bucket is one indexer query.
loadMessages — thread-style paging
// latest 100, decrypted
const latest = await room.loadMessages();
// older page: everything before the oldest message you have
const older = await room.loadMessages({
beforeSeq: latest[0].Message.globalSeq,
limit: 100,
});
The result is sorted by globalSeq ascending. Options:
| Option | Default | Description |
|---|---|---|
limit | 100 | Max messages to return |
beforeSeq | — | Only messages with globalSeq < beforeSeq (exclusive) |
direction | "backward" | "backward" walks newest-first; "forward" from bucket 0 up |
maxBuckets | 5 | Max buckets to walk per call |
decrypt | true | Recover epoch keys (one per distinct cryptoEpoch, newest first) and decrypt |
includeContent | false | Also fetch external content (Url/Ipfs/Irys/Arweave) for decrypted messages |
concurrentFetchLimit | 10 | Concurrency for per-message decrypt/content fetches |
force | false | Bypass the bucket cache |
currentBucketTtlMs | 0 | Serve a recently fetched current bucket from cache for this long |
loadBucket — direct bucket access
const msgs = await room.messages().loadBucket(0); // first 100 messages ever
Caching semantics: a completed bucket (100 messages, or below the room's currentBucket when fetched) is immutable and cached forever (until force: true). The current bucket is still filling, so it is refetched on every call — unless you allow a recent fetch to be reused with currentBucketTtlMs. A simple poll loop:
const items = await room.messages().loadBucket(room.Room.currentBucket, {
currentBucketTtlMs: 2_000, // at most one Photon query per 2s
});
Single messages
// handle by compressed address or by 64-byte clientMsgId (address is derived)
const msg = room.message(clientMsgId);
await msg.load(); // or loadRetrying() right after a send
// fetch + decrypt in one call (null if not found)
const loaded = await room.messages().loadMessage(clientMsgId);
loadMessagesByScan — full export
Cursor-based scan over all of a room's messages (bypasses buckets), useful for backups/exports:
let cursor: string | null = null;
do {
const page = await room.messages().loadMessagesByScan({ limit: 200, cursor: cursor ?? undefined });
// page.items: { message, status, content?, text?, error? }[]
cursor = page.cursor;
} while (cursor);
Decryption results
RoomMessageClient.decrypt() never throws; it returns a typed result (cached; "failed" results are not cached so transient errors can be retried):
| Status | Meaning |
|---|---|
"decrypted" | Plaintext available — content (bytes) and text (when valid UTF-8) |
"locked" | This wallet is not a recipient of the message's crypto epoch — expected for history before a join, or messages sent while removed |
"failed" | Epoch key recovered but decryption failed (corrupt/tampered content), or key recovery itself errored — error has details |
After decryption, external content types resolve exactly like thread messages:
const result = await msg.decrypt();
if (result.status === "decrypted") {
const content = await msg.loadContent(); // fetches Url/Ipfs/Irys/Arweave bodies
const parsed = await msg.loadParsedContent(); // + Packet envelope parsing
}
The RoomMessageData type
Accessed via msg.Message after loading:
| Field | Type | Description |
|---|---|---|
address | PublicKey | Compressed account address (derived from room + clientMsgId) |
room | PublicKey | Room PDA |
cryptoEpoch | BN | Epoch whose key encrypted this message |
globalSeq | BN | Program-assigned sequence number (1-based) |
bucket / bucketIndex | number | Bucket coordinates (floor((globalSeq - 1) / 100)) |
sender | PublicKey | Sender wallet |
memberSlot | number | Sender's member slot |
clientMsgId | Uint8Array | 64-byte client-chosen id |
timestamp | BN | Unix timestamp |
messageType | — | Text / Url / Ipfs / Irys / Arweave |
content | Uint8Array | Encrypted content |