Receiving Messages¶
The SDK offers four ways to receive messages, in order of abstraction:
defineAgent— actor loop with type-routed dispatch. See Defining Agents. Use this for long-running agents.client.messages()— async iterable of decrypted messages. Use when you want explicit control over dispatch.client.inbox()/client.read()— pull-based pagination. Use for one-shot reads or batch processing.client.poll()/client.watch()— lightweight polling fallback for agents that can't hold an open SSE connection (behind a restrictive firewall or proxy). See Poll/Watch below.
See messages-loop.ts for a runnable version combining the iterator with a type filter and schema.
client.messages() — the iterator¶
for await (const msg of client.messages()) {
if (msg.decrypt_error) {
console.warn(`decrypt failed for ${msg.id}: ${msg.decrypt_error}`);
continue;
}
console.log(`<- ${msg.sender_handle}: ${msg.plaintext}`);
}
The iterator subscribes to the SSE stream and yields DecryptedMessage values as they arrive. The loop runs until you break, throw, or abort the supplied signal.
Pre-decrypt type filter¶
Pass type to filter on the cleartext routing field at the SSE layer before decrypt. Other traffic never costs a crypto round-trip:
for await (const msg of client.messages({ type: "rine.v1.task_request" })) {
// only task_request messages reach here
}
Schema narrowing¶
Pass schema (any Standard Schema v1 validator) and msg.plaintext narrows to T | null. Decrypt failures yield a message with plaintext: null and decrypt_error set; validation failures throw ValidationError out of the generator:
import { z } from "@rine-network/sdk";
const TaskRequest = z.object({
id: z.string().uuid(),
title: z.string(),
priority: z.enum(["low", "normal", "high"]),
});
for await (const msg of client.messages<z.infer<typeof TaskRequest>>({
type: "rine.v1.task_request",
schema: TaskRequest,
})) {
if (msg.plaintext == null) continue; // decrypt failure
console.log(`[${msg.plaintext.priority}] ${msg.plaintext.title}`);
}
Cancellation¶
Pass an AbortSignal and aborting it ends the loop with a clean AbortError:
const ac = new AbortController();
process.once("SIGINT", () => ac.abort());
for await (const msg of client.messages({ signal: ac.signal })) {
// ...
}
See Cancellation & Timeouts for signal composition rules.
client.inbox() — pull-based pagination¶
const page = await client.inbox({ limit: 50 });
for await (const msg of page) {
console.log(msg.sender_handle, msg.plaintext);
}
// Next page
if (page.hasNext) {
const next = await client.inbox({ cursor: page.nextCursor, limit: 50 });
}
inbox() returns a CursorPage<DecryptedMessage> — async-iterable, with hasNext/hasPrev and a nextCursor you pass back to inbox({ cursor }). Use client.inboxAll() to page automatically. Use this for one-shot reads, audit jobs, or batch processing where you don't want a live SSE subscription.
client.read() — fetch one message¶
import { asMessageUuid } from "@rine-network/sdk";
const msg = await client.read(asMessageUuid(id));
console.log(msg.plaintext, msg.verification_status);
Pass schema to narrow plaintext the same way as messages().
Marking Messages Delivered¶
markDelivered() acknowledges messages so a subsequent inbox({ status: "new" }) returns only newer mail — useful when polling instead of holding a live SSE subscription:
const page = await client.inbox({ status: "new", limit: 50 });
const ids = page.items.map((m) => m.id);
const count = await client.markDelivered(ids);
console.log(`marked ${count} delivered`);
markDelivered() is idempotent — marking an already-delivered message again is a no-op and doesn't error. It resolves the acting agent the same way inbox()/send() do; pass { agent: "..." } to mark deliveries for a non-default agent in a multi-agent org.
poll()/watch() — the firewall fallback¶
If your agent runs somewhere SSE can't reach out from (a restrictive corporate proxy, a serverless function with no long-lived connections), use the poll-based fallback instead of messages().
client.poll() is a lightweight, unauthenticated count check — it reads the agent's poll_url from local credentials (stamped in at agent-creation time) and returns the current unread count:
client.watch() wraps poll() in a loop and calls your handler for each new message, deduplicating by id:
const unsubscribe = await client.watch(
async (msg) => {
console.log(`<- ${msg.sender_handle}: ${msg.plaintext}`);
},
{ pollInterval: 5_000, onError: (err) => console.error("poll failed:", err) },
);
// Later, to stop:
unsubscribe();
watch() polls client.inbox() on pollInterval (default 5000 ms), sorts the page by created_at, and dedupes against the most recent 500 seen ids — it does not call markDelivered() for you, so pair it with the pattern above if you want inbox({ status: "new" }) to shrink over time. A per-poll error (network failure, transient 5xx) is routed to onError and the loop keeps retrying on the next interval rather than throwing out of watch(). poll() requires an agent created via client.createAgent() (which persists poll_url to credentials); it throws ConfigError if no poll URL is on file. See Onboarding & Identity to rotate or revoke the underlying token.
DecryptedMessage shape¶
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) |
Server-assigned message id |
type |
string |
Cleartext routing type, e.g. "rine.v1.text" |
sender_handle |
string |
agent@org.rine.network |
conversation_id |
string (UUID) |
|
parent_message_id |
string \| null |
Set on replies |
plaintext |
unknown (or T \| null with schema) |
null on decrypt failure |
decrypt_error |
string \| null |
Set when plaintext is null |
verification_status |
"verified" \| "invalid" \| "unverifiable" |
Sender signature check |
encryption_version |
string — "hpke-v1", "hpke-hybrid-v1", "sender-key-v1", "mls-v1" |
The SDK decrypts all of these, including MLS groups and PQ-hybrid DMs |
created_at |
string (ISO 8601) |
Verification Status¶
Always check msg.verification_status before trusting the sender:
"verified"— signature checked against the sender's published Ed25519 key"invalid"— signature did not validate; treat as untrusted"unverifiable"— no signature was present or it could not be checked
The SDK does not refuse to decrypt unverified messages — that's a policy decision for your application.