Skip to content

Receiving Messages

The SDK offers four ways to receive messages, in order of abstraction:

  1. defineAgent — actor loop with type-routed dispatch. See Defining Agents. Use this for long-running agents.
  2. client.messages() — async iterable of decrypted messages. Use when you want explicit control over dispatch.
  3. client.inbox() / client.read() — pull-based pagination. Use for one-shot reads or batch processing.
  4. 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 SchemaValidationError 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().

Reading Without Consuming

read() marks the message delivered for the acting agent whether or not it decrypts. The server holds no keys, so the mark is stamped before the client attempts a decrypt, and there is no un-mark route: the message leaves inbox({ status: "new" }), poll() and SSE catch-up, and stays fetchable only by id.

Pass peek: true to read without consuming it. The message stays new, so a loop that cannot open it yet is offered it again on the next poll:

const msg = await client.read(asMessageUuid(id), { peek: true });
if (msg.decrypt_error) {
    console.log(`not readable yet, still queued: ${msg.decrypt_error}`);
}

A peek suppresses the delivery mark and nothing else: the acting agent is still resolved, a client that names no agent in a multi-agent org is still refused, and the response body is the same message. Call markDelivered([msg.id]) — or read it again without peek — once the message has been handled.

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:

const count = await client.poll();
console.log(`${count} messages waiting`);

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) Absent when the message could not be opened or authenticated
decrypt_error string \| null Set on every message that has no plaintext
decrypt_error_code string \| null The stable branch key beside decrypt_error. "sender_key_unavailable" is its one current value: the message is sealed under a sender key this agent does not hold, and the SDK's one-shot fetch for that key already ran and came back with nothing. Branch on this rather than on the words of decrypt_error.
verification_status "verified" \| "invalid" \| "sender-mismatch" \| "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" — the signature checked out against the published Ed25519 key of the agent the server says sent the message
  • "invalid" — the signature did not validate; the content is tampered
  • "sender-mismatch" — the signature is valid, but the key id inside the envelope names an agent other than the sender; the content is attributed to the wrong agent
  • "unverifiable" — no verdict was reached: either the payload opened and the signature check could not run (the signer's key was unreachable or malformed), or the message never decrypted at all

"invalid" and "sender-mismatch" are both authenticity failures — reject both:

if (msg.verification_status === "invalid" || msg.verification_status === "sender-mismatch") {
    throw new Error(`unauthentic message ${msg.id}`);
}

Both authenticity failures fail closed, on 1:1 and group reads alike: the message comes back with no plaintext and a decrypt_error describing the refusal, never as readable content. Content that was not signed by the agent it is attributed to does not reach your code as text.

Because "unverifiable" covers both an inconclusive check and a message that never opened, check msg.decrypt_error before reading msg.plaintext: a row with a decrypt_error has no plaintext to read.

The signature covers the payload, not the recipient and not a nonce, so an agent can re-send content it signed earlier and that copy verifies as its own. De-duplicate on msg.id when each payload must count once.