Stories & Live — Technical Reference
Where this lives
Backend
apps/backend/managers/live-stream-managers/live-stream.manager.js— implements the business logic for every live-stream operation (createLiveStream,startLiveStream,endLiveStream,cancelLiveStream,deleteLiveStream,joinLiveStream,leaveLiveStream,banViewerFromLive/unbanViewerFromLive,sendLiveComment/deleteLiveComment/pinLiveComment/unpinLiveComment,reactToLiveStream/removeReaction,getCurrentlyLive,getTrendingLiveStreams,getScheduledStreams,getUserLiveStreams,getLiveStreamStats, LiveKit token issuance, plus the more recently addedsendGift/getGiftHistory/getLiveGiftCatalog,requestToSpeak/respondToSpeakerRequest/getSpeakerRequests/getMySpeakerStatus, andcreateLivePromotionPost). Corrected — this is now fully wired to GraphQL. A prior pass of this doc said the broadcasting half of this manager had no resolver; that was accurate at the time but is no longer true. Auditing this manager before wiring it up also surfaced a systemic bug: nearly every.create()/.update()/.increment()call and instance-property read across this manager, its 4 access-services, and the 4 underlying Sequelize models used snake_case keys (user_id,live_stream_id,is_currently_watching,peak_viewer_count, ...) against models whose real attributes are camelCase — Sequelize silently drops unrecognized keys on writes rather than erroring, so writes likebanViewer'sis_banned: truenever actually persisted,updatePeakViewerCountwas dead code (undefinedcomparison),endLiveStream's duration always came outNaN, andjoinLiveStream's own validator call was miskeyed so it always threw. On top of that, all 4 LiveStream-family Sequelize models (LiveStream,LiveStreamComment,LiveStreamViewer,LiveStreamInteraction) had emptyassociate()methods despite every access-service method eager-loading{model: User, as: 'user'}— Sequelize throwsEagerLoadingErrorfor anincludealias with no defined association, so essentially every read in this feature would have failed at runtime. All of this was fixed (camelCase throughout, associations added, plus a missinggetLiveStream/joinLiveStreamvisibility-enforcement gap forprivate/followers_only/close_friendsstreams) before the GraphQL layer below was built on top of it.apps/backend/graphql/types/live-stream.type.jsandresolvers/live-stream.resolver.js— the full broadcasting GraphQL API: every query/mutation listed under GraphQL API below.apps/backend/graphql/types/live-stream-archive.type.jsandresolvers/live-stream-archive.resolver.js— a thin, read-only passthrough exposingliveArchive(limit, offset): the caller's own ended live streams, backed bylive-stream.manager.js#getUserLiveStreamsscoped tostatus: 'ended'.apps/backend/data-access-services/live-stream/live-stream.access-service.js,live-stream-viewer.access-service.js,live-stream-comment.access-service.js,live-stream-interaction.access-service.js— database layer used by the manager aboveapps/backend/managers/call-managers/speaker-request.manager.jsanddata-access-services/call/speaker-request.access-service.js— Corrected — moved and no longer the whole story. This page previously cited these at their old paths (managers/speaker-request.manager.js,data-access-services/speaker-request.access-service.js) and said the "request to speak" flow was wired only to groupCalls. Both are still true of this pair of files (they backpendingSpeakerRequests/mySpeakerRequests/cancelSpeakerRequestincall.resolver.js/call.type.js, forCalls only) — but live streams now have their own, separate speaker-request implementation that doesn't go through this manager at all:requestToSpeak/respondToSpeakerRequest/getSpeakerRequests/getMySpeakerStatusbuilt directly intolive-stream.manager.js, gating aspeakerStatuscolumn onLiveStreamViewer. See the Speaker requests section below.apps/backend/services/livekit.service.jsandlivekit-monitor.service.js— LiveKit room provisioning, tokens, and stream monitoring- Corrected — Stories are implemented, but not via
Tale. This page previously stated Stories have no backend implementation at all, citing atale.access-service.jsstub ("No DB model exists yet; returns safe empty values") as the only story-related code. That stub (along with itsblast/articlesiblings underdata-access-services/admin/) has since been deleted entirely - it was dead code kept alive only so admin moderation flows didn't crash when a report/removal request named one of these never-shipped content types; there was never aTalemodel to route to. Stories actually live asPostrows withtype: 'story'(parallel to how Clips aretype: 'clip'), reusing the Post table'sexpires_atcolumn instead of a dedicated stories table. A stale docblock comment onPost.typeinpost.type.js("'story' is reserved for the upcoming Stories feature and isn't creatable yet") is itself wrong and contradicted by thecreateStorymutation two dozen lines below it in the same file — don't trust that comment. apps/backend/graphql/types/post.type.js—homeStories,userStories,hasActiveStory,storyViewers,storyViewerCountqueries and thecreateStory(input: StoryCreateInput!)mutation;StoryCreateInput.audiencepicks the story'sPostVisibility(this doc previously said there was no audience field at all — corrected)apps/backend/graphql/resolvers/post.resolver.js— resolves all operations above viapostManagerapps/backend/managers/post-managers/post.manager.js—createStory(setsexpiresAt = now + STORY_DURATION_MS,visibility: validated.audience || 'followers'),getHomeStories,getUserStories,hasActiveStory.PostManagerstill has nogetStoryViewers/getStoryViewerCountmethods — that's fine now, because fixed this session, the fix went into the resolver instead of the manager:post.resolver.js'sstoryViewers/storyViewerCountno longer callpostManagerat all. They now delegate straight towatch-history.manager.js#getPostViewers/#getPostViewersCount— the same real, owner-only implementation already backingpostViewers/postViewersCountinwatch-history.type.js/watch-history.resolver.js. This page previously said bothstoryViewers/storyViewerCountthrewTypeError: ... is not a functionat runtime because they called nonexistentPostManagermethods — that's no longer true.apps/backend/validators/post.validator.js—validateStoryInputvalidatesaudienceagainst the sharedvalidateVisibilitywhitelist (now includesclose_friends), defaulting tofollowerswhen omitted- Fixed this session — now enforced. This page previously said the story-reading path never checked
audienceback:getPosthad noclose_friendsbranch, andgetHomeStories/getUserStoriesapplied no visibility filter at all. That's fixed.getPost(~line 643) gained aclose_friendsbranch alongside its existingprivate/followers/subscriberschecks, gated onuserCloseFriendAccessService.isCloseFriend(post.userId, viewerId). The story-listing paths are gated too:getHomeStoriesfilters outclose_friendsstories from any followed user the viewer isn't on the close-friends list of (resolved once per distinct owner, not per story, to avoid a lookup per story), andgetUserStoriesruns every non-owner viewer through apublic/followers/close_friendsswitch (isFollowing/isCloseFriend), defaulting closed for any other value.StoryCreateInput.audienceis now genuinely enforced on read, not just captured and stored. (close-friends.manager.js#isCloseFriendwas already used for real for close-friends live streams inlive-stream.manager.js— it's now used for stories too.)
Frontend
apps/frontend-nextjs/src/page-components/settings/StoryLivePage.tsx(routed atapps/frontend-nextjs/src/app/settings/story-live) — a settings toggle screen (default story audience / share stories / save to archive / location), not the actual stories or live-stream experience. Corrected — these toggles are wired, not placeholders. This page previously said the audience/toggle controls weren't wired to any GraphQL operation; they are —defaultStoryAudience,allowStorySharing,saveStoryToArchive, andshareLocationare all persisted throughprivacySettings/updatePrivacySettings(the samePrivacySettings/PrivacySettingsInputtypesAccountPrivacyPage.tsxuses). The "Hide story from" section is a separate, also-real and wired feature — see below.apps/frontend-nextjs/src/page-components/settings/ArchivePage.tsx(routed atapps/frontend-nextjs/src/app/settings/archive) — has a "Live" tab that queriesliveArchiveand lists the user's own ended streams (thumbnail, duration, view/like/comment counts). This is the one live-stream screen confirmed to exist infrontend-nextjs.apps/frontend-nextjs/src/components/stories/StoryRail.tsx— the horizontal story-avatar rail, rendered onHomePage.tsx. QuerieshomeStories, groups the flat post list into one avatar per user, and opensCreateStoryModal(for your own ring, if you have no active story) orStoryViewer(otherwise)apps/frontend-nextjs/src/components/stories/StoryViewer.tsx— fullscreen story playback: per-story progress bars, tap-left/right and arrow-key navigation, auto-advance (5s for images, onendedfor video), callsincrementPostViews(aggregate counter) andrecordPostView(per-viewer log, skipped on your own story) once per story, a 5-emoji quick-reaction bar callinglikePost, a comment box callingcreateComment, and (owner-only, via an eye icon) a "who viewed my story" bottom sheet backed bystoryViewersapps/frontend-nextjs/src/components/stories/CreateStoryModal.tsx— the composer; callscreateStorywith anaudiencepicker (Everyone/Followers/Close friends). Accepts both images and video (accept="image/*,video/mp4,video/quicktime,video/x-msvideo,video/webm", matching the backend's/uploadallowlist) - this doc previously said video was blocked; the backend allowlist already includedvideo/*mimetypes (seeapi/server.js), this modal's client-side file picker was the only remaining restriction — correctedhasActiveStoryis wired into the profile-picture ring on bothProfilePage.tsxandPublicProfilePage.tsxapps/frontend-nextjs/src/page-components/LiveDiscoveryPage.tsx(routed at/live) — currently-live / trending / scheduled grids (currentlyLive,trendingLiveStreams,scheduledLiveStreams), each card linking to/live/[id]; a "Go live" button (signed-in users only) opensGoLiveModalapps/frontend-nextjs/src/components/live/GoLiveModal.tsx— the stream-creation form (title, description, visibility, allow comments); callscreateLiveStreamand navigates to/live/{id}on success, where the owner starts the actual broadcastapps/frontend-nextjs/src/page-components/LiveRoomPage.tsx(routed at/live/[id]) — the room itself, with distinct owner/viewer/scheduled/ended states. Owner:startLiveStream→liveStreamerToken→ connects to LiveKit as a publisher (camera + mic, toggleable) vialivekit-client'sRoom/createLocalTracks;endLiveStreamon stop. Viewer:joinLiveStream(returns aLiveKitConnectionInfo: token + url + roomName in one call) → connects as a subscriber, attaching subscribed video/audio tracks;leaveLiveStreamon unmount/navigation-away. Both: a live comment feed (liveStreamComments,sendLiveComment, pin/unpin and delete for the host), and a 6-emoji reaction bar (reactToLiveStream, live counts vialiveStreamReactionCounts). Enforces the stream'svisibilityserver-side (see the manager correction above) — a private/followers-only/close-friends stream can't be joined just by knowing its ID.apps/frontend-nextjs/src/components/Navigation.tsx— a "Live" entry in the "More" menu, linking to/live- Dependency note:
livekit-clientwas added toapps/frontend-nextjs/package.jsonbut could not be installed in the environment this was built in (no network access to the npm registry from that sandbox) — runnpm installinapps/frontend-nextjsbefore building/running; until thenlivekit-clientimports will fail to resolve.
Story privacy — hidden users
query StoryHiddenUsers { storyHiddenUsers { id username profilePicture } }
mutation UpdateStoryHiddenUsers($userIds: [ID!]!) { updateStoryHiddenUsers(userIds: $userIds) { id username } }
Backed by graphql/types/extended-privacy-settings.type.js + privacy-settings.manager.js (getStoryHiddenUsers/updateStoryHiddenUsers), fully wired end to end. updateStoryHiddenUsers replaces the full exclude-list in one call; the backend filters the caller's own ID out of the input so a user can never hide their story from themselves. This is a real, working privacy control layered on top of the real Stories feature documented below (previously this page described it as controlling a preference for a feature that didn't exist yet — corrected).
Stories GraphQL API
mutation CreateStory($input: StoryCreateInput!) {
createStory(input: $input) { id createdAt user { username } media { mediaUrl mediaType } }
}
# Own + followed users' active (non-expired) stories — home rail
query HomeStories { homeStories { id createdAt user { id username profilePicture } } }
# A single user's active stories, oldest first — the viewer's per-user story list
query UserStories($userId: ID!) {
userStories(userId: $userId) { id text createdAt media { mediaUrl mediaType } }
}
# Profile-picture ring indicator
query HasActiveStory($userId: ID!) { hasActiveStory(userId: $userId) }
# Viewed + commented the same way as a regular post
mutation IncrementStoryViews($postId: ID!) { incrementPostViews(postId: $postId) { id } }
mutation CommentOnStory($postId: ID!, $text: String!) {
createComment(input: { postId: $postId, text: $text }) { id }
}
StoryCreateInput.audience accepts public, followers, or close_friends (any other PostVisibility value is technically accepted by the shared validator but doesn't make sense for a story) and defaults to followers when omitted, matching the previous hardcoded behavior. The Close Friends list itself (Settings → Close Friends) is now wireable to a story's audience.
Enforced on read. audience is validated, stored, and — fixed this session — now checked back on every read path too. See the "Fixed this session — now enforced" correction under Where this lives above.
mutation RecordStoryView($postId: ID!) { recordPostView(postId: $postId) }
mutation ReactToStory($postId: ID!, $interactionType: InteractionType) {
likePost(postId: $postId, interactionType: $interactionType) { id reactionType }
}
# Owner-only - throws for anyone else
query StoryViewers($postId: ID!, $limit: Int) {
storyViewers(postId: $postId, limit: $limit) { viewedAt viewer { id username profilePicture } }
}
query StoryViewerCount($postId: ID!) { storyViewerCount(postId: $postId) }
Fixed this session — works now. Both resolvers delegate straight to watch-history.manager.js#getPostViewers/#getPostViewersCount — see the correction under Where this lives above. StoryViewer.tsx's "who viewed my story" sheet calls storyViewers, which now returns real data instead of throwing.
Technical implementation checklist
-
createStory— wired end-to-end:post.resolver.js+CreateStoryModal.tsx(images only — see Frontend note above) -
homeStories/StoryRail.tsx— wired end-to-end -
userStories/hasActiveStory/StoryViewer.tsx— wired end-to-end, including the profile-picture ring onProfilePage.tsxandPublicProfilePage.tsx - Story view counting (
incrementPostViews) — wired, aggregate count only - Commenting on a story (
createComment) — wired, reuses the regular post comment mutation - Per-story audience selection (Everyone / Followers / Close Friends) —
StoryCreateInput.audienceis captured and stored on creation; fixed this session: it's now enforced on every read path too (getPost's newclose_friendsbranch, plus visibility filtering ingetHomeStories/getUserStories) — see the correction above - Video stories —
CreateStoryModal.tsx's file picker now acceptsvideo/*, matching the backend allowlist that already existed - "Who viewed my story" — fixed this session:
storyViewers/storyViewerCountnow delegate straight towatch-history.manager.js#getPostViewers/#getPostViewersCount(owner-only), the same real implementation already backingpostViewers/postViewersCount— see the correction above.StoryViewer.tsx's "who viewed my story" sheet works end to end now. - React to a story with an emoji — a 5-emoji quick-reaction bar in
StoryViewer.tsx, reusing the genericlikePost/InteractionTypesystem regular posts already had -
createLiveStream/startLiveStream/endLiveStream/cancelLiveStream/deleteLiveStream— wired end-to-end:live-stream.resolver.js+GoLiveModal.tsx+LiveRoomPage.tsx(owner controls) -
joinLiveStream/leaveLiveStream— wired end-to-end:LiveRoomPage.tsx(viewer), returns aLiveKitConnectionInfo(token + url + roomName); visibility-enforced server-side -
banLiveViewer/unbanLiveViewer— resolver + manager wired (the manager-level persistence bug is fixed — see the backend correction above); no dedicated moderation UI inLiveRoomPage.tsxyet, callable via GraphQL only -
sendLiveComment/pinLiveComment/unpinLiveComment/deleteLiveComment— wired end-to-end:live-stream.resolver.js+LiveRoomPage.tsx's comment sidebar (pin/unpin gated to the host; delete allowed for the comment's own author or the host) -
reactToLiveStream/removeLiveStreamReaction— wired end-to-end:live-stream.resolver.js+LiveRoomPage.tsx's reaction bar, with live counts vialiveStreamReactionCounts -
currentlyLive/trendingLiveStreams/scheduledLiveStreams— wired end-to-end:live-stream.resolver.js+LiveDiscoveryPage.tsx(/live) - Live coin gifts — wired end-to-end:
live-stream-gift.type.js/live-stream-gift.resolver.js(sendLiveGift,liveGiftCatalog,liveStreamGifts) + a gift picker inLiveRoomPage.tsx; coins move viewer → streamer through the coin ledger (sendTip), backed by thelive_stream_gifttable - Live-stream speaker requests —
requestLiveSpeaker/liveStreamSpeakerRequests/myLiveSpeakerStatus/respondToLiveSpeakerRequest, wired end-to-end:live-stream.resolver.js(implemented directly inlive-stream.manager.js, independent of the Call-onlyspeaker-request.manager.js) +LiveSpeakerControls.tsx. Approval flipsLiveStreamViewer.speakerStatustoapproved; the viewer must re-join to actually receive a LiveKit publish token (no auto-reconnect) -
StoryLivePage.tsx(/settings/story-live) — the default-audience select and the three toggles (share stories / save to archive / location) are wired toprivacySettings/updatePrivacySettings; "Hide story from" is wired separately viastoryHiddenUsers/updateStoryHiddenUsers -
promoteLiveStream— wired end-to-end:live-stream.resolver.js+ a "Promote" button inLiveRoomPage.tsx; creates/reuses a companion announcementPostlinking back to the live, which then runs through the normal post-promotion flow (lives aren'tPosts themselves) -
liveArchive— wired end-to-end:live-stream-archive.resolver.js+ArchivePage.tsx's Live tab
Settings
Story and live preferences are managed under Settings → Story & Live (/settings/story-live).
Stories
Features
- Upload a photo or video story that expires after 24 hours
- Choose an audience per story: Everyone, Followers, or Close Friends
- Comment on a story (reuses the regular post comment box)
- React with one of 5 quick emoji (reuses the regular post reaction system)
- Owners can see an aggregate view count, plus a "who viewed my story" list (
storyViewers) — both wired end to end, owner-only, since this session's fix — see the Technical implementation checklist above
Story viewer
A fullscreen overlay (StoryViewer.tsx) with tap-to-advance navigation, per-story progress bars, and auto-advance (5s per image, or on video end).
Close Friends stories
The Close Friends list (Settings → Close Friends, add/remove people) can now be selected as a story's audience via StoryCreateInput.audience: close_friends. As noted above, this selection is now enforced when the story is viewed — fixed this session — only the owner and people on their close-friends list can see it.
Live
Features
- Start a live video broadcast (
GoLiveModal.tsx→createLiveStream, thenstartLiveStreamfrom the room page) - Viewers join and watch over LiveKit WebRTC, and send real-time comments and emoji reactions
- Host sees a live viewer count, comment feed, and reaction totals; can pin/delete comments and toggle their own mic/camera
- Broadcast ends when the host calls
endLiveStream(or disconnects — LiveKit-side, not yet backed by a server-side auto-end on disconnect)
Live reactions
Viewers can send one of 6 emoji reactions (👍❤️😂😮😢😡) via reactToLiveStream; calling it again with a different type just changes the caller's existing reaction, and removeLiveStreamReaction clears it. liveStreamReactionCounts returns live per-type totals.
Live coin gifts
Viewers send coin-priced gifts from a picker in the live room: liveGiftCatalog lists the sendable gifts and prices, sendLiveGift transfers coins from viewer → streamer (server-authoritative pricing, balance-checked through the coin ledger), and liveStreamGifts lists a stream's received gifts. Backed by the live_stream_gift table. See Stories & Live and the separate live-stream-gift.type.js.
Permissions
| Setting | Options |
|---|---|
| Who can see/join your live | Public / Private / Followers only / Close friends — LiveStreamVisibility, enforced server-side in both getLiveStream and joinLiveStream |
| Live comments | allowComments — enabled/disabled per stream |
| Sharing | allowSharing — enabled/disabled per stream |
| Coin gifts | Implemented — sendLiveGift / liveGiftCatalog / liveStreamGifts (server-priced, coin-ledger backed) |
Speaker requests (bring a viewer on)
A viewer can raise a hand to co-host; the streamer approves and the approved viewer gets a LiveKit publish token.
| Operation | Purpose |
|---|---|
requestLiveSpeaker(liveStreamId) | Viewer asks to speak — sets speakerStatus to requested |
liveStreamSpeakerRequests(liveStreamId, limit, offset) | Host lists pending (requested) hand-raises |
myLiveSpeakerStatus(liveStreamId) | The caller's own speaker state in this stream (none / requested / approved) |
respondToLiveSpeakerRequest(liveStreamId, userId, approve) | Host approves/denies; approval sets speakerStatus to approved, upgrading the viewer's next publish token |
This is a separate, live-stream-only implementation in live-stream.manager.js — it does not share code with the group-Call speaker-request flow (pendingSpeakerRequests/mySpeakerRequests/cancelSpeakerRequest in call.resolver.js/call.type.js). Unlike that flow, there's no cancelSpeakerRequest equivalent here — a viewer can't withdraw a pending live-stream request once sent.
Viewer & session helpers
liveStreamViewers(liveStreamId, limit, offset)— the current/past viewer roster.liveStreamerToken(liveStreamId)— LiveKit publish token for the host;guestLiveViewerToken(liveStreamId)— subscribe token for a viewer (incl. logged-out guests where allowed).saveLiveRecording(liveStreamId, recordingUrl)— attach a recording URL to an ended stream (feeds the Live Archive).
Real-time subscriptions
Live UIs stay current over graphql-ws subscriptions rather than polling:
| Subscription | Fires when |
|---|---|
liveStreamCommentAdded(liveStreamId) | A new live comment is posted |
liveStreamReactionsUpdated(liveStreamId) | Reaction totals change |
liveStreamUpdated(liveStreamId) | Stream state changes (viewer count, status, …) |
liveStreamsChanged | The set of currently-live streams changes (for the /live discovery rail) |
liveSpeakerRequestsChanged(liveStreamId) | A speaker request is raised/withdrawn (host UI) |
liveSpeakerStatusChanged(liveStreamId) | The caller's own speaker status changes (approved/denied) |
Privacy
Both Stories and Live respect the private account setting. If your account is private, only approved followers can see your content regardless of individual story settings.
Live stream data model
| Field | Description |
|---|---|
title / description | Stream metadata |
streamKey | Unique key for RTMP ingest |
streamUrl / playbackUrl | Source and HLS playback URLs |
thumbnailUrl | Preview thumbnail |
status | scheduled, live, ended, cancelled |
visibility | public, private, followers_only, close_friends |
scheduledStartTime / actualStartTime / endTime | Timing |
durationSeconds | Total stream duration |
viewerCount / peakViewerCount / totalViews | Audience stats |
likesCount / commentsCount / sharesCount | Engagement stats |
isRecorded / recordingUrl | VOD recording |
allowComments / allowSharing | Stream controls |
isMonetized | Flag on the model/schema; not wired to the coin-gift feature (sendLiveGift works regardless of this flag) |
GraphQL API
Defined in graphql/types/live-stream.type.js, resolved in graphql/resolvers/live-stream.resolver.js.
createLiveStream provisions a LiveKit room and returns a streamKey/playbackUrl, without going live yet. Call startLiveStream when the host is ready — this is the moment viewers can join. updateLiveStream edits metadata (title/description/thumbnail/visibility/allowComments/allowSharing) on a stream that hasn't ended.
endLiveStream terminates the broadcast and returns the final durationSeconds. cancelLiveStream removes a still-scheduled stream before it starts. deleteLiveStream removes a stream record outright. promoteLiveStream (owner-only) creates — or reuses, if already created — a companion announcement Post linking back to /live/{id}, since a LiveStream isn't itself a Post and can't be run through the normal post-promotion/ads flow directly; the returned Post can then be promoted like any other post. Wired to a "Promote" button in LiveRoomPage.tsx.
joinLiveStream records the caller as a viewer and returns their LiveKit connection info (token, url, roomName) in a single call — the resolver combines liveStreamManager.joinLiveStream and .getViewerToken internally. leaveLiveStream disconnects the viewer. banLiveViewer / unbanLiveViewer (owner-only) moderate the audience. liveStreamerToken is the owner-only equivalent used to preview/publish camera+mic.
sendLiveComment posts a real-time comment. pinLiveComment / unpinLiveComment (owner-only) highlight/unhighlight a comment at the top of the feed. deleteLiveComment removes a comment (the comment's own author, or the stream's host — checked in live-stream.manager.js#deleteLiveComment; there's no separate site-admin bypass here). reactToLiveStream sets the caller's emoji reaction (like/love/wow/haha/sad/angry — calling again with a different type just changes it); removeLiveStreamReaction clears it; liveStreamReactionCounts returns live per-type totals.
currentlyLive returns streams live right now, sorted by viewerCount. trendingLiveStreams applies a recency-weighted ranking over a timeframe window (hours, default 24). scheduledLiveStreams shows upcoming streams. userLiveStreams returns a user's stream history. liveStreamStats returns aggregate + viewer-stats + reaction-count metrics for a stream.
# Create and schedule a stream — provisions the LiveKit room, doesn't go live yet
mutation CreateLiveStream($input: LiveStreamCreateInput!) {
createLiveStream(input: $input) { id title playbackUrl status scheduledStartTime }
}
# Go live — viewers can join after this
mutation StartLiveStream($id: ID!) { startLiveStream(id: $id) { id status actualStartTime } }
# End the broadcast — records final duration
mutation EndLiveStream($id: ID!) { endLiveStream(id: $id) { id status durationSeconds } }
mutation CancelLiveStream($id: ID!) { cancelLiveStream(id: $id) { id status } }
mutation DeleteLiveStream($id: ID!) { deleteLiveStream(id: $id) }
# Viewer joins — returns everything livekit-client needs to connect
mutation JoinLiveStream($id: ID!) {
joinLiveStream(id: $id) { token url roomName }
}
mutation LeaveLiveStream($id: ID!) { leaveLiveStream(id: $id) }
mutation BanLiveViewer($liveStreamId: ID!, $userId: ID!) { banLiveViewer(liveStreamId: $liveStreamId, userId: $userId) }
mutation UnbanLiveViewer($liveStreamId: ID!, $userId: ID!) { unbanLiveViewer(liveStreamId: $liveStreamId, userId: $userId) }
mutation SendLiveComment($liveStreamId: ID!, $commentText: String!) {
sendLiveComment(liveStreamId: $liveStreamId, commentText: $commentText) { id commentText createdAt user { username } }
}
mutation PinLiveComment($commentId: ID!) { pinLiveComment(commentId: $commentId) { id isPinned } }
mutation UnpinLiveComment($commentId: ID!) { unpinLiveComment(commentId: $commentId) { id isPinned } }
mutation DeleteLiveComment($commentId: ID!) { deleteLiveComment(commentId: $commentId) }
# interactionType: like | love | wow | haha | sad | angry
mutation ReactToLiveStream($liveStreamId: ID!, $interactionType: String!) {
reactToLiveStream(liveStreamId: $liveStreamId, interactionType: $interactionType) { id interactionType }
}
mutation RemoveLiveStreamReaction($liveStreamId: ID!) { removeLiveStreamReaction(liveStreamId: $liveStreamId) }
# Discovery — currently broadcasting, sorted by viewer count
query CurrentlyLive($limit: Int) { currentlyLive(limit: $limit) { id title viewerCount thumbnailUrl user { username profilePicture isVerified } } }
# Trending — recency-weighted ranking
query TrendingLiveStreams($limit: Int) { trendingLiveStreams(limit: $limit) { id title viewerCount likesCount } }
# Upcoming scheduled streams
query ScheduledLiveStreams($limit: Int) { scheduledLiveStreams(limit: $limit) { id title scheduledStartTime user { username } } }
# A user's stream history
query UserLiveStreams($userId: ID!) { userLiveStreams(userId: $userId) { id title status durationSeconds totalViews } }
# Live metrics for an active stream
query LiveStreamStats($id: ID!) {
liveStreamStats(liveStreamId: $id) {
viewerCount peakViewerCount totalViews likesCount commentsCount
viewerStats { currentViewers totalViewers avgWatchTimeSeconds }
reactionCounts { like love wow haha sad angry total }
}
}
# Owner-only — publish token to preview camera / go live
query LiveStreamerToken($id: ID!) { liveStreamerToken(liveStreamId: $id) { token url roomName } }
Live archive
query LiveArchive($limit: Int, $offset: Int) {
liveArchive(limit: $limit, offset: $offset) {
id
title
thumbnailUrl
status
durationSeconds
viewerCount
peakViewerCount
totalViews
likesCount
commentsCount
isRecorded
recordingUrl
createdAt
}
}
liveArchive is scoped server-side to the caller's own status: 'ended' streams — there's no userId argument, unlike userLiveStreams in the (unexposed) broader Live API above. It backs the "Live" tab in Settings → Archive, alongside the existing Posts/Stories/Instants archive tabs.
Infrastructure
Live streams are powered by LiveKit (WebRTC SFU). When a stream is created, the backend provisions a LiveKit room (up to 10,000 participants). Hosts and viewers receive signed JWT access tokens that the frontend uses to connect directly to LiveKit.
Backend data models
Live stream activity is tracked across several data-access services:
| Service | Description |
|---|---|
live-stream.access-service.js | Live stream sessions |
live-stream-viewer.access-service.js | Viewer tracking |
live-stream-comment.access-service.js | Real-time comments |
live-stream-interaction.access-service.js | Emoji reactions |
live-stream-gift.access-service.js | Coin gift records (live_stream_gift table) |