@streamcore/js-sdk

August 24, 2026 · View on GitHub

English | 简体中文

Framework-agnostic TypeScript SDK for connecting to a StreamCoreAI server via WebRTC + WHIP.

Installation

npm install @streamcore/js-sdk

Quick Start

import { StreamCoreAIClient } from "@streamcore/js-sdk";

const agent = new StreamCoreAIClient(
  { whipUrl: "http://localhost:8080/whip" },
  {
    onStatusChange: (status) => console.log("Status:", status),
    onTranscript: (entry, all) => console.log("Transcript:", entry),
    onAudioLevel: (level) => console.log("Audio level:", level),
    onAgentStateChange: (state) => console.log("Agent:", state),
    onError: (err) => console.error("Error:", err),
  }
);

// Connect (requests microphone permission, establishes WebRTC session)
await agent.connect();

// Mute / unmute
agent.toggleMute();
console.log("Muted:", agent.isMuted);

// Disconnect
agent.disconnect();

API

new StreamCoreAIClient(config?, events?)

Creates a new client instance.

StreamCoreAIConfig

PropertyTypeDefaultDescription
whipUrlstring"http://localhost:8080/whip"WHIP signaling endpoint URL
tokenstringPre-fetched JWT, sent as Authorization: Bearer on the WHIP request
tokenUrlstringEndpoint the SDK POSTs to for a short-lived token during connect()
apiKeystringSent as Authorization: Bearer when calling tokenUrl
iceServersRTCIceServer[][{ urls: "stun:stun.l.google.com:19302" }]ICE server configuration
audioConstraintsMediaTrackConstraints{ echoCancellation: true, noiseSuppression: true, autoGainControl: true, voiceIsolation: true, channelCount: 1 }Microphone constraints
reconnectAttemptsnumber3ICE restarts to attempt while the connection is disconnected. 0 disables the phase. See Reconnection
reconnectDelayMsnumber2000Wait before the first ICE restart, doubling each retry
resumeAttemptsnumber2Resume redials to attempt once the connection has failed. 0 disables the phase
resumeDelayMsnumber1000Wait before the first redial, doubling each retry

StreamCoreAIEvents

EventSignatureDescription
onStatusChange(status: ConnectionStatus) => voidFired when connection status changes
onTranscript(entry: TranscriptEntry, all: TranscriptEntry[]) => voidFired on new or updated transcript
onAudioLevel(level: number) => voidFired every animation frame with mic level (0–1)
onAgentStateChange(state: AgentState) => voidFired when the agent starts listening, thinking, or speaking
onError(error: Error) => voidFired on connection or server errors
onTiming(event: TimingEvent) => voidFired with server-side pipeline timing info
onReconnect(info: ReconnectEvent) => voidFired per recovery attempt and once on the outcome. Watch for recovered-without-history
onDataChannelMessage(message: DataChannelMessage) => voidFired for every raw data-channel message, before the typed callbacks
onData(topic: string, payload: Uint8Array) => voidFire-and-forget server data packet, payload already base64-decoded (movement.command carries locomotion commands)

Instance Methods

MethodReturnsDescription
connect()Promise<void>Request mic, establish WebRTC + WHIP session
disconnect()voidTear down connection, stop mic, free resources
toggleMute()voidToggle microphone mute
on(event, fn)voidRegister an event listener after construction

Instance Properties (read-only)

PropertyTypeDescription
statusConnectionStatus"idle" | "connecting" | "connected" | "reconnecting" | "error" | "disconnected"
transcriptTranscriptEntry[]Full conversation history
audioLevelnumberCurrent mic audio level (0–1)
isMutedbooleanWhether the mic is muted
localStreamMediaStream | nullLocal microphone stream (after connect)
remoteStreamMediaStream | nullRemote agent audio stream (after connect)

Types

type ConnectionStatus =
  | "idle"
  | "connecting"
  | "connected"
  // The transport dropped and recovery is in flight. Not terminal — the
  // session, and with it the conversation, is still alive on the server.
  | "reconnecting"
  | "error"
  | "disconnected";

type AgentState = "listening" | "thinking" | "speaking";

// Which mechanism a recovery attempt used. See Reconnection below.
type ReconnectPhase = "ice-restart" | "resume";

interface ReconnectEvent {
  attempt: number;      // 1-based, counted within the phase
  maxAttempts: number;
  phase: ReconnectPhase;
  // "recovered-without-history" means the call works but the agent has
  // forgotten the conversation — worth surfacing, not just logging.
  outcome: "attempting" | "recovered" | "recovered-without-history" | "failed";
  error?: Error;
}

interface TranscriptEntry {
  role: "user" | "assistant";
  text: string;
  partial?: boolean;
}

interface TimingEvent {
  stage: string;
  ms: number;
}

type DataChannelMessage =
  | { type: "transcript"; text: string; final: boolean }
  | { type: "response"; text: string }
  | { type: "error"; message: string }
  | { type: "timing"; stage: string; ms: number }
  | { type: "state"; state: AgentState }
  | { type: "connection"; state: "reconnecting" | "connected" };

Reconnection

A network change mid-call — Wi-Fi to cellular, a VPN toggle, a laptop moving networks, a phone asleep in a pocket — kills the transport without ending the call. The SDK recovers it automatically and the conversation survives: the agent still knows who you are and does not replay its greeting.

Recovery runs as a ladder of two phases, because they are good at different things:

PhaseWhenCost
ICE restartWhile the connection is disconnectedInvisible. Same peer connection, same DTLS, same tracks — just new candidates.
Resume redialOnce the connection has failedA full renegotiation and a moment of silence, but the server reattaches you to the same conversation.

ICE restart is tried first because it costs nothing. It stops being possible the moment the connection reaches failed — the server has closed its peer by then — which is exactly where a backgrounded tab or a laptop that slept lands. That is what the resume phase is for.

Status goes connectedreconnectingconnected throughout. Subscribe to onReconnect for per-attempt detail:

const agent = new StreamCoreAIClient(
  {
    whipUrl,
    reconnectAttempts: 3,   // ICE restarts,  2s → 4s → 8s
    reconnectDelayMs: 2000,
    resumeAttempts: 2,      // then redials,  1s → 2s
    resumeDelayMs: 1000,
  },
  {
    onStatusChange: (s) => setBanner(s === "reconnecting" ? "Reconnecting…" : ""),
    onReconnect: ({ phase, attempt, maxAttempts, outcome }) => {
      console.log(`${phase} ${attempt}/${maxAttempts}: ${outcome}`);
      if (outcome === "recovered-without-history") {
        toast("Reconnected, but I've lost track of our conversation.");
      }
    },
  }
);

Handle recovered-without-history. It means the call is working but the server could not resume the session — usually because the client was away longer than session_grace_ms — so the agent has no memory of anything said before. Everything still functions, which is precisely why users will not notice until the agent asks a question it was already answered. Say so in the UI.

Two details worth knowing:

  • The first ICE restart is deliberately delayed (reconnectDelayMs, default 2s). Most drops are brief packet loss that ICE repairs unaided, and patching immediately would spend an attempt on a connection that was about to recover by itself.
  • Both phases share one deadline. disconnected becomes failed after roughly 25 seconds, and the server then holds the conversation for session_grace_ms (30s by default). The defaults fit comfortably; if you raise reconnectAttempts, you are spending budget the resume phase would otherwise have.

If every phase fails, or the session is gone (404/409), the status becomes disconnected and recovery is up to your app: call connect() again for a fresh conversation. Set reconnectAttempts: 0 to skip ICE restart, resumeAttempts: 0 to skip redials, or both to handle drops yourself.

The microphone stream is reused across a redial, so no second permission prompt and no device re-acquisition.

Low-level helpers

The package also exports the raw WHIP calls, for building a custom client:

import {
  whipOffer,
  whipDelete,
  whipRestartIce,
  iceFragmentFromSdp,
  applyIceFragment,
} from "@streamcore/js-sdk";

const { answerSDP, sessionURL, etag } = await whipOffer(whipUrl, offerSDP, token?);

// ICE restart (RFC 9725 §4.4): PATCH a fragment, fold the reply back in.
const { fragment, etag: newEtag } = await whipRestartIce(
  sessionURL,
  iceFragmentFromSdp(pc.localDescription.sdp),
  etag,
  token
);
const answerSdp = applyIceFragment(pc.currentRemoteDescription.sdp, fragment);

await whipDelete(sessionURL, token?);

Most applications should use StreamCoreAIClient instead — it handles peer setup, ICE gathering, the events DataChannel, audio metering, and teardown.

Authentication

When the server sets jwt_secret, /whip requires a bearer token. Do not put provider API keys in the browser — they belong in the server's config.toml. Instead, have the client fetch a short-lived token:

const agent = new StreamCoreAIClient({
  whipUrl: "https://agent.example.com/whip",
  tokenUrl: "https://api.example.com/agent-token",
  apiKey: process.env.NEXT_PUBLIC_APP_KEY,
});

During connect(), the SDK POSTs to tokenUrl (sending apiKey as a bearer token if provided) and expects { "token": "..." } back. If both token and tokenUrl are set, tokenUrl wins. The token is cached so disconnect() can authenticate the WHIP DELETE.

Caller identity

If you use an external agent and want it to remember a user across separate calls, it needs to know who is calling. This SDK has no resourceId option, and that is deliberate — a browser asserting its own identity is a claim anyone can edit in devtools.

Set it on the server that backs your tokenUrl instead. That endpoint already knows which user is signed in, and it holds the API key that StreamCore's /token requires:

// your backend, at POST /agent-token
const res = await fetch("https://agent.example.com/token", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${process.env.STREAMCORE_API_KEY}`,
  },
  body: JSON.stringify({ resource_id: session.user.id }),
});
return Response.json(await res.json()); // { token } — hand it to the browser

StreamCore signs the identity into the token, then forwards it to your agent as resource_id on every turn. See Protocol → Caller identity and Bring your own agent.

Building from Source

cd typescript-sdk
npm install
npm run build

The compiled output is written to dist/.

Usage with Bundlers

The SDK ships as ES modules with TypeScript declarations. It works out of the box with Vite, webpack, Next.js, esbuild, and other modern bundlers.

License

Apache2.0