Threads

A reply is an ordinary message that names the message it answers. That id — the parent's id — is the thread's id, and the first reply is what brings the thread into existence. There is no create call, and there is no such thing as an empty thread.

import { Portal } from "@portalsdk/core";

interface ChatMessage {
  text: string;
}

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const room = portal.channel<ChatMessage>("room-1");
room.acquire();

async function reply(parentMessageId: string) {
  await room.send({
    content: { text: "replying to that" },
    threadParentId: parentMessageId,
  });
}

Replies ride the channel like everything else: they arrive on the same socket, in the same stream, and they are part of room.messages. What narrows is notification — only the thread's participants (whoever has posted in it, plus anyone mentioned) get an inbox entry for a reply. Render a flat timeline by ignoring threadParentId, or filter it out to keep replies off the main transcript.

A lens over one thread

channel.thread(id) gives you a view narrowed to a single thread. It owns no connection of its own — the channel handle's refcount still governs the socket — and the same id always returns the same object:

import { Portal } from "@portalsdk/core";

interface ChatMessage {
  text: string;
}

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const room = portal.channel<ChatMessage>("room-1");
room.acquire();

const thread = room.thread("msg_the_parent_id");

thread.messages; // replies in THIS thread only, oldest first
thread.hasPrevious;
thread.isLoadingPrevious;

async function open() {
  // The first subscription lazily fetches the thread's latest page.
  const unsubscribe = thread.subscribe(() => console.log(thread.messages.length));

  await thread.send({ content: { text: "no threadParentId needed here" } });
  await thread.loadPrevious(); // older replies in this thread only

  unsubscribe();
}

thread.send() supplies the threadParentId for you, so it takes the same input as channel.send() minus that field. loadPrevious() pages the thread by its own dense sequence — thread positions, not channel positions — and resolves false once it reaches the thread's first reply.

Events are narrowed where narrowing makes sense: message, mention and retract fire for this thread's replies only, while presence, activity and status are the channel's own, because that's what they describe.

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const room = portal.channel("room-1");
room.acquire();

const thread = room.thread("msg_the_parent_id");
const off = thread.on("message", (msg) => console.log("reply:", msg.id));
off();

Nesting

The message you reply to may itself be a reply, so threads nest. The depth cap is 8 levels; a reply that would sit deeper is refused before it reaches your middleware:

import { Portal, BlockedError } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const room = portal.channel("room-1");
room.acquire();

function showToUser(_message: string) {}

async function replyDeep(parentMessageId: string) {
  try {
    await room.send({ content: { text: "…" }, threadParentId: parentMessageId });
  } catch (err) {
    if (err instanceof BlockedError && err.reason === "thread_depth_exceeded") {
      showToUser("This conversation is nested as deep as it goes.");
    } else {
      throw err;
    }
  }
}

A threadParentId the channel could not have minted yet is refused the same way with reason: "thread_parent_unknown". An id whose message has simply aged out of hot storage is not refused — a thread must never become un-repliable because its root got old.

Nothing bubbles. A reply in a sub-thread never touches its parent thread's unread count, or the channel's. If you want "14 new in this branch", you compute it — the pointers below are there so you can.

Which threads exist

channel.threads() reads the channel's thread registry: every thread, whether or not you participate in it. This is enumeration — for a tree walk, a backend, an agent picking up work. Your own threads, with unread counts, are the inbox's job instead.

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const room = portal.channel("room-1");
room.acquire();

async function walk() {
  const roots = await room.threads(); // root threads (same as { parent: null })
  for (const node of roots.threads) {
    console.log(node.id, node.depth, node.messageCount, node.spawnedBy.id);
  }

  const children = await room.threads({ parent: "msg_the_parent_id" }); // direct children
  const branch = await room.threads({ root: "msg_the_root_id" }); // whole branch, any depth

  console.log(children.threads.length, branch.threads.length);

  // Paging: next() carries the server's cursor. hasMore alone is not the signal to stop —
  // an exhausted page comes back empty.
  let page = roots;
  while (page.hasMore) {
    page = await page.next();
  }
}

A ThreadNode:

Field Type Notes
id string The thread id — the id of the message whose first reply created it.
parentThreadId string | undefined The enclosing thread. Absent on a root thread.
rootThreadId string The top-level ancestor. Equals id on a root thread.
depth number Nesting level. A root thread is depth 1, and the cap is 8.
spawnedBy { id: string } Who sent the message the thread hangs off.
messageCount number Replies in the thread.
createdAt number Epoch milliseconds.

Nodes come back newest-spawned first. depth is 1-based, matching the wire and the HTTP API — don't renormalize it client-side.

Threads in the inbox

A thread you participate in gets its own inbox entry, sitting beside its channel's entry rather than inside it: same id, its own latest, unread, read position and muted. Identity is the pair (id, threadId):

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

const channelEntry = inbox.channels.get("room-1"); // the channel's own entry, never a thread's
const threadEntry = inbox.channels.get("room-1", "msg_the_parent_id");

threadEntry?.unread;
threadEntry?.parentThreadId; // the enclosing thread, on a nested thread
threadEntry?.rootThreadId; // the top of the branch
threadEntry?.markAsRead(); // this thread only — never its parent, never its children
threadEntry?.mute();

console.log(channelEntry?.unread);

parentThreadId / rootThreadId are rendering pointers: nest the sidebar, sort within a parent, pick the sub-thread with the newest activity. They imply no hierarchy in the protocol — no entry's unread ever reflects another's. See Inbox.

What threads don't change

  • Read position doesn't cascade. Reading a thread reads that thread. Not its parent, not its children, not the channel.
  • Participation doesn't inherit. Posting in a sub-thread joins that sub-thread. You are not thereby in its parent.
  • Presence and activity stay channel-wide. There is no per-thread typing indicator: sendTyping() describes the room. If you need typing scoped to a thread, send it yourself on the ephemeral lane (see Channels).

If you're using React, useThread wraps the lens; the registry is a promise rather than a store, so call threads() on the client you handed PortalProvider.