Messaging
Inbox enumeration, raw envelope reads, and the live decrypted message stream — driven by Snap's own bundle session.
client.messaging exposes two layers in one manager:
- Raw envelope reads —
listConversations()andfetchEncryptedMessages()are direct gRPC-Web calls againstMessagingCoreService. Useful for inbox enumeration and historical backfill that doesn't need decrypt. - Live decrypted stream + outbound presence —
on("message", …),setTyping,setViewing,setRead, plus the outbound sends (sendText,sendImage,sendSnap). The first call to any of these lazily brings up Snap's own bundle session inside the per-instance Sandbox; subsequent calls reuse it.
The bring-up is lazy because it costs ~3s cold (mint/register Fidelius identity if needed, eval the worker chunk, open the duplex WebSocket). Consumers that only want raw envelope reads shouldn't pay for it.
Live decrypted inbound
import { SnapcapClient, FileDataStore } from "@snapcap/native";
const client = new SnapcapClient({
dataStore: new FileDataStore("./auth.json"),
credentials: { username, password },
browser: { userAgent },
});
await client.authenticate();
// First .on(...) call triggers the ~3s bundle-session bring-up.
const sub = client.messaging.on("message", (msg) => {
const text = new TextDecoder().decode(msg.content);
console.log(`${msg.isSender ? "->" : "<-"} (type ${msg.contentType}) ${text}`);
});
// Later — tear down the subscription:
sub();msg is a PlaintextMessage:
| Field | Meaning |
|---|---|
content | Decrypted bytes the WASM produced. UTF-8 string for text DMs; a small protobuf header pointing at the encrypted CDN blob for media. |
isSender | true iff the logged-in user sent the message; false for inbound from a peer. |
contentType | Snap's contentType enum — 2 = text, 3 = media, etc. |
raw | The unwrapped delegate object the bundle handed to its messagingDelegate.onMessageReceived. Keys vary across bundle builds; use defensively. |
Tie the subscription's life to anything that takes an AbortSignal:
const ctrl = new AbortController();
client.messaging.on("message", onMessage, { signal: ctrl.signal });
ctrl.abort(); // tears the subscription downOn the first subscription per process, the SDK pumps history for every conversation it knows about so the WASM can decrypt cached messages. Expect a burst of message events for already-seen messages, with isSender and contentType populated. Subscribers should be idempotent — match on (conversationId, messageId) if you need to dedupe across restarts.
Conversation list
const convs = await client.messaging.listConversations();
for (const c of convs) {
console.log(`${c.conversationId} type=${c.type} ${c.participants.length} participants`);
}ConversationSummary carries conversationId, type (5 = DM, 13 = group, 420 = MOB friends, …), and the full participants UUID list (including self). No decrypt — this is a direct gRPC call.
If you call listConversations() before any on(...) subscription, the bundle session hasn't been brought up yet, so the SDK reads selfUserId from the chat-bundle auth slice. Pass selfUserId explicitly if you need to bypass that lookup.
Historical backfill (raw envelopes)
const convs = await client.messaging.listConversations();
const dmsOnly = convs.filter((c) => c.type === 5);
const envelopes = await client.messaging.fetchEncryptedMessages(dmsOnly);
for (const env of envelopes) {
if (env.cleartextBody) {
// Non-E2E content (AI bot replies, MEMORIES) carries plaintext directly.
console.log(`[${env.conversationId}] ${env.cleartextBody}`);
} else {
// E2E body — opaque bytes here. Subscribe to `messaging.on("message", ...)`
// to see the decrypted form once the bundle session is live.
console.log(`[${env.conversationId}] (encrypted, ${env.envelope.byteLength}B)`);
}
}RawEncryptedMessage gives you conversationId, senderUserId, messageId, serverTimestampMs, the raw envelope bytes, and an opportunistic cleartextBody field — Snap embeds plaintext metadata (media URLs, snap IDs, AI bot replies) alongside the E2E-wrapped body, so the SDK surfaces whatever is readable without decrypt.
fetchEncryptedMessages() and listConversations() are independent of the bundle session — they're plain gRPC-Web. You can mix raw-envelope backfill with on("message", …) for go-forward live delivery.
Sending text
const messageId = await client.messaging.sendText(conversationId, "hello");sendText routes through Snap's own bundle send pipeline (sendMessageWithContent inside the messaging session), which builds the ContentMessage envelope, encodes it with the bundle's own proto codec, drives Fidelius for E2E conversations, and dispatches via gRPC. It triggers the lazy bundle-session bring-up if it hasn't happened yet.
The returned id is best-effort: if the bundle's response shape doesn't carry a server-assigned id under a known field, the SDK returns a locally-generated client UUID. Subscribe via messaging.on("message", cb) and filter isSender === true for delivery confirmation through the canonical inbound path.
const sub = client.messaging.on("message", (msg) => {
if (msg.isSender) console.log("delivered:", new TextDecoder().decode(msg.content));
});
await client.messaging.sendText(conversationId, "hello");Sending images and snaps (experimental)
await client.messaging.sendImage(conversationId, imageBytes, { caption: "hi" });
await client.messaging.sendSnap(conversationId, imageBytes, { timer: 5 });Both route through Snap's own bundle send pipeline. sendImage posts a persistent attachment that stays in chat history; sendSnap posts a disappearing snap (destination kind 122) — pass timer for a custom display duration in seconds, omit for view-once. The bundle owns upload, Fidelius encryption, and dispatch end-to-end. They compile and bring up the bundle session cleanly but are not yet wire-tested in the integration suite — treat as experimental.
client.stories.post(bytes, { caption }) shares the same pipeline targeted at MY_STORY. Same experimental status — see Stories.
Outbound presence
await client.messaging.setTyping(convId, 1500);
await client.messaging.setViewing(convId, 5000);
await client.messaging.setRead(convId, messageId);setTyping shows a typing indicator in convId for the requested duration, then auto-clears. The pulse re-fires every 2s so the recipient's idle timer doesn't drop the indicator early; the try/finally teardown fires chatHidden on the bundle's presence session so the dot clears immediately rather than waiting on the recipient's ~3s idle window.
setViewing marks the conversation as actively open for the requested duration, then dispatches exitConversation on teardown. Same dual-path treatment — primes the bundle's presence session so modern Snap clients honour the convMgr enterConversation frame.
setRead fires a read-receipt frame for messageId in convId. Accepts the message id as string or bigint — coerced internally. From a RawEncryptedMessage, pass the messageId: bigint field; from a live message event, the underlying delegate record carries it.
const sub = client.messaging.on("message", async (msg) => {
if (!msg.isSender) {
const id = (msg.raw as { messageId?: bigint }).messageId;
if (id !== undefined) await client.messaging.setRead(msg.conversationId, id);
}
});All three calls trigger the lazy bundle-session bring-up if it hasn't happened yet.
Global presence — client.setStatus / getStatus
Independent of any conversation, the bundle exposes a global Present / Away slot that gates outbound typing-pulse broadcasts. Use setStatus to mark the session idle without tearing down auth, and getStatus to read what the bundle currently holds.
await client.authenticate();
client.setStatus("Away"); // suppress typing pulses (bundle gates broadcastTypingActivity)
client.setStatus("Present"); // reopen the gate
console.log(client.getStatus()); // "Present""Present" is the default after authenticate() (the chat-bundle loader patches document.hasFocus = () => true so the slice initializes there). "AwaitingReactivate" is a rare transitional value the bundle uses when the session is briefly paused; consumers can pass or read it but typically won't.
See PresenceStatus for the type and setStatus / getStatus for the full surface.
When does the session come up?
The first call to any of these brings up the bundle session:
client.messaging.on(event, …)(any event)client.messaging.setTyping(...)/setViewing(...)/setRead(...)client.messaging.sendText(...)/sendImage(...)/sendSnap(...)client.stories.post(...)
listConversations() and fetchEncryptedMessages() do not — they're plain gRPC-Web and stay cheap. If you only need inbox enumeration, you'll never pay for the bundle session. client.setStatus / getStatus also stay cheap — they just read/write the existing bundle's Zustand slice and require authenticate() to have completed.
Bring-up is single-flight per SnapcapClient: concurrent .on(...) calls all wait on the same in-flight promise. If bring-up fails, the next subscription / presence call retries from scratch.
What's not (yet) wired
| Capability | Status |
|---|---|
on("message", ...) — live decrypted inbound | Working |
listConversations() / fetchEncryptedMessages() — raw envelopes | Working |
sendText(convId, text) | Working — routes through bundle send pipeline |
setTyping / setViewing / setRead — outbound presence | Working — convMgr + presence-slice dual-path |
client.setStatus / getStatus — global Present/Away | Working |
sendImage(...) / sendSnap(...) | Experimental — compiles and brings up cleanly, not wire-tested |
client.stories.post(media) | Experimental — same shape as sendImage, not wire-tested |
on("typing" / "viewing" / "read", ...) | Subscribable today, but the bundle's inbound presence delegate hasn't been hooked yet — events don't fire |
See the messaging-session internals for the bring-up plumbing, and Fidelius for the identity / decrypt side.
Friends
Reading the social graph, pending request inboxes, typed events with offline replay, and the refresh cadence consumers must drive themselves.
Multi-tenant
Run many accounts in one process. Per-instance Sandbox, per-tenant DataStore + browser context, and shared throttle gates so aggregate request rate stays constant in N.