Skip to main content

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 memberDescription
RoomThe room account (counters, current epoch, message count)
address / roomIdRoom 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:

OptionDefaultDescription
limit100Max messages to return
beforeSeqOnly messages with globalSeq < beforeSeq (exclusive)
direction"backward""backward" walks newest-first; "forward" from bucket 0 up
maxBuckets5Max buckets to walk per call
decrypttrueRecover epoch keys (one per distinct cryptoEpoch, newest first) and decrypt
includeContentfalseAlso fetch external content (Url/Ipfs/Irys/Arweave) for decrypted messages
concurrentFetchLimit10Concurrency for per-message decrypt/content fetches
forcefalseBypass the bucket cache
currentBucketTtlMs0Serve 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):

StatusMeaning
"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:

FieldTypeDescription
addressPublicKeyCompressed account address (derived from room + clientMsgId)
roomPublicKeyRoom PDA
cryptoEpochBNEpoch whose key encrypted this message
globalSeqBNProgram-assigned sequence number (1-based)
bucket / bucketIndexnumberBucket coordinates (floor((globalSeq - 1) / 100))
senderPublicKeySender wallet
memberSlotnumberSender's member slot
clientMsgIdUint8Array64-byte client-chosen id
timestampBNUnix timestamp
messageTypeText / Url / Ipfs / Irys / Arweave
contentUint8ArrayEncrypted content