Channels

A channel is a room: a seq-ordered stream of messages, plus presence, activity, and read state, shared by everyone connected to the same channel id. Get a handle with portal.channel(id, options?) — the same id always returns the same handle, so many views of a room share one socket.

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", { history: 50 });
room.acquire();

ChannelOptions:

Field Type Notes
history number | "none" Initial backfill on connect. Default 50. "none" starts live-only.
metadata Record<string, unknown> Initial presence metadata for this session — see Presence.

Sending messages

send() takes either a persistent message (stored, appears in history, gets a seq) or an ephemeral one (fire-and-forget — no persistence, no seq, never appears in history):

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 examples() {
  // Persistent — stored, ordered, shows up in history and for late joiners.
  const ack = await room.send({ content: { text: "hello" } });
  console.log(ack.id, ack.timestamp);

  // Directed to one member only, and mentioning another.
  await room.send({
    content: { text: "@alice you're up" },
    to: "user_alice",
    mentions: [{ userId: "user_alice" }],
  });

  // Ephemeral — never stored, no seq, no history. Good for cursors and transient signals.
  await room.send({ ephemeral: true, content: { text: "typing-like signal" } });
}

send() rejects with a PortalError subclass when the platform or your own portal.config.ts middleware refuses the message — see Errors.

Reading messages and history

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();

room.messages; // readonly Message<M>[], seq-ordered, mutations (retractions) applied in place

room.hasPrevious; // true until loadPrevious() reaches the beginning of the channel
room.isLoadingPrevious;

async function loadOlder() {
  await room.loadPrevious(); // fetch an older page; returns false once exhausted
}

room.messages starts with the initial backfill from history (or empty, under "none") and grows as new messages arrive; loadPrevious() pages further back.

You can also take a filtered, live lens over the same store without opening a second socket:

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 mentionsOnly = room.view({ type: { eq: "mention" } });
mentionsOnly.messages;
mentionsOnly.subscribe(() => console.log(mentionsOnly.getSnapshot().messages.length));

Read state

Every channel tracks a watermark — how far this device has read, independent of the inbox's own per-channel read state (see Inbox for that distinction):

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

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

room.unread; // number
room.markAsRead(); // advances the channel watermark

Presence

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

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

room.presence; // DetailedPresence | AggregatePresence | undefined

Small/standard channels get DetailedPresence ({ kind: "detailed", participants, count }); larger ones get AggregatePresence ({ kind: "aggregate", count, recent }) with only join/leave deltas, not a full roster. Either way, check presence.kind to narrow:

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

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

const p = room.presence;
if (p?.kind === "detailed") {
  console.log(p.participants.map((u) => u.id));
} else if (p?.kind === "aggregate") {
  console.log(p.count, p.recent);
}

Replace your own presence metadata mid-session with setMetadata — this is presentation-only, never used for authorization:

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

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

room.setMetadata({ cursor: { x: 120, y: 48 } });

Activity (typing indicators, and beyond)

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

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

room.activity; // readonly ActivityEntry[] — other users' current activity, never your own
room.sendActivity("typing"); // any string kind; SDK throttles automatically

// Sugar for the common case:
room.typing; // readonly string[] of user ids currently typing
room.sendTyping(); // sendActivity("typing")

sendActivity/sendTyping are a no-op on broadcast channels.

Members and channel info

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

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

async function inspect() {
  const members = await room.members(); // MemberRow[] — fetched directory, not live state
  console.log(members);
}

room.info; // ChannelInfo | undefined — id, mode ("standard" | "broadcast"), name?, meta?
room.me; // { id, anon, claims } | undefined — your own verified identity, once connected

Status and events

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();

room.status; // "idle" | "connecting" | "ready" | "reconnecting" | "degraded" | "degraded-http" | "blocked"

const unsubscribe = room.on("message", (msg) => console.log(msg.content));
room.on("mention", (msg) => console.log("mentioned:", msg));
room.on("retract", (messageId) => console.log("retracted:", messageId));
room.on("presence", (p) => console.log(p));
room.on("activity", (entries) => console.log(entries));
room.on("status", (status, error) => console.log(status, error));

unsubscribe();

"degraded-http" means the socket is down and reconnecting, but HTTP publish still works — you can still send, incoming messages may lag until reconnect gap-fill catches up. "blocked" is terminal (bad key, banned, not a member, at capacity) — see Errors.

Store contract

ChannelHandle (and the filtered ChannelView from view()) exposes a useSyncExternalStore-shaped contract directly, so you can bind it to any UI layer, not just React:

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

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

function render(_snapshot: ChannelSnapshot) {}

room.subscribe(() => render(room.getSnapshot()));

If you're using React, useChannel wraps all of this for you.