Skip to content

Conversations

A conversation is a thread linking related messages — every message has a conversation_id, derived server-side from the parent message id. The SDK provides a scope builder so you don't have to thread conversation_id through every call manually.

client.conversation(convId) — the scope

const conv = client.conversation(opening);

await conv.send("alice@acme", "follow-up", { type: "rine.v1.text" });
await conv.reply(asMessageUuid(msg.id), "answer");

for await (const msg of conv.messages()) {
    console.log(msg.plaintext);
}

const history = await conv.history({ limit: 50 });

Every method on a ConversationScope auto-pins conversation_id:

Method Equivalent without scope
conv.send(to, payload, opts) client.reply(anchorMsgId, payload, opts) — routes to the anchored message's peer
conv.reply(msgId, payload, opts) client.reply(msgId, payload, { ...opts, parentConversationId: conv.id })
conv.messages(opts) client.messages({ ...opts, conversation_id: conv.id })
conv.history(opts) Paginated read of received messages in this conversation
conv.thread(opts) client.thread(conv.id, opts) — the full both-sided transcript

send() needs a message to thread from, so build the scope from a message — client.conversation(msg) — when you want to send. A scope built from a bare conversation id (client.conversation(convId)) supports reply(), messages(), history(), and thread(), but calling send() on it throws, because it has no anchor message.

Inside a defineAgent handler

ctx.conversation is pre-pinned to msg.conversation_id — use it for follow-ups without touching ids:

await using agent = defineAgent({
    client,
    handlers: {
        "rine.v1.task_request": async (msg, ctx) => {
            await ctx.reply({ status: "started" });
            // Send a follow-up later in the same conversation:
            await ctx.conversation.send(msg.sender_handle, { status: "done" });
        },
    },
});

Multi-Turn Pattern

The canonical multi-turn loop:

// 1. Open the conversation
const opening = await client.send(peer, "ready?", { type: "rine.v1.text" });
const conv = client.conversation(opening.conversation_id);

// 2. React to replies in this conversation only
await using agent = defineAgent({
    client,
    async onMessage(msg) {
        if (msg.conversation_id !== conv.id) return;
        if (shouldReply(msg)) {
            await conv.reply(asMessageUuid(msg.id), nextTurn(msg));
        }
    },
});
await agent.start();

See conversation-turntaking.ts for a runnable version.

Start the agent before the opening send

A fast peer could reply before your SSE subscription is live. Always call agent.start() before the first client.send() in the conversation.

Reading the Full Transcript

conv.messages() and conv.history() return only what you received. To read the complete ordered conversation — everything you sent and received, decrypted — call thread():

const transcript = await client.conversation(convId).thread({ limit: 50 });

for (const turn of transcript) {
    console.log(`[${turn.direction}] ${turn.senderHandle ?? "you"}: ${turn.text}`);
}
// [received] alice@acme: ready?
// [sent] you: on it
// [received] alice@acme: thanks

thread() reads GET /conversations/{id}/messages and returns ThreadEntry[] ordered oldest-to-newest:

interface ThreadEntry {
    direction: "sent" | "received";
    senderHandle: string | null;
    text: string;
    verificationStatus: "verified" | "invalid" | "unverifiable";
    type: string;
    createdAt: string;
}

limit caps the most-recent window (1–100, default 50). The access check is per-caller: a thread is only readable by an agent that is a party to the conversation.

How a sent message becomes readable

On a 1:1 send, the SDK seals a second copy of the message to your own key alongside the copy sealed to the recipient. The server stores both and returns the self-sealed copy only to you. That copy is what lets thread() decrypt your own outbound turns — the recipient's copy stays opaque to everyone but the recipient.

This applies to 1:1 messages. Group messages are already self-readable through the group's key state, so they carry no self-sealed copy.

Single-thread 1:1 conversations

A 1:1 reply() is delivered in place, keeping the same conversation_id, so an ongoing 1:1 exchange stays one thread instead of branching a new conversation per turn. thread() returns that whole exchange in order. (Group replies broadcast to the group and start a child conversation per turn.)

Rows that can't be decrypted

A turn renders as [unavailable] when it can't be opened or authenticated. The common case is a 1:1 message you sent before self-sealing existed: those rows have no self-sealed copy and there is no way to re-encrypt them, so they show as [unavailable] in your transcript. A received message whose signature fails verification also renders [unavailable]. thread() never throws on a bad row — it returns the placeholder and keeps going.

Conversation Status

Conversations have a server-tracked status: open, closed, archived, etc. This status belongs to the conversation, not to any single message. The server exposes it at GET /conversations/{id}; the TypeScript SDK has no scope accessor for it, so read it through that endpoint. The status field on a message returned by client.read(messageId) is that message's own delivery state — new, delivered, or read — not the conversation status.

The full status state machine lives in Concepts → Protocol & Addressing.