React quickstart

Three reusable hooks and one shared SDK init cover ~90% of what your app needs.

1. Install

npm install @kardolive/sdk mediasoup-client hls.js

Two of those are optional:

2. KardoliveProvider — one-line SDK init

Put this at the top of your React app. It exposes useKardolive() everywhere and wires automatic token refresh.

// your-app/apps/web/src/kardolive/KardoliveProvider.tsx
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Kardolive } from '@kardolive/sdk';

type Ctx = {
  /** Fetches a chat token from your backend. */
  mintChatToken: (channelId?: string) => Promise<{ token: string; chatUrl: string }>;
  /** Fetches a session token (live/conference/call). */
  mintSessionToken: (sessionId: string, role?: 'host' | 'guest') =>
    Promise<{ token: string; sfuUrl: string; chatUrl: string; role: string; perms: any }>;
};
const KardoliveCtx = createContext<Ctx | null>(null);

export function KardoliveProvider({ children }: { children: React.ReactNode }) {
  const value = useMemo<Ctx>(() => ({
    mintChatToken: async (channelId) => {
      const r = await fetch('/api/kardolive/chat-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ channelId }),
      });
      if (!r.ok) throw new Error('chat token mint failed');
      return r.json();
    },
    mintSessionToken: async (sessionId, role = 'guest') => {
      const r = await fetch('/api/kardolive/session-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId, role }),
      });
      if (!r.ok) throw new Error('session token mint failed');
      return r.json();
    },
  }), []);
  return <KardoliveCtx.Provider value={value}>{children}</KardoliveCtx.Provider>;
}

export function useKardolive() {
  const ctx = useContext(KardoliveCtx);
  if (!ctx) throw new Error('Wrap your app in <KardoliveProvider>');
  return ctx;
}

Wrap your root:

// your-app/apps/web/src/App.tsx
import { KardoliveProvider } from './kardolive/KardoliveProvider';

export default function App() {
  return (
    <KardoliveProvider>
      <Router>{/* …rest of your app… */}</Router>
    </KardoliveProvider>
  );
}

useChat(channelId) hook

Connects to a chat channel, exposes messages, send(), typing, and unread state. Auto-reconnects.

// your-app/apps/web/src/kardolive/useChat.ts
import { useEffect, useRef, useState } from 'react';
import { useKardolive } from './KardoliveProvider';

type Msg = { id: string; authorId: string; authorName: string; body: string; at: string; parentId?: string };

export function useChat(channelId: string) {
  const { mintChatToken } = useKardolive();
  const wsRef = useRef<WebSocket | null>(null);
  const [messages, setMessages] = useState<Msg[]>([]);
  const [typing, setTyping] = useState<string[]>([]);
  const [state, setState] = useState<'connecting' | 'open' | 'closed'>('connecting');

  useEffect(() => {
    let cancelled = false;
    let ws: WebSocket | null = null;

    async function connect() {
      const { token, chatUrl } = await mintChatToken(channelId);
      if (cancelled) return;
      ws = new WebSocket(`${chatUrl}/v1/chat/socket?room=${channelId}&token=${token}`);
      wsRef.current = ws;
      ws.onopen = () => setState('open');
      ws.onmessage = (e) => {
        const env = JSON.parse(e.data);
        if (env.type === 'chat') setMessages((m) => [...m, env.data]);
        if (env.type === 'typing') {
          setTyping((t) => env.data.on
            ? Array.from(new Set([...t, env.data.name]))
            : t.filter((n) => n !== env.data.name));
        }
      };
      ws.onclose = () => {
        setState('closed');
        if (!cancelled) setTimeout(connect, 2000); // simple reconnect
      };
    }
    connect();
    return () => { cancelled = true; ws?.close(); };
  }, [channelId, mintChatToken]);

  function send(body: string) {
    wsRef.current?.send(JSON.stringify({ type: 'chat.send', data: { body } }));
  }
  function sendTyping(on: boolean) {
    wsRef.current?.send(JSON.stringify({ type: on ? 'typing.start' : 'typing.stop', data: {} }));
  }

  return { messages, typing, state, send, sendTyping };
}

Use it:

// your-app/apps/web/src/screens/ContactDetail.tsx
function ContactChatTab({ channelId }: { channelId: string }) {
  const { messages, typing, send } = useChat(channelId);
  const [draft, setDraft] = useState('');

  return (
    <div>
      <div className="messages">
        {messages.map((m) => (
          <div key={m.id}><b>{m.authorName}:</b> {m.body}</div>
        ))}
        {typing.length > 0 && <div className="muted">{typing.join(', ')} typing…</div>}
      </div>
      <input
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => { if (e.key === 'Enter' && draft) { send(draft); setDraft(''); }}}
      />
    </div>
  );
}

useLiveStream(streamKey) hook

Just watches an HLS stream. For publishing, see Live stream →.

// your-app/apps/web/src/kardolive/useLiveStream.ts
import { useEffect, useRef, useState } from 'react';
import Hls from 'hls.js';

export function useLiveStream(streamKey: string) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [state, setState] = useState<'loading' | 'playing' | 'offline' | 'error'>('loading');

  useEffect(() => {
    const v = videoRef.current;
    if (!v) return;
    const url = `https://kardocloud.com/live/${streamKey}.m3u8`;

    // Safari + iOS: native HLS
    if (v.canPlayType('application/vnd.apple.mpegurl')) {
      v.src = url;
      v.addEventListener('playing', () => setState('playing'));
      v.addEventListener('error', () => setState('offline'));
      v.play().catch(() => {});
      return;
    }

    // Other browsers: hls.js
    if (!Hls.isSupported()) { setState('error'); return; }
    const hls = new Hls({
      lowLatencyMode: true, manifestLoadingMaxRetry: 30, manifestLoadingRetryDelay: 1500,
    });
    hls.loadSource(url);
    hls.attachMedia(v);
    hls.on(Hls.Events.MANIFEST_PARSED, () => v.play().catch(() => {}));
    hls.on(Hls.Events.ERROR, (_e, data) => {
      if (data.fatal) setState(data.type === 'networkError' ? 'offline' : 'error');
    });
    v.addEventListener('playing', () => setState('playing'));
    return () => hls.destroy();
  }, [streamKey]);

  return { videoRef, state };
}

Use:

function StreamViewer({ streamKey }: { streamKey: string }) {
  const { videoRef, state } = useLiveStream(streamKey);
  return (
    <div>
      <video ref={videoRef} controls muted playsInline style={{ width: '100%' }} />
      <div>Status: {state}</div>
    </div>
  );
}

useSession(sessionId) hook

The big one — handles conferences, calls, interactive livestreams. Uses mediasoup-client under the hood.

// your-app/apps/web/src/kardolive/useSession.ts
import { useEffect, useRef, useState } from 'react';
import { Device } from 'mediasoup-client';
import { useKardolive } from './KardoliveProvider';

type Remote = { peerId: string; stream: MediaStream };

export function useSession(sessionId: string, opts: { audio?: boolean; video?: boolean } = {}) {
  const { mintSessionToken } = useKardolive();
  const localRef  = useRef<HTMLVideoElement>(null);
  const [remotes, setRemotes] = useState<Remote[]>([]);
  const [state, setState] = useState<'connecting' | 'connected' | 'reconnecting' | 'closed'>('connecting');
  const sessionRef = useRef<any>(null);

  useEffect(() => {
    let cancelled = false;

    async function connect() {
      const { token, sfuUrl } = await mintSessionToken(sessionId);
      if (cancelled) return;
      const ws = new WebSocket(`${sfuUrl}/?token=${token}&room=${sessionId}`);
      const device = new Device();
      const pending = new Map();
      let nextId = 1;
      const rpc = (method: string, params: any = {}) => new Promise<any>((resolve, reject) => {
        const id = String(nextId++);
        pending.set(id, { resolve, reject });
        ws.send(JSON.stringify({ id, type: 'request', method, params }));
        setTimeout(() => pending.has(id) && reject(new Error('timeout')), 15000);
      });

      await new Promise((r) => (ws.onopen = r));
      setState('connected');

      ws.onmessage = async (e) => {
        const m = JSON.parse(e.data);
        if (m.type === 'response') {
          const { resolve, reject } = pending.get(m.id) || {};
          pending.delete(m.id);
          m.error ? reject?.(new Error(m.error.msg)) : resolve?.(m.result);
        } else if (m.type === 'notify' && m.method === 'new-producer') {
          const r = await rpc('consume', { producerId: m.params.producerId, rtpCapabilities: device.rtpCapabilities });
          const consumer = await recvT.consume({ id: r.id, producerId: r.producerId, kind: r.kind, rtpParameters: r.rtpParameters });
          await rpc('resumeConsumer', { consumerId: consumer.id });
          setRemotes((prev) => {
            const existing = prev.find((p) => p.peerId === m.params.peerId);
            if (existing) { existing.stream.addTrack(consumer.track); return [...prev]; }
            const stream = new MediaStream([consumer.track]);
            return [...prev, { peerId: m.params.peerId, stream }];
          });
        } else if (m.type === 'notify' && m.method === 'peer-left') {
          setRemotes((prev) => prev.filter((p) => p.peerId !== m.params.peerId));
        }
      };
      ws.onclose = () => setState('closed');

      const { rtpCapabilities } = await rpc('getRouterRtpCapabilities');
      await device.load({ routerRtpCapabilities: rtpCapabilities });
      await rpc('join', { rtpCapabilities: device.rtpCapabilities });

      const sendT = device.createSendTransport(await rpc('createWebRtcTransport', { direction: 'send' }));
      sendT.on('connect', ({ dtlsParameters }, cb, errb) => rpc('connectTransport', { transportId: sendT.id, dtlsParameters }).then(cb, errb));
      sendT.on('produce', async ({ kind, rtpParameters }, cb, errb) => {
        try { const { producerId } = await rpc('produce', { transportId: sendT.id, kind, rtpParameters }); cb({ id: producerId }); }
        catch (e: any) { errb(e); }
      });

      const recvT = device.createRecvTransport(await rpc('createWebRtcTransport', { direction: 'recv' }));
      recvT.on('connect', ({ dtlsParameters }, cb, errb) => rpc('connectTransport', { transportId: recvT.id, dtlsParameters }).then(cb, errb));

      const local = await navigator.mediaDevices.getUserMedia({
        audio: opts.audio !== false,
        video: opts.video !== false ? { width: 1280, height: 720 } : false,
      });
      if (localRef.current) localRef.current.srcObject = local;
      for (const t of local.getTracks()) await sendT.produce({ track: t });

      sessionRef.current = { ws, sendT, recvT, local };
    }

    connect().catch((e) => { console.error(e); setState('closed'); });
    return () => {
      cancelled = true;
      const s = sessionRef.current;
      s?.local?.getTracks().forEach((t: MediaStreamTrack) => t.stop());
      s?.ws?.close();
    };
  }, [sessionId]);

  return { localRef, remotes, state };
}

Use:

function ConferenceView({ sessionId }: { sessionId: string }) {
  const { localRef, remotes, state } = useSession(sessionId);
  return (
    <div className="grid">
      <video ref={localRef} autoPlay muted playsInline />
      {remotes.map((r) => (
        <video key={r.peerId} autoPlay playsInline ref={(el) => el && (el.srcObject = r.stream)} />
      ))}
      <div>{state}</div>
    </div>
  );
}

Full your app contact-screen example

Imagine the user opens a contact in your app. They want chat + a call button.

// your-app/apps/web/src/screens/ContactScreen.tsx
import { useEffect, useState } from 'react';
import { useChat } from '../kardolive/useChat';
import { useKardolive } from '../kardolive/KardoliveProvider';

export function ContactScreen({ contactId }: { contactId: string }) {
  const [channelId, setChannelId] = useState<string | null>(null);
  const [callSessionId, setCallSessionId] = useState<string | null>(null);

  // On mount, ensure a DM channel exists between current user + contact.
  useEffect(() => {
    fetch('/api/kardolive/ensure-dm', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ contactId }),
    }).then(r => r.json()).then(c => setChannelId(c.id));
  }, [contactId]);

  async function startCall(kind: 'voice' | 'video') {
    const r = await fetch('/api/kardolive/calls/invite', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ contactId, kind }),
    });
    const { sessionId } = await r.json();
    setCallSessionId(sessionId);
  }

  return (
    <div>
      <header>
        <h2>Contact #{contactId}</h2>
        <button onClick={() => startCall('voice')}>📞</button>
        <button onClick={() => startCall('video')}>📹</button>
      </header>
      {channelId && <ChatTab channelId={channelId} />}
      {callSessionId && <CallOverlay sessionId={callSessionId} onEnd={() => setCallSessionId(null)} />}
    </div>
  );
}

Each piece (ensure-dm, calls/invite) is a thin your app backend endpoint that proxies to Kardolive with the API key. See Chat and Calls for those.

Next

Chat → · Live stream → · Conference → · Calls →