osamcall
v1.0.1
Published
Official SDK for OsamCall — 1:1 video calling for web, Next.js, Node.js, and React Native.
Downloads
231
Maintainers
Readme
OsamCall
Dedicated 1:1 video calling for your own app — built on the same open-source LiveKit
media engine as osamlive, but with an API
shaped around calls, not generic rooms: every call is hard-capped at exactly 2
participants by the media server itself, and the React layer hands you a single
remoteParticipant instead of a list you'd only ever use one entry of.
If you need group calls, arbitrary-size rooms, or OBS/RTMP live streaming, use
osamlive instead — both packages talk to the
same backend deployment, so you can use either (or both) against one project.
- Works everywhere on the server: Node.js, Next.js (API routes, Server Actions,
Route Handlers), Express, serverless functions, even Edge runtimes — it's built on
nothing but the global
fetch. - Works in the browser: a React hook, a ready-made full call screen
(
<DefaultCallUI />) you can drop in as-is or fully replace, and lower-level primitives (<CallView />,<ParticipantView />) if you want your own layout. - Works in React Native:
osamcall/react-nativehas its ownDefaultCallUIfor the zero-effort path andOsamCallRoom/useCallMedia()for a custom UI, plus an Expo config plugin that fixes a real@livekit/react-nativeAndroid build issue — see React Native. (osamcall/react, the web UI layer, is browser-only and doesn't run in RN — that's whatosamcall/react-nativeis for.) - Self-mirroring local preview, like WhatsApp/FaceTime: your own video preview is flipped so raising your right hand shows up on your right side, the way a mirror works — the other participant always sees the true, un-mirrored feed regardless (only your own local rendering is flipped; nothing about what's actually sent changes).
- Automatic volume leveling: incoming audio is run through a compressor + makeup
gain so it lands at one comfortable level regardless of how loud or quiet the other
person's mic/environment is — on top of
autoGainControlon what you send. On by default, and can be turned off per component if you'd rather handle audio yourself.
Compatibility
| Environment | Works? | Notes |
|---|---|---|
| JavaScript (any runtime) | ✅ | No TypeScript required — the server SDK is plain JS/fetch, the shipped .d.ts types are optional. |
| TypeScript | ✅ | No minimum version pinned; the shipped types avoid anything recent enough to trip up older compilers. |
| Node.js backend | ✅ | 18+ (needs global fetch). This is where OsamCall (the server SDK) runs — see Quick start. |
| Next.js | ✅ | Server SDK in API routes/Server Actions/Route Handlers (even Edge runtime); osamcall/react in client components — see Next.js. |
| Plain React (Vite, CRA, etc.) | ✅ | osamcall/react has no Next.js dependency at all — see Plain React. |
| Vanilla JS/TS, no framework | ✅ | osamcall/react needs React, but the server SDK doesn't, and connecting in the browser is just livekit-client directly — see Vanilla JavaScript. |
| Bare React Native | ✅ | osamcall/react-native ships DefaultCallUI for the zero-effort path and OsamCallRoom/useCallMedia() for a custom UI — both hide @livekit/react-native's raw API (no registerGlobals() step, no manual AudioSession handling, no Track.Source filtering). One caveat: automatic volume leveling doesn't carry over (no Web Audio API in RN) — see React Native for the full list of what does/doesn't transfer. |
| Expo (managed) | ⚠️ Partial, with a catch | Same as bare RN above, plus: @livekit/react-native-webrtc is a native module, so it does not run in Expo Go. You need EAS Build (or expo prebuild) with a dev client; Expo's managed workflow supports this via config plugins, it's just not the Expo-Go-only path. |
The one feature that doesn't carry over to React Native is automatic volume leveling — it's built on the browser's Web Audio API, which has no equivalent in RN's JS runtime. Default UI and self-mirroring preview both work natively on RN — see React Native for details.
Install
npm install osamcallIf you're building the call UI in a browser (React/Next.js), also install LiveKit's client SDK, which this package's React helpers wrap:
npm install livekit-clientBuilding for React Native instead? See React Native for its own install step (a few more packages, since RN needs native WebRTC modules).
The two (or three) halves of this SDK
| | Where it runs | What it needs | Import |
|---|---|---|---|
| Server SDK | Your backend only | Your project's App Secret | import OsamCall from "osamcall" |
| React helpers | Browser / client component | A { token, wsUrl } pair from your backend | import { useOsamCall, CallView } from "osamcall/react" |
| React Native helpers | Your RN/Expo app | Same { token, wsUrl } pair | import { DefaultCallUI } from "osamcall/react-native" |
Never put your App Secret in browser or mobile app code. The flow is always:
Caller's client → your backend → osamcall server SDK → OsamCall
▲ │
└────────────────── { callCode, token, wsUrl } ─────────────┘
Callee's client → your backend → osamcall server SDK → OsamCall
▲ (joinCall with the same callCode)
└────────────────── { token, wsUrl } ───────────────────────┘Your backend mints a fresh, short-lived token per real user right before they join. Never share one token across users, and never mint tokens ahead of time and store them.
Getting your credentials
Same App Key as osamlive — they're the same project/dashboard, just a different API
surface on it. If you already have an OSAMLIVE_APP_KEY, reuse it here.
# .env (server-side only — never NEXT_PUBLIC_/EXPO_PUBLIC_/etc.)
OSAMCALL_APP_KEY=proj_xxxxxxxxxxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
OSAMCALL_BASE_URL=https://live.yourdomain.comQuick start — Node.js
import { OsamCall } from "osamcall";
const client = new OsamCall({
appKey: process.env.OSAMCALL_APP_KEY,
baseUrl: process.env.OSAMCALL_BASE_URL,
});
// Caller starts the call:
const caller = await client.startCall({ identity: currentUser.id, name: currentUser.name });
// caller = { callCode, token, wsUrl, iceServers? }
// Send caller.callCode to the callee (push notification, your own signaling, etc.)
// and { token, wsUrl, iceServers } to the caller's own client to join.
// Callee joins using the call code:
const callee = await client.joinCall({ callCode: caller.callCode, identity: otherUser.id });
// Send { token, wsUrl, iceServers } to the callee's client.Next.js
API routes (app/api/call/start/route.ts and app/api/call/join/route.ts) — this
is where the App Key actually lives:
// app/api/call/start/route.ts
import { NextResponse } from "next/server";
import { OsamCall } from "osamcall";
import { getCurrentUser } from "@/lib/auth";
const client = new OsamCall({
appKey: process.env.OSAMCALL_APP_KEY!,
baseUrl: process.env.OSAMCALL_BASE_URL!,
});
export async function POST(req: Request) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const call = await client.startCall({ identity: user.id, name: user.name });
return NextResponse.json(call);
}// app/api/call/join/route.ts
export async function POST(req: Request) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { callCode } = await req.json();
try {
const call = await client.joinCall({ callCode, identity: user.id, name: user.name });
return NextResponse.json(call);
} catch (err: any) {
// err.status is 409 if the call already has two other participants
return NextResponse.json({ error: err.message }, { status: err.status ?? 500 });
}
}Client component — default call UI (the fastest way to get a working call screen):
<DefaultCallUI /> connects automatically, shows the mirrored local preview + the
other participant's video with auto-leveled audio, and renders mic/camera/end-call
controls — the whole call experience in one component:
"use client";
import { useEffect, useState } from "react";
import { DefaultCallUI } from "osamcall/react";
export default function CallScreen({ callCode, isCaller }: { callCode?: string; isCaller: boolean }) {
const [creds, setCreds] = useState<{ token: string; wsUrl: string; iceServers?: any[]; callCode: string } | null>(null);
useEffect(() => {
fetch(isCaller ? "/api/call/start" : "/api/call/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ callCode }),
})
.then((res) => res.json())
.then(setCreds);
}, []);
if (!creds) return <p>Connecting…</p>;
return (
<div style={{ height: "100vh" }}>
{isCaller && <p>Share this code: {creds.callCode}</p>}
<DefaultCallUI
token={creds.token}
wsUrl={creds.wsUrl}
iceServers={creds.iceServers}
onEndCall={() => {
/* e.g. router.push("/") */
}}
style={{ height: "calc(100vh - 32px)" }}
/>
</div>
);
}Building your own UI instead — DefaultCallUI is just useOsamCall + CallView +
some buttons, all exported below, so you're never stuck with the default look. Skip
DefaultCallUI and use the hook directly for full control over layout and controls:
"use client";
import { useEffect, useState } from "react";
import { useOsamCall, CallView } from "osamcall/react";
export default function CustomCallScreen({ callCode, isCaller }: { callCode?: string; isCaller: boolean }) {
const { connect, disconnect, isConnected, localParticipant, remoteParticipant, toggleCamera, toggleMicrophone } =
useOsamCall();
const [error, setError] = useState<string | null>(null);
const [code, setCode] = useState(callCode);
useEffect(() => {
(async () => {
const res = await fetch(isCaller ? "/api/call/start" : "/api/call/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ callCode }),
});
const data = await res.json();
if (!res.ok) return setError(data.error);
setCode(data.callCode);
await connect({ token: data.token, wsUrl: data.wsUrl, iceServers: data.iceServers }).catch((err) =>
setError(err.message)
);
})();
return () => disconnect();
}, []);
if (error) return <p>Could not join call: {error}</p>;
return (
<div style={{ height: "100vh" }}>
{isCaller && code && <p>Share this code: {code}</p>}
<CallView
localParticipant={localParticipant}
remoteParticipant={remoteParticipant}
style={{ height: "80vh", borderRadius: 16, overflow: "hidden" }}
/>
{isConnected && (
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
<button onClick={toggleCamera}>Toggle camera</button>
<button onClick={toggleMicrophone}>Toggle mic</button>
<button onClick={disconnect}>Leave call</button>
</div>
)}
</div>
);
}Plain React (Vite, CRA, etc.)
Same as the Next.js client component above — osamcall/react doesn't depend on Next.js
at all.
Vanilla JavaScript / no framework
osamcall/react needs React, but nothing about connecting to a call actually requires
it — that hook is a thin wrapper over livekit-client, which you can use directly.
Auto-leveled audio and the mirrored preview are conveniences this package's React layer
adds on top; without React you wire livekit-client up yourself (mirroring is one CSS
line, auto-leveling is the same Web Audio graph shown in this package's source if you
want it — ask if you'd like a plain-JS helper for that extracted out separately).
<video id="remote-video" autoplay playsinline></video>
<video id="local-video" autoplay playsinline muted style="transform: scaleX(-1)"></video>
<!-- scaleX(-1) above is the whole "mirror" trick — flips only your own local preview -->
<script type="module">
import { Room, RoomEvent, Track } from "https://esm.sh/livekit-client@2";
// { token, wsUrl, iceServers } comes from YOUR backend, which called the osamcall
// server SDK's startCall()/joinCall() — never call those from the browser.
const res = await fetch("/api/call/join", { method: "POST", body: JSON.stringify({ callCode }) });
const { token, wsUrl, iceServers } = await res.json();
const room = new Room();
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Video) track.attach(document.getElementById("remote-video"));
if (track.kind === Track.Kind.Audio) track.attach(); // creates + auto-plays an <audio> element
});
await room.connect(wsUrl, token, iceServers?.length ? { rtcConfig: { iceServers } } : undefined);
await room.localParticipant.setCameraEnabled(true);
await room.localParticipant.setMicrophoneEnabled(true, {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
});
const camTrack = room.localParticipant.getTrackPublication(Track.Source.Camera)?.track;
camTrack?.attach(document.getElementById("local-video"));
</script>Multiple concurrent calls
Yes — this is inherent to the architecture, not something you opt into. Every call is
its own LiveKit room (call-<callCode>), and callCode is randomly generated per call
(startCall), so any number of independent 1:1 calls can be in progress at the same
time, fully isolated from each other — Alice↔Bob and Carol↔Dave never see or affect one
another, whether they're started by the same backend process or different ones. This was
verified directly: two calls started simultaneously (Promise.all), each joined by its
own pair of participants, with one call ended mid-flight — the other was completely
unaffected and remained joinable.
The only real ceiling is your deployment's compute/bandwidth (each call's media flows through your self-hosted LiveKit server), not anything in this SDK or the call-code scheme itself — LiveKit's SFU handles many simultaneous rooms as standard operation.
React Native
osamcall/react (the web hook, DefaultCallUI, CallView, ParticipantView) is built
on browser DOM APIs (<video>/<audio> elements, AudioContext) and does not run in
React Native — don't import it there. For React Native, use osamcall/react-native
instead — a separate entry point with its own DefaultCallUI and CallView, built on
LiveKit's own official React Native SDK, @livekit/react-native.
The server SDK (osamcall, the default export) is plain fetch-based and runs fine
inside a React Native app's JS engine too, but should still only ever run on your
backend, never inside the app bundle (a compiled mobile app isn't a secure place for a
secret either — it can be decompiled).
Setup
npm install osamcall @livekit/react-native @livekit/react-native-webrtc livekit-client
npx pod-install # iOSExpo: add "osamcall" to your app.json's plugins array. That's it — no manual
registerGlobals() call (this package does it for you, automatically, on import), and
this plugin's real job is a Gradle/Kotlin fix: @livekit/react-native's Android build
can fail with Module was compiled with an incompatible version of Kotlin on any
project that overrides its Kotlin version for any reason (a genuinely painful one to
debug blind — this plugin ships the actual working fix, extracted from a real production
app that hit it, instead of you needing to patch-package it yourself):
{ "expo": { "plugins": ["osamcall"] } }If that alone doesn't clear it, also pass your project's exact Kotlin version — this
adds a second, more targeted fix directly inside @livekit/react-native's own
build.gradle:
{ "expo": { "plugins": [["osamcall", { "kotlinVersion": "2.3.0" }]] } }(Already have native android/ios folders generated locally? Run
npx expo prebuild --clean once after adding this plugin to pick up the fix — a fresh
expo run:android/EAS Build from a clean checkout picks it up automatically.)
Bare React Native (no Expo): registerGlobals() is still automatic. The Kotlin fix
above is Expo-plugin-specific — apply the equivalent Gradle change by hand if you hit
the same error (see osamcall's app.plugin.cjs source for exactly what it does).
Default call UI
import { useEffect, useState } from "react";
import { View, Text } from "react-native";
import { DefaultCallUI } from "osamcall/react-native";
export function CallScreen({ callCode, isCaller, onLeave }: { callCode?: string; isCaller: boolean; onLeave: () => void }) {
const [creds, setCreds] = useState<{ token: string; wsUrl: string; iceServers?: any[]; callCode: string } | null>(null);
useEffect(() => {
fetch(isCaller ? "https://your-backend.com/api/call/start" : "https://your-backend.com/api/call/join", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ callCode }),
})
.then((res) => res.json())
.then(setCreds);
}, []);
if (!creds) return <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>Connecting…</Text></View>;
return (
<DefaultCallUI
token={creds.token}
wsUrl={creds.wsUrl}
iceServers={creds.iceServers}
onEndCall={onLeave}
style={{ flex: 1 }}
/>
);
}Same shape as the web /api/call/start and /api/call/join routes shown earlier — one
backend serves both your web and native clients.
Building your own UI instead — this is the realistic path for any real app (custom
layout, gifting/billing UI, draggable picture-in-picture, whatever your call screen
actually needs), not an edge case — so it's built to need zero LiveKit-specific
knowledge, not just a thin re-export of it. Use OsamCallRoom (instead of raw
LiveKitRoom — same props, plus it manages the Android/iOS audio session for you
automatically, a real, silent bug otherwise: calls come through at near-silent volume on
Android without it) and useCallMedia() (instead of useTracks(...) +
Track.Source.Camera + manually filtering by participant.isLocal yourself):
import { OsamCallRoom, useCallMedia, VideoTrack } from "osamcall/react-native";
import { StyleSheet, View, Button } from "react-native";
function MyCallScreen({ token, wsUrl, iceServers }) {
return (
<OsamCallRoom serverUrl={wsUrl} token={token} connect audio video iceServers={iceServers}>
<MyCallUI />
</OsamCallRoom>
);
}
function MyCallUI() {
const { remoteVideoTrack, localVideoTrack, toggleCamera, toggleMicrophone } = useCallMedia();
return (
<View style={{ flex: 1 }}>
<VideoTrack trackRef={remoteVideoTrack} style={StyleSheet.absoluteFill} objectFit="cover" />
<VideoTrack trackRef={localVideoTrack} mirror style={{ position: "absolute", top: 40, right: 16, width: 100, height: 140 }} />
<Button title="Mute" onPress={toggleMicrophone} />
<Button title="Camera" onPress={toggleCamera} />
</View>
);
}No livekit-client import, no Track.Source enum, no manual track filtering, no
AudioSession call — the raw LiveKitRoom/useLocalParticipant/useRemoteParticipants/
useTracks/Track are still re-exported from osamcall/react-native too, for the rare
case you need something useCallMedia() doesn't expose, but the common custom-UI case
never needs them.
What doesn't carry over from the web package
- Mirroring works the same way —
@livekit/react-native's video view has a nativemirrorprop, same effect as the web CSS flip, already wired intoDefaultCallUI/CallViewabove. - Automatic volume leveling does not carry over. It's built on the browser's Web
Audio API (
AudioContext/DynamicsCompressorNode), which doesn't exist in React Native's JS runtime, and@livekit/react-native-webrtcdoesn't expose an equivalent — a real receive-side audio-leveling feature here would need a separate native audio module, out of scope for this package today. Sender-sideautoGainControl(the mic capture constraint) is still applied, same as on web, viaOsamCallRoom.
Expo
@livekit/react-native-webrtc is a native module, so this needs a dev client build
(EAS Build or expo prebuild) — it will not run inside Expo Go. Expo's managed
workflow still works via its config plugin system; it's the "install a native module"
path, not the "everything stays in Expo Go" path.
API reference
new OsamCall(options)
| Option | Type | Required | Description |
|---|---|---|---|
| appKey | string | one of appKey or appId+secret | <appId>.<secret>, shown once at project creation |
| appId | string | | Alternative to appKey |
| secret | string | | Alternative to appKey |
| baseUrl | string | recommended | Your OsamCall/OsamLive deployment's URL |
client.startCall({ identity, name? })
→ Promise<{ callCode, token, wsUrl, iceServers? }>. Creates a new call and mints the
caller's own join token in one step. Share callCode with the other participant.
client.joinCall({ callCode, identity, name? })
→ Promise<{ callCode, token, wsUrl, iceServers? }>. Mints a join token for the second
participant (or a reconnecting one, using the same identity). Throws OsamCallError
with status: 409 if the call already has two other participants.
client.endCall(callCode)
→ Promise<void>. Ends the call immediately, disconnecting both participants.
Errors
Every method throws OsamCallError ({ name: "OsamCallError", message, status }) on
failure — wrap calls in try/catch and read .status (e.g. 409 for a full call) to
branch on the reason.
osamcall/react
<DefaultCallUI token wsUrl iceServers? onEndCall? autoLevelAudio? className? style? />— the complete default call screen: connects on mount, disconnects on unmount, rendersCallViewplus mic/camera/end-call controls, shows connecting/error states. The quickest way to a working call; swap it out entirely for your own UI whenever you want.useOsamCall()→{ room, isConnected, isConnecting, error, localParticipant, remoteParticipant, connect, disconnect, toggleCamera, toggleMicrophone, isCameraEnabled, isMicrophoneEnabled }.remoteParticipantisnulluntil the other person joins. WhatDefaultCallUIis built on — use this directly for a fully custom UI.<CallView localParticipant remoteParticipant autoLevelAudio? className? style? />— ready-made 1:1 layout: remote participant fills the frame (audio auto-leveled by default), local participant is a small mirrored corner picture-in-picture.<ParticipantView participant={p} mirror? autoLevelAudio? className? style? />— the lower-level primitiveCallViewis built on, for full control over your own layout.mirror: flip this participant's video for selfie-style local preview — set it only on the local participant, never the remote one (it's a local rendering flip only; the other participant is never affected by it).autoLevelAudio(defaulttrue): run this participant's incoming audio through a compressor + makeup gain so it lands at a consistent, comfortable volume; has no effect on the local participant.
osamcall/react-native
<DefaultCallUI token wsUrl iceServers? onEndCall? style? />— the complete default call screen: connects on mount (viaOsamCallRoombelow), shows the mirrored local preview + remote video, renders mic/camera/end-call controls, shows connecting/error states. NoautoLevelAudioprop here — see What doesn't carry over.<OsamCallRoom {...LiveKitRoomProps} iceServers? />— use this instead of rawLiveKitRoomwhen building a custom call screen. Same props, plus: managesAudioSession.startAudioSession()/stopAudioSession()automatically (fixes real, silent near-silent-audio-on-Android behavior if you don't), and a flaticeServersprop instead of needing to knowconnectOptions.rtcConfig.iceServers.useCallMedia()→{ localParticipant, remoteParticipant, localVideoTrack, remoteVideoTrack, isCameraEnabled, isMicrophoneEnabled, toggleCamera, toggleMicrophone }. Must be used inside anOsamCallRoom/LiveKitRoom.localVideoTrack/remoteVideoTrackare ready to pass straight to<VideoTrack trackRef={...} />— noTrack.Sourceenum, no manual filtering byparticipant.isLocalneeded.<CallView style? />— ready-made 1:1 layout built onuseCallMedia(), must be rendered inside anOsamCallRoom/LiveKitRoom.- Also re-exports
LiveKitRoom,useLocalParticipant,useRemoteParticipants,useTracks,useRoomContext,VideoTrack,AudioSession,Trackstraight from@livekit/react-native/livekit-client— an escape hatch for the rare caseOsamCallRoom/useCallMedia()don't cover, not what you're expected to reach for.
The Expo config plugin (app.plugin.cjs)
Add "osamcall" to your app.json's plugins array (see Setup above for the
kotlinVersion option). It:
- Sets
NSCameraUsageDescription/NSMicrophoneUsageDescriptionon iOS andCAMERA/RECORD_AUDIO/MODIFY_AUDIO_SETTINGSon Android, if not already set. - Applies
-Xskip-metadata-version-checkproject-wide — Kotlin's own documented escape hatch forModule was compiled with an incompatible version of Kotlin, which@livekit/react-native(and often several other native modules at once) can trigger on any project that overrides its Kotlin version. Safe unconditionally — it only loosens a compile-time metadata check, not what compiles successfully or any runtime behavior. - With
kotlinVersionpassed: additionally rewrites the Kotlin-version resolution inside@livekit/react-native's ownbuild.gradledirectly — a more targeted fix for the cases the flag above doesn't fully resolve on its own.
License
MIT
