Server-side LiveKit support for MemberJunction: scoped access-token minting, the agent-room session-start coordinator, and recording (egress) control. This is the seam between the MJ realtime bridge and the browser LiveKit UI.
browser ── GraphQL ──► RealtimeBridgeResolver (MJServer)
│
▼
@memberjunction/livekit-room-server ← you are here
├─ LiveKitTokenService mint client + bot tokens (livekit-server-sdk)
├─ LiveKitAgentRoomCoordinator open realtime session → AIBridgeEngine.StartBridgeSession
└─ LiveKitEgressService start/stop room recording
The realtime bridge already joins the agent bot to a LiveKit room with an MJ-minted token. Two gaps
remained: (1) a human browser participant needs its own scoped token for the same room, and (2) nothing
outside the bridge package opened a realtime session and called StartBridgeSession. This package closes
both.
Credentials resolve from explicit config or environment variables (never inline secrets):
LIVEKIT_URL=wss://livekit.myorg.com
LIVEKIT_API_KEY=...
LIVEKIT_API_SECRET=...
Where the LiveKit server comes from — see
docker/livekit/README.md(canonical setup guide):
- Local dev: spin up a throwaway LiveKit server with matching dev creds — Docker (
docker compose up -d) for browser-only testing, or nativelivekit-server --config docker/livekit/livekit.yamlfor the agent-bot path on macOS. ThenLIVEKIT_URL=ws://localhost:7880/LIVEKIT_API_KEY=devkey/LIVEKIT_API_SECRET=mj-local-dev-livekit-secret-0123456789.- Production / real-world: use LiveKit Cloud — a hosted
wss://…livekit.cloudURL + API key + secret to drop into.env, no server to run (or a properly-networked self-host).
import { LiveKitTokenService } from '@memberjunction/livekit-room-server';
const tokens = new LiveKitTokenService(); // reads env, or pass { ServerUrl, ApiKey, ApiSecret }
const human = await tokens.MintClientToken('support-42', 'user-abc', 'Amith');
// → { ServerUrl, Token, Identity, RoomName } — hand Token to <mj-livekit-room>
LiveKitAgentRoomCoordinator is a BaseSingleton. It mints the bot token, opens the realtime model
session via an injectable factory seam, and bridges it into the room:
import { LiveKitAgentRoomCoordinator } from '@memberjunction/livekit-room-server';
// At deployment/startup, bind the realtime-session factory (resolves a BaseRealtimeModel → IRealtimeSession):
LiveKitAgentRoomCoordinator.Instance.SetSessionFactory(async (ctx) => openRealtimeSession(ctx));
// Then, per request:
const session = await LiveKitAgentRoomCoordinator.Instance.StartAgentRoomSession({
AgentSessionID, RoomName: 'support-42', AgentName: 'Sage', TurnMode: 'Passive', ContextUser,
});
The factory is a seam (like LiveKitBridge.SetSdkFactory) so this package carries no heavy agent-runtime
coupling and unit-tests with a stub session. The actual native room media client (@livekit/rtc-node) is
bound on the bridge driver at deployment, per the realtime-bridge buildout plan.
import { LiveKitEgressService } from '@memberjunction/livekit-room-server';
const egress = new LiveKitEgressService();
const rec = await egress.StartRoomRecording({ RoomName: 'support-42' }); // → { EgressID, Status }
const done = await egress.StopRecording(rec.EgressID);
// On stop/complete, RecordingInfo also surfaces the produced file:
// done.OutputLocation – the file's path/key in the egress sink
// done.OutputSizeBytes – byte count
// done.OutputDurationMs – duration in ms (normalized from the SDK's nanoseconds)
// These are what the server registers into MJStorage on the Meeting-Room Conversation
// (Conversation.RecordingFileID + EgressID). While recording is in progress they are undefined.
Egress output storage (S3 / GCS / Azure / LiveKit Cloud) is configured on the LiveKit project.
When the browser stops a room recording, MJServer's RealtimeBridgeResolver.StopLiveKitRecording calls
registerMeetingRecordingFile (packages/MJServer/src/resolvers/meetingRecordingRegistration.ts), which:
EgressID, then room name, else creates one).MJ: Files row for the egress MP4 (ProviderKey = OutputLocation, ContentType = video/mp4, Status = Uploaded). v1 points the Files row directly at the egress output — no byte
copy — so playback streams straight from the sink.Conversation.RecordingFileID (+ EgressID) and returns the new file id on the result.This requires the egress sink and the MJStorage account to target the same bucket/container, configured via the provider:
MJ_MEETING_RECORDING_STORAGE_PROVIDER=<MJ: File Storage Providers ID> (or
meetingRecordingStorageProviderID in mj.config.cjs).Optional copy-to-canonical ("copy into Box") — set a different canonical provider via
MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER (or meetingRecordingCanonicalStorageProviderID) to read
the bytes out of the sink and re-upload them into a separate provider, pointing the Files row there. OFF by
default.
Registration is best-effort: a missing storage provider or any failure leaves RecordingFileID unset
and logs — the stop-recording mutation still succeeds. See
REALTIME_SESSION_CAPTURE_GUIDE.md
for the full loop incl. Meet-app playback.
These are exposed to the browser via MJServer's RealtimeBridgeResolver:
MintLiveKitClientToken, StartLiveKitAgentRoomSession, StartLiveKitRecording, StopLiveKitRecording
(returns RecordingFileID once registered). Call them from the browser with GraphQLLiveKitClient in
@memberjunction/graphql-dataprovider.
ISC © MemberJunction.com
Fileoverview
Public entry point for
@memberjunction/livekit-room-server.Server-side LiveKit room support for MemberJunction: scoped token minting + the agent-room session-start coordinator that bridges a realtime model session into a LiveKit room.