Everything the room can do

Hall is the real-time layer under every Soulcraft product — video, voice, presence, chat, recording, broadcast, and live understanding. One calm page, every capability, small examples.

hall v0.6.0 · wire format: camelCase msgpack
ConnectRoomsBrowser sessions ResiliencePub/sub & presenceChat Live understandingRecordingMedia BroadcastTranscode workersOperations
Start here

Connect from your product

Your server keeps one long-lived WebSocket to Hall and authenticates with its product secret. Everything in @soulcraft/sdk — nothing new to install.

// server-side, once at startup
import { createHallModule } from '@soulcraft/sdk/server'

const hall = createHallModule({
  url: 'wss://hall.soulcraft.com/ws/product',
  productName: 'venue',
  secret: process.env.HALL_VENUE_SECRET,
})
await hall.connect()

Products are added and removed in [auth.products] on the server and apply with a config reload — no Hall restart, no redeploy.

The primitive

Rooms

A room is one live session — a class, a stage, a huddle. Your server creates it, names it after your own entity id, and closes it when the moment ends.

const room = await hall.createRoom('cohort-42', {
  maxPeers: 12,
  enableTranscription: true,
  enableRecording: false,
})
// ... later
await hall.closeRoom('cohort-42')

Room events stream back on the same connection: peerJoined, peerLeft, speakerChanged, transcript, conceptMention, recordingManifest.

People join

Browser sessions & roles

Your server mints a short-lived signed token for each person; the browser joins with it. The token carries the role — participants publish, viewers only receive — and cannot be tampered with.

// server: mint a token (role: 'participant' | 'viewer')
const { token } = await hall.createSessionToken('cohort-42', userId, 300, 'participant')

// browser: join with it
import { joinHallRoom } from '@soulcraft/sdk/client'
const session = await joinHallRoom({ token, hallUrl: 'wss://hall.soulcraft.com' })
session.on('trackAdded', ({ peerId, track }) => attach(track))
Storms end here

Resilience: shed, resume, never stampede

Hall assumes networks blip and servers deploy — and makes both boring. When Hall must refuse or drop a connection it says exactly when to come back; when a connection drops abnormally, the peer's place in the room survives a grace window so reconnecting resumes instead of rejoining.

// the shed frame — sent before a 1013 close
{ t: 'shed', d: { code: 'SERVER_DRAINING', message: '...', retryAfterMs: 4000 } }

// reconnect inside the grace window (default 15s), same token:
{ t: 'sessionOk', d: { roomId, peerId, role, canPublish, resumed: true } }
Who's here

Pub/sub & presence

Topic-based messaging with live presence and replay for late joiners — the "this room is live" chip, cursors on a doc, lightweight fan-out between your own services.

// server or browser (browser uses a pubsub token)
await hall.subscribeTopic('community:general', { name: 'Ada' })
await hall.broadcastTopic('community:general', { kind: 'wave' })
const here = await hall.getPresence('community:general')
Say it

Room chat with history

Chat rides the room's data channel, fans out to everyone, and is retained in a capped ring — late joiners ask for the scroll-back.

// browser: send on the 'chat' channel
session.sendData('chat', { text: 'hello, room' })

// server: fetch history (last 50)
const { messages } = await hall.getChatHistory('cohort-42', 50)
// → [{ peerId, text, timestampMs }, ...]
The room listens

Live understanding

Hall transcribes speech in-process (Whisper) and matches it against the concepts you care about (BERT embeddings) — no audio ever leaves the box. Your server receives understanding as it happens.

hall.on('transcript', ({ roomId, peerId, text, isFinal }) => ...)
hall.on('conceptMention', ({ nodeId, verbType, confidence, text }) => ...)
hall.on('relationProposed', ({ fromNodeId, verbType, toNodeId }) => ...)

Pass concepts at createRoom time (your graph nodes); use selectiveTranscriptionThreshold to transcribe only the active speaker in big rooms.

Keep it

Recording

Per-participant audio and video tracks, written on Hall's data volume. Stopping returns a manifest of file paths — your product decides where they live forever.

await hall.startRecording('cohort-42')
// ... the session happens ...
const manifest = await hall.stopRecording('cohort-42')
// → { sessionId, audioTracks: [paths], videoTracks: [paths] }
Files too

Media pipeline

Upload, store, transcode, and stream media with range requests and auto-thumbnails — with optional TTL so transient media cleans itself up.

POST   /media/upload              # multipart → { mediaId }
GET    /media/{id}                # stream, supports Range
GET    /media/{id}/thumbnail      # auto-generated webp
GET    /media/{id}/info           # metadata json
DELETE /media/{id}
One to many

Broadcast: WHIP in, WHEP/HLS out

External publishers (OBS, FFmpeg) push into a room over standard WHIP; big audiences watch over WHEP (sub-second) with automatic overflow to low-latency HLS.

# OBS → Settings → Stream → WHIP
Server:  https://hall.soulcraft.com/whip/stage-night
Token:   {product secret}

# viewers beyond the WebRTC cap fall back automatically
GET /hls/stage-night/playlist.m3u8
Heavy lifting

Transcode workers

Recordings become VOD through a pull-based job queue. Workers — CPU on the box today, the GPU box's NVENC encoders when volume justifies — pull work when they have budget, so Hall can never overload them.

# a worker leases the next job it can encode
POST /worker/lease   Authorization: Bearer {workerSecret}
{ workerId: 'gex44', capabilities: { nvenc: ['h264','hevc','av1'] } }
# → 200 { jobId, sourcePath, outputPath, codec }  |  204 no work

POST /worker/complete   { workerId, jobId }
POST /worker/fail       { workerId, jobId, reason }  # requeues, then fails loudly
Run it well

Operations

hall.soulcraft.com · dashboard · health · the real-time layer of the Soulcraft platform