Client setup

@portalsdk/core is the framework-agnostic Portal client: it manages realtime channels and a per-user inbox over a WebSocket, and exposes reactive, useSyncExternalStore-shaped stores you can bind to any UI. It's client-only — the published package ships a "use client" directive and isn't meant for server components (see SSR & Next.js).

npm install @portalsdk/core

Construct a client

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

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

Construction is synchronous and passive: it stores your config and builds empty registries, with no network call, no token fetch, and no validation. That makes it safe to construct once at module scope, before any user exists — the first network activity happens later, on the first channel acquire() or the first inbox subscription.

PortalConfig accepts:

Field Type Notes
apiKey string Publishable key identifying the app. Safe in the bundle.
token string | (() => Promise<string>) Identifies the user. Optional — omit it for anonymous mode. A callback is re-invoked on connect, reconnect, and expiry (recommended); a plain string is used as-is.
apiUrl string Base URL override. Production hosts are baked in; primarily for development and testing.
realtimeUrl string Base URL override for the realtime socket. Same caveat as apiUrl.

See Tokens & auth for everything token affects, including anonymous mode and setToken.

Get a channel handle

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.subscribe(() => {
  console.log(room.getSnapshot().messages.length, "messages");
});

await room.send({ content: { text: "hello" } });

room.release();

portal.channel(id, options?) is a registry lookup-or-create: calling it again with the same id returns the same handle, so many views of a room share one socket. Options (history, metadata) apply only at first creation — a later call with different options returns the existing handle and ignores them (a dev-mode warning fires; this is silent in production).

A handle does nothing until acquire()-ed; call release() when you're done with it (or use using for automatic cleanup, since ChannelHandle implements Symbol.dispose):

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

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

function readLobbyOnce() {
  using room = portal.channel("lobby");
  room.acquire();
  return room.getSnapshot().messages.length;
}

If you're using React, useChannel manages acquire()/release() for you via mount/unmount — most apps never call these directly.

The inbox

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

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

const inbox = portal.inbox();
console.log(inbox.getSnapshot().counter);

portal.inbox() is a lazy singleton: it's created, and its connection opened, on first call — never at construction. See Inbox for the full surface.

Switching identity

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

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

async function login(): Promise<string> {
  const res = await fetch("/api/portal-token", { credentials: "include" });
  const { token } = (await res.json()) as { token: string };
  return token;
}

async function onSignIn() {
  portal.setToken(login);
}

function onSignOut() {
  portal.setToken(undefined);
}

setToken replaces the token source for every live channel and the inbox — see Tokens & auth.

Package size

@portalsdk/core is ~14 kB min+gzip, with two runtime dependencies: @portalsdk/wire-protocol (the wire-format types the client sends and parses) and partysocket. You won't reach for either directly in normal use.