You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Follow-up to #1134. That audit fixed what was broken in the Stream subsystem; this issue records what Stream sells that we have not assessed properly, so the next person to ask "should we turn on transcription / session timers / live streaming?" reads one page instead of repeating the research.
Every external claim below cites a URL that was fetched while writing it. Where Stream's documentation does not answer a question, this says so rather than guessing — there is a Verification gaps section at the end listing twelve such items, several of which are ship blockers rather than footnotes.
Noise cancellation is deliberately not covered here. It has its own issue, #1158, because it is parked work with a restoration path rather than an unbuilt capability. The billing research that came out of this pass is recorded there.
2. Other Stream capabilities worth assessing
Everything below was checked on 2026-08-14 against Stream's published pricing and documentation, and — where the question was about our own configuration — against the live Stream app rather than against the docs. The docs have been wrong about this app before, so measurement wins wherever the two disagree.
2.0 The rate card, because almost every decision below is really a pricing decision
Stream's video billing uses two different units and the difference matters more than the headline rates do. A participant minute is "the length of time an individual user is connected to a call", so it scales with headcount. A call minute is the wall-clock duration of the call itself, and Stream is explicit that "it doesn't matter how many participants are included on the call" (pricing guide, React pricing guide). Every add-on that runs server-side — recording, transcription, RTMP — is billed on call minutes, which is why those add-ons are disproportionately expensive for a two-person consultation: the meter does not care that our calls are small.
The video recording rate is the one number here that does not appear on the pricing page. The pricing page lists only "Recording (Audio Only)" at $1.50; the $6.00 video figure comes from Stream's own competitor-comparison post, which works an example at "18,000 recorded minutes, at $6 for every 1,000 minutes: $108" (getstream.io/blog/100ms-comparison). It is a Stream-authored source but it is marketing copy, not the rate card, and it should be confirmed before anyone budgets on it.
Against those rates, a single 60-minute two-person HD consultation costs the following, and the ordering is the point:
What
Arithmetic
Cost
The call itself
2 × 60 = 120 participant minutes at $1.50/1,000
$0.18
Noise cancellation, worst-case whole-call billing
120 participant minutes at $0.30/1,000
$0.04
Recording it
60 call minutes at $6.00/1,000
$0.36
Transcribing it
60 call minutes at $8.00/1,000
$0.48
Transcribing a consultation costs roughly two and a half times what it costs to hold the consultation. Recording it costs twice as much. This inverts the intuition that the video call is the expensive part, and it should be the first thing anyone remembers when they read the rest of this section. Stream gives every account "$100 per month in free usage of the Audio/Video API" (pricing guide), which is roughly 555 bare HD consultation-hours per month. Turn transcription on for every session and the same $100 buys about 151 — the free tier shrinks by a factor of three and a half for a feature nobody has asked for.
Chat is billed on a completely different axis. Stream Chat is priced per monthly active user, defined as "the number of active users that connect to Stream servers within a month", with a free Build tier capped at 1,000 MAU and 100 concurrent connections, and the first paid tier (Start) at $399 per month billed annually or $499 monthly for 10,000 MAU (getstream.io/chat/pricing). Every signed-in user who opens a DM or an event channel is an MAU. That means our single largest Stream cost event is not a feature we might switch on; it is the moment we cross 1,000 chat-connecting users, which converts a $0 line item into a $399 per month line item in one step. That cliff deserves a place in the launch financial model regardless of which capabilities below we adopt.
2.1 The live call type, measured rather than assumed
Before assessing anything, it is worth writing down what the default call type actually has switched on, because several capabilities below are already enabled server-side and simply unused by our client. This was read on 2026-08-14 from the live app via video_get_call against real calls, and default is the only call type in use (STREAM_CALL_TYPE in lib/stream/call-cid.ts is a constant).
Setting
Live value
Consequence
limits.max_duration_seconds
null
No server-side session cap. Nothing ends a call except our code or the last participant leaving.
limits.max_participants
null
No server-side headcount cap on webinars or classes.
session.inactivity_timeout_seconds
30
Stream's own end-of-session trigger, at the default.
transcription.mode
"available"
Transcription is enabled and startable today; nothing in our tree calls it.
transcription.closed_caption_mode
"available"
Same for captions.
transcription.translation.enabled
false
Off.
recording.mode
"available"
Server-initiated recording, which is what lib/stream/recording-service.ts uses.
recording.quality / recording.layout.name
"720p" / "spotlight"
Defaults, never configured by us.
broadcasting.enabled
true
HLS and RTMP are both enabled on the call type with auto_on: false.
ingress.enabled
true
RTMP ingest is available.
frame_recording.mode
"available"
Enabled, unused.
individual_recording.mode / raw_recording.mode
"available"
Enabled, unused.
audio.noise_cancellation.mode
"auto-on"
Confirms section 1 and supersedes the note in #1144 that this reads back undefined. It does not; it reads auto-on.
The useful observation is that most of these are available rather than disabled. Stream ships permissive call-type defaults, so "we have not built it" and "it cannot be started" are different statements. Anything holding a start-* capability can trigger the paid ones. That is the same class of exposure section 1 describes for noise cancellation, and it is worth a deliberate pass to set unused paid features to disabled rather than leaving them merely unbuilt.
2.2 Session timers — build it, but not for the reason we assumed
Free.settings.limits.max_duration_seconds does not appear as a line item anywhere on the pricing page or in the pricing guide. It is a call-type and per-call setting, not a metered service.
Stream's API documentation states the behaviour plainly: "When set, users will be automatically removed from a call when the time runs out and the call will be marked as ended" (calls API). It is settable per call, not only per call type, via settings_override on getOrCreate, which is the shape we would need (JavaScript session timers):
It can be extended before or during the call with call.update(), participants receive a call.updated event when it is, and the client can read the deadline from call.state.session?.timer_ends_at (JavaScript session timers, Flutter session timers). Extension is gated on the CHANGE_MAX_DURATION capability (React session timers cookbook).
The premise in #1144 is wrong in one specific and important way, and it changes the design.#1144 records session timers as the highest-value unbuilt item on the grounds that the SFU would end calls "at the slot boundary". It would not. The timer does not run to a wall-clock deadline and has no relationship to starts_at. It counts from the moment the session starts, which is when the first participant joins — Stream's Flutter guide states the duration counts from "the time the session starts (a participant joins the call)", and every other platform's version of the page says the same (Flutter session timers, JavaScript session timers).
That distinction is the difference between a safe feature and an incident. Our join windows admit a consultant 15 minutes early and a consultee 10 minutes early (CONSULTANT_JOIN_WINDOW_MS and CONSULTEE_JOIN_WINDOW_MS). If we set max_duration_seconds to the booked slot length, a consultant who joins early to check their camera starts the clock early, and Stream hard-terminates the session up to fifteen minutes before the booked end of a session the consultee paid for. Both parties are ejected mid-sentence with no recourse. That is precisely the failure mode lib/meeting.ts cites when it declines to send the field, and on this specific point the existing comment is correct rather than over-cautious.
The feature is still worth building, reframed as a billing and data-integrity backstop rather than as slot enforcement. Set it generously — booked duration, plus the early-join window, plus a grace period, so a 60-minute slot gets something like 90 to 120 minutes — and it buys three things that nothing else in the system provides:
It bounds the worst-case bill. A call nobody leaves — a forgotten browser tab, a phone in a pocket, a client that fails to tear down media — bills participant minutes indefinitely at $1.50 per 1,000. Today the only thing standing between us and an unbounded meter is session.inactivity_timeout_seconds: 30, which requires everyone to actually disconnect. A duration cap is the ceiling.
Effort: roughly one to two days. The change is a settings_override on the existing getOrCreate call in lib/meeting.ts, a duration computed from the anchor slot, and a decision about the grace window. The existing session-clock-copy.test.ts suite already models the overrun states and would need its "nothing ends the call for us" comment revisited.
Recommendation: pre-MVP, as a generous backstop only. It is free, it is a handful of lines, and it is the only defence we have against an unbounded bill and against a repeat of the orphaned-session class of bug. It must not be set to the booked slot length. This should be recorded against #1070, which currently defers it, and against #472, which describes the application-side half.
What I could not verify: whether expiry emits call.ended or call.session_ended on the webhook (the documentation says the call is "marked as ended" but names no event, and this is exactly the event we would be depending on); whether the timer resets if all participants leave and a new session starts within the same call; and the maximum accepted value of max_duration_seconds. All three are answerable in a single test call against the dev app and should be answered before this ships.
2.3 Background blur and virtual backgrounds — the clear win
Free.@stream-io/video-filters-web appears nowhere on the video pricing page and nowhere in the pricing guide. Background filters run entirely in the browser on MediaPipe and WebAssembly; no frame reaches Stream's servers for processing, so there is no plausible metering point. This is a strong inference from architecture plus absence from the rate card rather than an explicit "this is free" statement from Stream, and I did not find such a statement.
It is also already installed. As section 1 records, @stream-io/video-filters-web@0.7.4 is an exactly-pinned hard dependency of @stream-io/video-react-sdk@1.35.2, so it costs no new dependency, and PR #1143 already built the UI for it in CallEffectsMenu.tsx on top of useBackgroundFilters(), gated on isSupported with an honest "not available in this browser" message for Safari.
The distinction from noise cancellation is worth stating precisely, because the two shipped in one PR and are easy to conflate. Noise cancellation is deferred for three reasons: it is metered per participant minute with an unresolved billing scope, the call type is auto-on so registration alone starts the meter, and the 44MB @stream-io/audio-filters-web package breaks the Netlify deploy. Background blur has none of those three properties. It is not metered, it has no auto-on billing hazard, and its package is already in the tree and already deployed today. The 30MB of MediaPipe assets are a build-output concern, not a Lambda-bundling one, since they are copied to public/ — though anyone restoring this should confirm that claim against a real deploy given that section 1 could not obtain the actual Netlify error string.
Effort: under a day, and mostly deletion. The component exists; the work is extracting the blur half of CallEffectsMenu from the noise-cancellation half it currently ships alongside, and adding the lobby toggle that #1144 lists as deferred by #1143.
Recommendation: pre-MVP. Consultees take calls from bedrooms and kitchens. For a paid professional consulting product this is a privacy control rather than a decoration, which is how #1143 already framed it. It is free, the code is written, and it is the single highest ratio of perceived professionalism to engineering cost available in the Stream surface. The one thing to leave alone is virtual background images, which #1143 correctly deferred because they need a curated asset set; blur needs nothing.
2.4 Transcription and closed captions — PAID, $8.00 per 1,000 call minutes
This is the most expensive thing Stream will sell us relative to the value of the call it attaches to. At $8.00 per 1,000 call minutes (getstream.io/video/pricing), transcribing a 60-minute consultation costs $0.48 against $0.18 for the call. The pricing guide confirms the unit: "Recordings and Transcriptions are billed separately on Call Minutes" (pricing guide). Because it is a call-minute charge, a 1:1 consultation and a 50-person webinar of the same length cost exactly the same to transcribe, which is the one case where our small calls are penalised rather than rewarded.
The capability itself is mature. It supports 30-plus languages, startTranscription({ language: "en" }) and startClosedCaptions() are separate or combinable calls, transcripts are delivered as JSONL with start_time, stop_time, speaker_id and text per line, and Stream emits call.transcription_started, call.transcription_ready, call.transcription_failed, call.closed_captions_started, call.closed_caption, call.closed_captions_stopped and call.closed_captions_failed (transcriptions and captions). Both modes are already available on our live call type, so this is startable today by anything holding the capability.
Two operational details matter for us. Transcripts are "stored in Stream's S3 bucket and retained for two weeks" by default, which means a two-week clock we would have to beat with a transfer job — the same problem lib/stream/recording-transfer-service.ts already solves for recordings, so the pattern exists and would be copied rather than invented. And "the complete transcription file is uploaded only once at the end of the call", so transcription produces an artefact after the fact, whereas closed captions are the live experience. They are separate features with separate start calls even though the rate card prices them on one line.
Effort: two to four days for transcription including transfer-to-Supabase, retention, consent and a viewer. Closed captions alone are closer to a day since the React SDK renders them.
Recommendation: post-MVP, and then only closed captions, and only as an accessibility feature. Live captions have a real user served by them — a hard-of-hearing consultee, or anyone on a bad connection — and that user is served whether or not anyone ever reads a transcript. Stored transcripts are a different proposition: they are the raw material for AI session summaries, which is a genuinely attractive product idea and also a substantial commitment to consent, retention, storage, deletion and a privacy policy that currently describes none of it. Recording already carries a consent gate (#1134 P1-7, lib/stream/recording-consent.ts); transcription would need to be folded into it, not bolted beside it. Never turn either mode to auto-on — that is the same silent-meter hazard section 1 documents for noise cancellation, at twenty-six times the rate.
What I could not verify: whether running captions and transcription simultaneously bills the $8.00 once or twice. The pricing page lists them on a single combined line ("Transcriptions/Closed Captions"), which suggests once, but Stream never states it. Since startTranscription({ enable_closed_captions: true }) is a documented single call, the combined form is the one to use if we ever want both.
2.5 Recording layouts and composite options — a config change, not a project
Already paid for, at $6.00 per 1,000 call minutes for video (see the caveat in 2.0). We record today, so this cost is live rather than prospective, and it is worth internalising that recording a session costs twice what holding it does.
Stream offers three built-in layouts — single_participant, grid and spotlight — plus an external_css_url to restyle the built-in recorder and an external_app_url to substitute an entirely custom recording web app (recording calls). Our live call type sits on the defaults: spotlight at 720p. Spotlight "shows only one participant video at a time", selecting by pin, then screenshare, then dominant speaker.
For a 1:1 consultation, spotlight is arguably the wrong default and grid is the better one. A consulting session is a conversation between two people, and a recording that cuts to whoever is currently talking loses the listener's reaction — which for coaching and mentorship review is often the informative half. This is a one-field change on the call type, not an engineering project. It is worth ten minutes and a test recording to decide, and it is entirely reversible.
Custom layouts through external_app_url are a different matter: that means building and hosting a second web application whose only job is to be screenshotted by Stream's recorder, and then keeping it alive forever because a broken recorder app silently corrupts recordings. external_css_url is the cheap middle ground if branded recordings ever matter commercially — relevant to #366 and #367, which contemplate selling recordings.
Effort: ten minutes for the layout swap. Days to weeks for a custom recording app.
Recommendation: pre-MVP for the layout and quality review, since it is a settings change with a real quality impact and no cost delta. Never for external_app_url at one developer. external_css_url is post-MVP and only if recordings become a product rather than an artefact.
2.6 Live streaming, HLS and RTMP — PAID, $15.00 per 1,000 call minutes each way
Both broadcasting.enabled and ingress.enabled read true on our live call type, so this is available today and unused. RTMP in and RTMP out are each $15.00 per 1,000 call minutes (getstream.io/video/pricing) — the most expensive per-minute item Stream sells us, ten times the rate of the HD call itself.
The relevance to webinars is worth taking seriously and then dismissing. Stream's livestreaming documentation describes four ingest paths with different economics: WebRTC and WHIP have "~100ms" latency and cost nothing extra, while SRT and RTMP have "2s" latency and require transcoding, which is what the $15.00 pays for (livestreaming overview). HLS output adds roughly six seconds of latency. The entire point of that machinery is either ingesting a professional broadcast setup (OBS, hardware encoders) or fanning out to a viewer count too large for an SFU to handle as participants.
Our webinars are neither. They are consultants presenting to attendees who need to be able to ask questions, which is a WebRTC call with a presenter, not a broadcast. Adding RTMP would take a feature that currently costs $1.50 per 1,000 participant minutes and add $15.00 per 1,000 call minutes for the privilege of six seconds of latency and losing interactivity. The one genuine use case — a consultant who wants to present from OBS with slides and overlays — is served by WHIP at no extra cost, and by screensharing at no cost and no new code, and screensharing.enabled is already true.
Effort: days, plus an ongoing support burden the moment a consultant's stream key does not work.
Recommendation: never, for RTMP in and out. Revisit HLS only if a webinar ever needs more concurrent viewers than an SFU call supports, which is a problem we would be delighted to have and which no current product plan implies. Note that HLS and RTMP being enabled: true on the call type with auto_on: false means nothing is billing today, but they are startable by anything holding the capability — they belong on the list of paid features to set to disabled rather than leave merely unused.
2.7 Call quality, stats and the debugging dashboard — mostly free, mostly already there
There are three distinct things here and they have different values.
call.submitFeedback(rating, { reason, custom }) collects a 1-5 user rating that "later can be seen in the call stats section of our dashboard" (call quality rating). It is not separately priced. It is also close to redundant for us: #1022 already shipped a per-appointment CSAT flow into our own database, where the rating can be joined to the appointment, the consultant, the payment and the support thread. A second rating living in Stream's dashboard, unjoinable to any of that, is a worse version of a thing we have. The one argument for it is that Stream correlates the rating against its own WebRTC telemetry, which we cannot do — that is a genuine diagnostic capability, but it is a debugging convenience rather than a product feature.
Stream's dashboard call stats are included with the account and require no code at all. Stream publishes a walkthrough of using them to debug calls (debugging calls with the dashboard). This is free capability we are simply not using, and the correct response is to open it when a call quality complaint arrives, not to build anything.
The server-side stats API exposes get_active_calls_status, returning publisher and subscriber metrics — RTT, jitter, audio.concealment_pct, audio.packets_lost_pct, video.frame_encoding_time_ms, resolution and bitrate — as p50/p90 percentiles intended for alerting (platform stats). Wiring this into Sentry is technically straightforward and operationally premature: it is an alerting surface for a service with no traffic yet, and we already have Sentry coverage on the Stream subsystem from #901.
Effort: zero for the dashboard. Half a day for submitFeedback. Two to three days to build meaningful alerting on the stats API.
Recommendation: pre-MVP for using the dashboard, which needs no work at all — write it into the Stream runbook so the next quality complaint gets diagnosed instead of guessed at. Post-MVP and low priority for submitFeedback, and only if we want Stream-side telemetry correlation specifically. Never for custom stats alerting at one developer; the dashboard already does this and Sentry covers the errors.
What I could not verify: how long Stream retains call stats, and whether stats access varies by plan tier. Neither page states it.
2.8 Stream Moderation — the dashboard is free capability we are already paying for in dead code
This is the one item where an existing, known defect points straight at a Stream product. user.flagged and message.flagged are in HANDLED_EVENT_TYPES and are handled in lib/stream/webhook-dispatch.ts, but they never fire, because chat moderation events require a second Stream webhook scoped to the chat product and that webhook does not exist. The handler code is live and dead simultaneously.
Stream Moderation is a distinct product with its own dashboard and its own engines for text, image, video and audio (moderation docs). Its changelog shows heavy investment through 2026: audio moderation reaching GA in July 2026, Review Queue v2 becoming the default with p50 latency dropping from 1,783ms to 1ms, appeals management, escalation queues, homoglyph detection in block lists, and LLM-based rules (Stream changelog). Stream's chat pricing places "Advanced Moderation & Filters" on the paid Start tier and "AI Moderation" on Enterprise (getstream.io/chat/pricing), so the sophisticated end of this is gated behind money we are not spending.
The part that is not gated is the dashboard and basic flagging, and the correct sequence is unambiguous. Create the chat-scoped webhook first. That is a configuration change plus a verification pass, it makes existing handled code actually run, and it is a prerequisite for evaluating anything else here. Until it exists we have no flag volume at all, which means no data on whether moderation is a real problem for a consulting product — where every participant is identified, paying, and in a bilateral commercial relationship, and where the abuse profile looks nothing like a public community app.
Effort: half a day to a day for the webhook, mirroring scripts/stream/ensure-webhook-subscription.ts. The block-list and rule configuration is dashboard work, not code.
Recommendation: pre-MVP for the chat webhook, because dead handler code for a moderation path is worse than no moderation path — it reads as covered when it is not. Post-MVP for tuning any moderation rules, and only against real flag volume. Never budget for the Enterprise AI moderation tier on a product where users are identified and paying; the threat model does not justify it.
2.9 Stream Chat capabilities
Stream Chat has accumulated a large feature surface, and most of it is built for consumer community apps rather than for a two-party professional relationship. Taking the ones named in the brief, plus what the changelog surfaced:
Capability
Cost
Fit for a consulting product
Call
Threads
Included
Deliberately off already; inline quote-reply covers it
Never
Polls
Included
Real fit in webinars and classes, none in 1:1
Post-MVP
Reminders
Included
Duplicates our own notification system
Never
Drafts
Included
Genuinely useful, genuinely small
Post-MVP
Shared locations
Included
No use case; a privacy liability
Never
Campaigns
Paid tier
Overlaps our newsletter work; admin-privileged API
Never
Threads are already an explicit decision rather than an omission. ChatContainer.tsx records that no <Thread> is mounted and CustomMessage replaces the SDK message UI, with inline quote-reply covering the need. That comment also notes that turning replies off on the channel type in the Stream dashboard is the server-side half and is not code in this repo — worth doing, since it is currently a client-side-only suppression of a server-enabled feature.
Polls are the one chat feature with a clear product fit, and only in one place. A consultant running a webinar or class can poll attendees, which is a recognised format expectation for that kind of session and something attendees notice the absence of. In a 1:1 consultation it is meaningless. Stream's changelog shows polls under active development through 2026, including voter comments and dashboard integration (Stream changelog). Scope it to team-type event channels only, which is where lib/stream-channel-ids.ts already puts webinars and classes. Post-MVP, because webinars and classes are not the launch product.
Reminders need user_message_reminders enabled on the channel type, cap at 250 per user with one per message, emit reminder.created / reminder.updated / reminder.deleted / notification.reminder_due, and require Push V3 for the notification half (message reminders). That last clause is the answer. We do not run Stream push; our notification and email infrastructure is ours. Adopting reminders means either accepting an in-app-only reminder that nobody sees, or standing up Push V3 to deliver a notification our own system could already send. Never — this is a duplicate of a system we own.
Drafts are the sleeper. channel.createDraft(), getDraft(), deleteDraft() and client.queryDrafts(), with draft.updated and draft.deleted events, and the docs describe no enablement step or plan restriction (draft messages). The scenario is concrete: a consultee types a long question, navigates away, and loses it. Server-side drafts fix that across devices for very little code. It is not launch-critical and it is a nice thing to have. Post-MVP.
Shared locations need no analysis. There is no consulting workflow that involves knowing where the other party physically is, and adding location data to a platform handling paid professional relationships imports a privacy and compliance obligation in exchange for nothing. Never.
Campaigns are a bulk-messaging and segmentation product, priced such that "all paid plans include 3 times the procured MAU volume in message capacity", and carrying a warning that deserves quoting: "The Campaign API runs with administrative privileges and does not validate permissions—any valid user ID can be used as the sender_id" (campaign API). It requires a paid plan we are not on, it overlaps directly with the newsletter and double-opt-in work already done on the Waitlist model, and sending marketing messages into a professional consulting DM channel is a product mistake independent of the engineering. Never.
2.10 The stream-chat-react v14 major — not a feature, but it gates the features above
We run stream-chat-react@13.14.4 against a latest of 14.11.0. Stream's April 2026 release was a coordinated major across every chat SDK — React 14.0.0, iOS 5.0.0, Android 7.0.0, React Native 9.0.0 — described as a "complete visual refresh" with the headline breaking change being that MessageInput became MessageComposer (Stream changelog). I could not fetch the v14 migration guide itself; the versioned docs path returns 404 to a direct fetch, so the upgrade's real blast radius has not been read, only its summary.
This is relevant here because it is the gate. Anything new in chat — polls, drafts — is documented against the current SDK, and building against a major-behind version means either backporting or upgrading mid-feature. #1144 already flags this as an unwritten P3 item needing its own PR.
The complication specific to us is that ChatContainer.tsx replaces the SDK's message UI with CustomMessage and relies on Virtuoso message-list configuration. A release billed as a complete visual refresh with a design-token system will interact with heavy UI customisation, and the blast radius is the entire chat surface.
Effort: two to four days, most of it verification rather than code.
Recommendation: post-MVP, and before any new chat feature. There is no user-visible reason to do it now and every reason not to do it during launch preparation. But it should be sequenced ahead of polls or drafts rather than after, so we are not building twice.
2.11 Summary
Capability
Cost
Effort
Recommendation
Session timers, as a generous backstop
Free
1-2 days
Pre-MVP
Background blur
Free
Under a day
Pre-MVP
Chat-scoped moderation webhook
Free
0.5-1 day
Pre-MVP
Recording layout and quality review
No delta
10 minutes
Pre-MVP
Disabling unused paid call-type features
Free
Under a day
Pre-MVP
Dashboard call stats
Free
Zero
Pre-MVP (runbook only)
Closed captions, as accessibility
$8.00/1,000 call min
~1 day
Post-MVP
stream-chat-react v14 upgrade
Free
2-4 days
Post-MVP, before any chat feature
Drafts
Included
1-2 days
Post-MVP
Polls, event channels only
Included
2-3 days
Post-MVP
Stored transcripts and AI summaries
$8.00/1,000 call min
2-4 days plus policy
Post-MVP
submitFeedback
Free
0.5 day
Post-MVP, low priority
external_css_url recording branding
No delta
1-2 days
Post-MVP, only if recordings are sold
Everything in section 3
—
—
Never
3. Not worth it
These were investigated and rejected. Each entry states the reason so that the question does not get re-opened from scratch.
RTMP in and RTMP out. At $15.00 per 1,000 call minutes each direction (getstream.io/video/pricing), this is ten times the cost of the HD call itself, and it buys two seconds of added latency on ingest and roughly six on HLS output (livestreaming overview). Our webinars are interactive sessions with question-and-answer, not broadcasts. The only real use case — a consultant presenting from OBS — is covered by WHIP at no transcoding cost and by screensharing, which is already enabled, at no cost and no new code. Both are currently enabled: true on the call type and should be set to disabled.
HLS broadcasting for webinars. Same reasoning, plus one more: HLS exists to fan out to viewer counts an SFU cannot carry as participants. No current product plan implies those numbers. It also converts attendees into viewers, destroying the interactivity that is the entire reason someone books a webinar rather than watching a recording.
Frame recording. Captures periodic stills at a configurable capture_interval_in_seconds between 2 and 60, stored on Stream's CDN for two weeks, emitting call.frame_recording_started / _stopped / _failed, and built explicitly for AI-assisted live video moderation (frame recording). Its purpose is catching abuse in calls between strangers. Our calls are between an identified paying consultee and a vetted consultant with a commercial relationship and a support path. Periodically screenshotting private paid consultations to check for misconduct is a serious privacy imposition in service of a threat we do not have. It is available on the call type today and should be disabled. Its pricing is also undocumented, which is its own reason to leave it alone.
Individual and raw recording tracks. Both read available on the live call type. They produce per-participant media files instead of a composite, which serves post-production editing workflows. We transfer a single composite recording to Supabase already (lib/stream/recording-transfer-service.ts); multiplying the file count multiplies storage, transfer time, retention obligations and the consent surface, in exchange for an editing capability nobody has requested. Their pricing is not published either. Set to disabled.
End-to-end encryption.encryption.mode reads disabled. Turning it on sounds like an obvious win for a product handling sensitive consulting conversations, and it is not, because it is mutually exclusive with everything server-side: Stream cannot record, transcribe or moderate media it cannot decrypt. We record today, with a consent flow built in #1134 P1-7. Choosing E2EE means deleting recording. If a regulated use case ever demands it, that is a HIPAA-tier conversation with Stream sales and a different product, not a settings toggle.
Geofencing.geofencing.names is empty. It pins media to named regions, which serves data-residency requirements. We have none — settlement is INR-only by design but that is a payments constraint, not a data-residency one. Pinning regions can only make latency worse for a user base we have not yet mapped. Revisit only if a customer contract demands residency in writing.
Ring / incoming-call semantics. The call type carries ring timeouts (15s incoming, 15s auto-cancel, 18s missed). This models a telephony experience where one user calls another unexpectedly. Our sessions are booked, scheduled and entered from a dashboard at a known time. There is no ringing, and adding it would replace a calendar with an interruption.
Call thumbnails.thumbnails.enabled is false. Preview images of a call are a lobby and channel-list decoration. For scheduled 1:1 consultations there is no list of live calls to decorate, and generating imagery from private sessions is a privacy cost for a cosmetic gain.
2K and 4K video. At $6.00 and $12.00 per 1,000 participant minutes they cost four and eight times HD (React pricing guide) to transmit detail that a talking head does not contain and that most consultee webcams cannot produce. 720p is the right tier and is what the call type already sends. If anything, SD at $0.75 deserves evaluation as a bandwidth-constrained fallback, which Dynascale may already be handling.
Transcription translation.transcription.translation.enabled is false. It presupposes transcription, which is already post-MVP, and then adds cross-language consulting sessions — a market entry decision, not a feature toggle. Sequenced behind two things that have not happened.
Message reminders. Covered in 2.9. They require Push V3 to deliver the notification, and we do not run Stream push. Adopting them means building a second notification pipeline to duplicate one we own.
Chat threads. Already an explicit product decision recorded in ChatContainer.tsx, not an oversight: inline quote-reply covers the need and the SDK's thread panel is not mounted. Re-enabling would mean re-introducing UI that CustomMessage deliberately replaced. The only outstanding work is turning replies off on the channel type in the Stream dashboard so the server agrees with the client.
Shared locations. No consulting workflow needs the other party's physical location. It imports a privacy and compliance obligation for zero product value.
The Campaign API. Requires a paid Chat plan, overlaps the newsletter and double-opt-in work already built on the Waitlist model, and ships with a documented warning that it "runs with administrative privileges and does not validate permissions—any valid user ID can be used as the sender_id" (campaign API). Sending marketing into a paid professional DM channel is a product mistake before it is an engineering one.
Custom recording applications via external_app_url. Building and hosting a second web app whose only job is to be screenshotted by Stream's recorder, then keeping it alive indefinitely because its failure silently corrupts recordings, is not a one-developer commitment. external_css_url covers the realistic branding need at a fraction of the risk.
Custom call-quality alerting on the stats API.get_active_calls_status returns real percentile telemetry (platform stats), but Stream's own dashboard already presents it, and we have Sentry on the Stream subsystem from #901. Building a third observability surface for a service with no production traffic is work that produces alerts nobody will read.
Stream Feeds and Vision Agents. Both appear prominently in Stream's changelog and neither is a fit. Feeds is an activity-feed product for social applications; we have no social graph and no feed. Vision Agents is real-time AI video analysis with telephony integrations (Stream changelog) aimed at AI-participant use cases. Adding an AI participant to a paid human consulting session is a product pivot, not a feature.
Verification gaps
These claims could not be confirmed from primary sources and are flagged so nobody treats them as settled.
Whether session-timer expiry emits a webhook event. The API docs say the call "will be marked as ended" but name no event. Since the entire case for session timers rests on Stream stamping ended_at independently of our pipeline, this must be tested against the dev app before shipping.
Whether the session timer resets when a new session starts within the same call. Undocumented, and directly relevant to a rejoin after a network drop.
The maximum accepted value of max_duration_seconds. Not published.
Whether background filters are separately billed. Inferred from architecture (browser-side MediaPipe, no server processing) and from absence on the rate card. Stream never states it affirmatively.
The video recording rate. $6.00 per 1,000 call minutes comes from Stream's competitor-comparison blog, not from the pricing page, which lists only audio-only recording at $1.50. We already record, so this is a live cost worth confirming with support.
Whether running captions and transcription together bills $8.00 once or twice. The pricing page lists one combined line; Stream never addresses the simultaneous case.
Pricing for frame recording, individual recording and raw recording. Absent from the rate card entirely.
Call-stats retention and whether stats access varies by plan. Not stated on any page fetched.
Our current Stream plan tier and actual month-to-date spend. Requires dashboard access I do not have. Every cost figure here is a published rate applied to a hypothetical session, not a reading of our bill. In particular, whether we are still inside the $100 monthly credit is unverified.
Our current chat MAU. The 1,000-MAU free ceiling is the single largest Stream cost cliff identified in this issue, and I could not measure how close we are to it.
Whether Stream Moderation's engines require a paid Chat tier. The chat pricing page places "Advanced Moderation & Filters" on Start and "AI Moderation" on Enterprise, but the moderation docs state no plan requirement, and the two are not obviously describing the same thing.
The stream-chat-react v14 migration guide. The versioned documentation path 404s to a direct fetch, so the v14 assessment rests on Stream's changelog summary rather than on the upgrade guide. The effort estimate for that upgrade is correspondingly soft.
Follow-up to #1134. That audit fixed what was broken in the Stream subsystem; this issue records what Stream sells that we have not assessed properly, so the next person to ask "should we turn on transcription / session timers / live streaming?" reads one page instead of repeating the research.
Every external claim below cites a URL that was fetched while writing it. Where Stream's documentation does not answer a question, this says so rather than guessing — there is a Verification gaps section at the end listing twelve such items, several of which are ship blockers rather than footnotes.
Noise cancellation is deliberately not covered here. It has its own issue, #1158, because it is parked work with a restoration path rather than an unbuilt capability. The billing research that came out of this pass is recorded there.
2. Other Stream capabilities worth assessing
Everything below was checked on 2026-08-14 against Stream's published pricing and documentation, and — where the question was about our own configuration — against the live Stream app rather than against the docs. The docs have been wrong about this app before, so measurement wins wherever the two disagree.
2.0 The rate card, because almost every decision below is really a pricing decision
Stream's video billing uses two different units and the difference matters more than the headline rates do. A participant minute is "the length of time an individual user is connected to a call", so it scales with headcount. A call minute is the wall-clock duration of the call itself, and Stream is explicit that "it doesn't matter how many participants are included on the call" (pricing guide, React pricing guide). Every add-on that runs server-side — recording, transcription, RTMP — is billed on call minutes, which is why those add-ons are disproportionately expensive for a two-person consultation: the meter does not care that our calls are small.
The published rates, read from getstream.io/video/pricing and the React pricing guide:
The video recording rate is the one number here that does not appear on the pricing page. The pricing page lists only "Recording (Audio Only)" at $1.50; the $6.00 video figure comes from Stream's own competitor-comparison post, which works an example at "18,000 recorded minutes, at $6 for every 1,000 minutes: $108" (getstream.io/blog/100ms-comparison). It is a Stream-authored source but it is marketing copy, not the rate card, and it should be confirmed before anyone budgets on it.
Against those rates, a single 60-minute two-person HD consultation costs the following, and the ordering is the point:
Transcribing a consultation costs roughly two and a half times what it costs to hold the consultation. Recording it costs twice as much. This inverts the intuition that the video call is the expensive part, and it should be the first thing anyone remembers when they read the rest of this section. Stream gives every account "$100 per month in free usage of the Audio/Video API" (pricing guide), which is roughly 555 bare HD consultation-hours per month. Turn transcription on for every session and the same $100 buys about 151 — the free tier shrinks by a factor of three and a half for a feature nobody has asked for.
Chat is billed on a completely different axis. Stream Chat is priced per monthly active user, defined as "the number of active users that connect to Stream servers within a month", with a free Build tier capped at 1,000 MAU and 100 concurrent connections, and the first paid tier (Start) at $399 per month billed annually or $499 monthly for 10,000 MAU (getstream.io/chat/pricing). Every signed-in user who opens a DM or an event channel is an MAU. That means our single largest Stream cost event is not a feature we might switch on; it is the moment we cross 1,000 chat-connecting users, which converts a $0 line item into a $399 per month line item in one step. That cliff deserves a place in the launch financial model regardless of which capabilities below we adopt.
2.1 The live call type, measured rather than assumed
Before assessing anything, it is worth writing down what the
defaultcall type actually has switched on, because several capabilities below are already enabled server-side and simply unused by our client. This was read on 2026-08-14 from the live app viavideo_get_callagainst real calls, anddefaultis the only call type in use (STREAM_CALL_TYPEinlib/stream/call-cid.tsis a constant).limits.max_duration_secondsnulllimits.max_participantsnullsession.inactivity_timeout_seconds30transcription.mode"available"transcription.closed_caption_mode"available"transcription.translation.enabledfalserecording.mode"available"lib/stream/recording-service.tsuses.recording.quality/recording.layout.name"720p"/"spotlight"broadcasting.enabledtrueauto_on: false.ingress.enabledtrueframe_recording.mode"available"individual_recording.mode/raw_recording.mode"available"audio.noise_cancellation.mode"auto-on"undefined. It does not; it readsauto-on.backstage.enabledfalseencryption.mode"disabled"thumbnails.enabledfalsegeofencing.names[]The useful observation is that most of these are
availablerather thandisabled. Stream ships permissive call-type defaults, so "we have not built it" and "it cannot be started" are different statements. Anything holding astart-*capability can trigger the paid ones. That is the same class of exposure section 1 describes for noise cancellation, and it is worth a deliberate pass to set unused paid features todisabledrather than leaving them merely unbuilt.2.2 Session timers — build it, but not for the reason we assumed
Free.
settings.limits.max_duration_secondsdoes not appear as a line item anywhere on the pricing page or in the pricing guide. It is a call-type and per-call setting, not a metered service.Stream's API documentation states the behaviour plainly: "When set, users will be automatically removed from a call when the time runs out and the call will be marked as ended" (calls API). It is settable per call, not only per call type, via
settings_overrideongetOrCreate, which is the shape we would need (JavaScript session timers):It can be extended before or during the call with
call.update(), participants receive acall.updatedevent when it is, and the client can read the deadline fromcall.state.session?.timer_ends_at(JavaScript session timers, Flutter session timers). Extension is gated on theCHANGE_MAX_DURATIONcapability (React session timers cookbook).The premise in #1144 is wrong in one specific and important way, and it changes the design. #1144 records session timers as the highest-value unbuilt item on the grounds that the SFU would end calls "at the slot boundary". It would not. The timer does not run to a wall-clock deadline and has no relationship to
starts_at. It counts from the moment the session starts, which is when the first participant joins — Stream's Flutter guide states the duration counts from "the time the session starts (a participant joins the call)", and every other platform's version of the page says the same (Flutter session timers, JavaScript session timers).That distinction is the difference between a safe feature and an incident. Our join windows admit a consultant 15 minutes early and a consultee 10 minutes early (
CONSULTANT_JOIN_WINDOW_MSandCONSULTEE_JOIN_WINDOW_MS). If we setmax_duration_secondsto the booked slot length, a consultant who joins early to check their camera starts the clock early, and Stream hard-terminates the session up to fifteen minutes before the booked end of a session the consultee paid for. Both parties are ejected mid-sentence with no recourse. That is precisely the failure modelib/meeting.tscites when it declines to send the field, and on this specific point the existing comment is correct rather than over-cautious.The feature is still worth building, reframed as a billing and data-integrity backstop rather than as slot enforcement. Set it generously — booked duration, plus the early-join window, plus a grace period, so a 60-minute slot gets something like 90 to 120 minutes — and it buys three things that nothing else in the system provides:
ended_atregardless of our webhook pipeline. This is the argument that survives the correction. Stream SDK subsystem audit v2 — the webhook pipeline has never run, and any signed-in user can join any call #1134 found 1,417MeetingSessionrows with noendedAtand a webhook pipeline that had never processed a single event because the signing secret was missing from Netlify. A server-side duration cap is the only control in the stack that degrades gracefully through that outage, because it does not run on our infrastructure. PR feat(stream): say what the connection is doing, and stop leaking sessions #1155 fixed thesetDisconnectionTimeoutdefault that caused the orphaned rows, but that fix lives in our client; the timer does not.session.inactivity_timeout_seconds: 30, which requires everyone to actually disconnect. A duration cap is the ceiling.timer_ends_at(React session timers cookbook) — buttimer_ends_atis a clean single source of truth for the client clock, which today is derived independently inreadClock.Effort: roughly one to two days. The change is a
settings_overrideon the existinggetOrCreatecall inlib/meeting.ts, a duration computed from the anchor slot, and a decision about the grace window. The existingsession-clock-copy.test.tssuite already models the overrun states and would need its "nothing ends the call for us" comment revisited.Recommendation: pre-MVP, as a generous backstop only. It is free, it is a handful of lines, and it is the only defence we have against an unbounded bill and against a repeat of the orphaned-session class of bug. It must not be set to the booked slot length. This should be recorded against #1070, which currently defers it, and against #472, which describes the application-side half.
What I could not verify: whether expiry emits
call.endedorcall.session_endedon the webhook (the documentation says the call is "marked as ended" but names no event, and this is exactly the event we would be depending on); whether the timer resets if all participants leave and a new session starts within the same call; and the maximum accepted value ofmax_duration_seconds. All three are answerable in a single test call against the dev app and should be answered before this ships.2.3 Background blur and virtual backgrounds — the clear win
Free.
@stream-io/video-filters-webappears nowhere on the video pricing page and nowhere in the pricing guide. Background filters run entirely in the browser on MediaPipe and WebAssembly; no frame reaches Stream's servers for processing, so there is no plausible metering point. This is a strong inference from architecture plus absence from the rate card rather than an explicit "this is free" statement from Stream, and I did not find such a statement.It is also already installed. As section 1 records,
@stream-io/video-filters-web@0.7.4is an exactly-pinned hard dependency of@stream-io/video-react-sdk@1.35.2, so it costs no new dependency, and PR #1143 already built the UI for it inCallEffectsMenu.tsxon top ofuseBackgroundFilters(), gated onisSupportedwith an honest "not available in this browser" message for Safari.The distinction from noise cancellation is worth stating precisely, because the two shipped in one PR and are easy to conflate. Noise cancellation is deferred for three reasons: it is metered per participant minute with an unresolved billing scope, the call type is
auto-onso registration alone starts the meter, and the 44MB@stream-io/audio-filters-webpackage breaks the Netlify deploy. Background blur has none of those three properties. It is not metered, it has noauto-onbilling hazard, and its package is already in the tree and already deployed today. The 30MB of MediaPipe assets are a build-output concern, not a Lambda-bundling one, since they are copied topublic/— though anyone restoring this should confirm that claim against a real deploy given that section 1 could not obtain the actual Netlify error string.Effort: under a day, and mostly deletion. The component exists; the work is extracting the blur half of
CallEffectsMenufrom the noise-cancellation half it currently ships alongside, and adding the lobby toggle that #1144 lists as deferred by #1143.Recommendation: pre-MVP. Consultees take calls from bedrooms and kitchens. For a paid professional consulting product this is a privacy control rather than a decoration, which is how #1143 already framed it. It is free, the code is written, and it is the single highest ratio of perceived professionalism to engineering cost available in the Stream surface. The one thing to leave alone is virtual background images, which #1143 correctly deferred because they need a curated asset set; blur needs nothing.
2.4 Transcription and closed captions — PAID, $8.00 per 1,000 call minutes
This is the most expensive thing Stream will sell us relative to the value of the call it attaches to. At $8.00 per 1,000 call minutes (getstream.io/video/pricing), transcribing a 60-minute consultation costs $0.48 against $0.18 for the call. The pricing guide confirms the unit: "Recordings and Transcriptions are billed separately on Call Minutes" (pricing guide). Because it is a call-minute charge, a 1:1 consultation and a 50-person webinar of the same length cost exactly the same to transcribe, which is the one case where our small calls are penalised rather than rewarded.
The capability itself is mature. It supports 30-plus languages,
startTranscription({ language: "en" })andstartClosedCaptions()are separate or combinable calls, transcripts are delivered as JSONL withstart_time,stop_time,speaker_idandtextper line, and Stream emitscall.transcription_started,call.transcription_ready,call.transcription_failed,call.closed_captions_started,call.closed_caption,call.closed_captions_stoppedandcall.closed_captions_failed(transcriptions and captions). Both modes are alreadyavailableon our live call type, so this is startable today by anything holding the capability.Two operational details matter for us. Transcripts are "stored in Stream's S3 bucket and retained for two weeks" by default, which means a two-week clock we would have to beat with a transfer job — the same problem
lib/stream/recording-transfer-service.tsalready solves for recordings, so the pattern exists and would be copied rather than invented. And "the complete transcription file is uploaded only once at the end of the call", so transcription produces an artefact after the fact, whereas closed captions are the live experience. They are separate features with separate start calls even though the rate card prices them on one line.Effort: two to four days for transcription including transfer-to-Supabase, retention, consent and a viewer. Closed captions alone are closer to a day since the React SDK renders them.
Recommendation: post-MVP, and then only closed captions, and only as an accessibility feature. Live captions have a real user served by them — a hard-of-hearing consultee, or anyone on a bad connection — and that user is served whether or not anyone ever reads a transcript. Stored transcripts are a different proposition: they are the raw material for AI session summaries, which is a genuinely attractive product idea and also a substantial commitment to consent, retention, storage, deletion and a privacy policy that currently describes none of it. Recording already carries a consent gate (#1134 P1-7,
lib/stream/recording-consent.ts); transcription would need to be folded into it, not bolted beside it. Never turn either mode toauto-on— that is the same silent-meter hazard section 1 documents for noise cancellation, at twenty-six times the rate.What I could not verify: whether running captions and transcription simultaneously bills the $8.00 once or twice. The pricing page lists them on a single combined line ("Transcriptions/Closed Captions"), which suggests once, but Stream never states it. Since
startTranscription({ enable_closed_captions: true })is a documented single call, the combined form is the one to use if we ever want both.2.5 Recording layouts and composite options — a config change, not a project
Already paid for, at $6.00 per 1,000 call minutes for video (see the caveat in 2.0). We record today, so this cost is live rather than prospective, and it is worth internalising that recording a session costs twice what holding it does.
Stream offers three built-in layouts —
single_participant,gridandspotlight— plus anexternal_css_urlto restyle the built-in recorder and anexternal_app_urlto substitute an entirely custom recording web app (recording calls). Our live call type sits on the defaults:spotlightat720p. Spotlight "shows only one participant video at a time", selecting by pin, then screenshare, then dominant speaker.For a 1:1 consultation, spotlight is arguably the wrong default and
gridis the better one. A consulting session is a conversation between two people, and a recording that cuts to whoever is currently talking loses the listener's reaction — which for coaching and mentorship review is often the informative half. This is a one-field change on the call type, not an engineering project. It is worth ten minutes and a test recording to decide, and it is entirely reversible.Custom layouts through
external_app_urlare a different matter: that means building and hosting a second web application whose only job is to be screenshotted by Stream's recorder, and then keeping it alive forever because a broken recorder app silently corrupts recordings.external_css_urlis the cheap middle ground if branded recordings ever matter commercially — relevant to #366 and #367, which contemplate selling recordings.Effort: ten minutes for the layout swap. Days to weeks for a custom recording app.
Recommendation: pre-MVP for the layout and quality review, since it is a settings change with a real quality impact and no cost delta. Never for
external_app_urlat one developer.external_css_urlis post-MVP and only if recordings become a product rather than an artefact.2.6 Live streaming, HLS and RTMP — PAID, $15.00 per 1,000 call minutes each way
Both
broadcasting.enabledandingress.enabledreadtrueon our live call type, so this is available today and unused. RTMP in and RTMP out are each $15.00 per 1,000 call minutes (getstream.io/video/pricing) — the most expensive per-minute item Stream sells us, ten times the rate of the HD call itself.The relevance to webinars is worth taking seriously and then dismissing. Stream's livestreaming documentation describes four ingest paths with different economics: WebRTC and WHIP have "~100ms" latency and cost nothing extra, while SRT and RTMP have "2s" latency and require transcoding, which is what the $15.00 pays for (livestreaming overview). HLS output adds roughly six seconds of latency. The entire point of that machinery is either ingesting a professional broadcast setup (OBS, hardware encoders) or fanning out to a viewer count too large for an SFU to handle as participants.
Our webinars are neither. They are consultants presenting to attendees who need to be able to ask questions, which is a WebRTC call with a presenter, not a broadcast. Adding RTMP would take a feature that currently costs $1.50 per 1,000 participant minutes and add $15.00 per 1,000 call minutes for the privilege of six seconds of latency and losing interactivity. The one genuine use case — a consultant who wants to present from OBS with slides and overlays — is served by WHIP at no extra cost, and by screensharing at no cost and no new code, and
screensharing.enabledis alreadytrue.Effort: days, plus an ongoing support burden the moment a consultant's stream key does not work.
Recommendation: never, for RTMP in and out. Revisit HLS only if a webinar ever needs more concurrent viewers than an SFU call supports, which is a problem we would be delighted to have and which no current product plan implies. Note that HLS and RTMP being
enabled: trueon the call type withauto_on: falsemeans nothing is billing today, but they are startable by anything holding the capability — they belong on the list of paid features to set todisabledrather than leave merely unused.2.7 Call quality, stats and the debugging dashboard — mostly free, mostly already there
There are three distinct things here and they have different values.
call.submitFeedback(rating, { reason, custom })collects a 1-5 user rating that "later can be seen in the call stats section of our dashboard" (call quality rating). It is not separately priced. It is also close to redundant for us: #1022 already shipped a per-appointment CSAT flow into our own database, where the rating can be joined to the appointment, the consultant, the payment and the support thread. A second rating living in Stream's dashboard, unjoinable to any of that, is a worse version of a thing we have. The one argument for it is that Stream correlates the rating against its own WebRTC telemetry, which we cannot do — that is a genuine diagnostic capability, but it is a debugging convenience rather than a product feature.Stream's dashboard call stats are included with the account and require no code at all. Stream publishes a walkthrough of using them to debug calls (debugging calls with the dashboard). This is free capability we are simply not using, and the correct response is to open it when a call quality complaint arrives, not to build anything.
The server-side stats API exposes
get_active_calls_status, returning publisher and subscriber metrics — RTT, jitter,audio.concealment_pct,audio.packets_lost_pct,video.frame_encoding_time_ms, resolution and bitrate — as p50/p90 percentiles intended for alerting (platform stats). Wiring this into Sentry is technically straightforward and operationally premature: it is an alerting surface for a service with no traffic yet, and we already have Sentry coverage on the Stream subsystem from #901.Effort: zero for the dashboard. Half a day for
submitFeedback. Two to three days to build meaningful alerting on the stats API.Recommendation: pre-MVP for using the dashboard, which needs no work at all — write it into the Stream runbook so the next quality complaint gets diagnosed instead of guessed at. Post-MVP and low priority for
submitFeedback, and only if we want Stream-side telemetry correlation specifically. Never for custom stats alerting at one developer; the dashboard already does this and Sentry covers the errors.What I could not verify: how long Stream retains call stats, and whether stats access varies by plan tier. Neither page states it.
2.8 Stream Moderation — the dashboard is free capability we are already paying for in dead code
This is the one item where an existing, known defect points straight at a Stream product.
user.flaggedandmessage.flaggedare inHANDLED_EVENT_TYPESand are handled inlib/stream/webhook-dispatch.ts, but they never fire, because chat moderation events require a second Stream webhook scoped to thechatproduct and that webhook does not exist. The handler code is live and dead simultaneously.Stream Moderation is a distinct product with its own dashboard and its own engines for text, image, video and audio (moderation docs). Its changelog shows heavy investment through 2026: audio moderation reaching GA in July 2026, Review Queue v2 becoming the default with p50 latency dropping from 1,783ms to 1ms, appeals management, escalation queues, homoglyph detection in block lists, and LLM-based rules (Stream changelog). Stream's chat pricing places "Advanced Moderation & Filters" on the paid Start tier and "AI Moderation" on Enterprise (getstream.io/chat/pricing), so the sophisticated end of this is gated behind money we are not spending.
The part that is not gated is the dashboard and basic flagging, and the correct sequence is unambiguous. Create the chat-scoped webhook first. That is a configuration change plus a verification pass, it makes existing handled code actually run, and it is a prerequisite for evaluating anything else here. Until it exists we have no flag volume at all, which means no data on whether moderation is a real problem for a consulting product — where every participant is identified, paying, and in a bilateral commercial relationship, and where the abuse profile looks nothing like a public community app.
Effort: half a day to a day for the webhook, mirroring
scripts/stream/ensure-webhook-subscription.ts. The block-list and rule configuration is dashboard work, not code.Recommendation: pre-MVP for the chat webhook, because dead handler code for a moderation path is worse than no moderation path — it reads as covered when it is not. Post-MVP for tuning any moderation rules, and only against real flag volume. Never budget for the Enterprise AI moderation tier on a product where users are identified and paying; the threat model does not justify it.
2.9 Stream Chat capabilities
Stream Chat has accumulated a large feature surface, and most of it is built for consumer community apps rather than for a two-party professional relationship. Taking the ones named in the brief, plus what the changelog surfaced:
Threads are already an explicit decision rather than an omission.
ChatContainer.tsxrecords that no<Thread>is mounted andCustomMessagereplaces the SDK message UI, with inline quote-reply covering the need. That comment also notes that turningrepliesoff on the channel type in the Stream dashboard is the server-side half and is not code in this repo — worth doing, since it is currently a client-side-only suppression of a server-enabled feature.Polls are the one chat feature with a clear product fit, and only in one place. A consultant running a webinar or class can poll attendees, which is a recognised format expectation for that kind of session and something attendees notice the absence of. In a 1:1 consultation it is meaningless. Stream's changelog shows polls under active development through 2026, including voter comments and dashboard integration (Stream changelog). Scope it to
team-type event channels only, which is wherelib/stream-channel-ids.tsalready puts webinars and classes. Post-MVP, because webinars and classes are not the launch product.Reminders need
user_message_remindersenabled on the channel type, cap at 250 per user with one per message, emitreminder.created/reminder.updated/reminder.deleted/notification.reminder_due, and require Push V3 for the notification half (message reminders). That last clause is the answer. We do not run Stream push; our notification and email infrastructure is ours. Adopting reminders means either accepting an in-app-only reminder that nobody sees, or standing up Push V3 to deliver a notification our own system could already send. Never — this is a duplicate of a system we own.Drafts are the sleeper.
channel.createDraft(),getDraft(),deleteDraft()andclient.queryDrafts(), withdraft.updatedanddraft.deletedevents, and the docs describe no enablement step or plan restriction (draft messages). The scenario is concrete: a consultee types a long question, navigates away, and loses it. Server-side drafts fix that across devices for very little code. It is not launch-critical and it is a nice thing to have. Post-MVP.Shared locations need no analysis. There is no consulting workflow that involves knowing where the other party physically is, and adding location data to a platform handling paid professional relationships imports a privacy and compliance obligation in exchange for nothing. Never.
Campaigns are a bulk-messaging and segmentation product, priced such that "all paid plans include 3 times the procured MAU volume in message capacity", and carrying a warning that deserves quoting: "The Campaign API runs with administrative privileges and does not validate permissions—any valid user ID can be used as the sender_id" (campaign API). It requires a paid plan we are not on, it overlaps directly with the newsletter and double-opt-in work already done on the
Waitlistmodel, and sending marketing messages into a professional consulting DM channel is a product mistake independent of the engineering. Never.2.10 The
stream-chat-reactv14 major — not a feature, but it gates the features aboveWe run
stream-chat-react@13.14.4against a latest of14.11.0. Stream's April 2026 release was a coordinated major across every chat SDK — React 14.0.0, iOS 5.0.0, Android 7.0.0, React Native 9.0.0 — described as a "complete visual refresh" with the headline breaking change being thatMessageInputbecameMessageComposer(Stream changelog). I could not fetch the v14 migration guide itself; the versioned docs path returns 404 to a direct fetch, so the upgrade's real blast radius has not been read, only its summary.This is relevant here because it is the gate. Anything new in chat — polls, drafts — is documented against the current SDK, and building against a major-behind version means either backporting or upgrading mid-feature. #1144 already flags this as an unwritten P3 item needing its own PR.
The complication specific to us is that
ChatContainer.tsxreplaces the SDK's message UI withCustomMessageand relies onVirtuosomessage-list configuration. A release billed as a complete visual refresh with a design-token system will interact with heavy UI customisation, and the blast radius is the entire chat surface.Effort: two to four days, most of it verification rather than code.
Recommendation: post-MVP, and before any new chat feature. There is no user-visible reason to do it now and every reason not to do it during launch preparation. But it should be sequenced ahead of polls or drafts rather than after, so we are not building twice.
2.11 Summary
stream-chat-reactv14 upgradesubmitFeedbackexternal_css_urlrecording branding3. Not worth it
These were investigated and rejected. Each entry states the reason so that the question does not get re-opened from scratch.
RTMP in and RTMP out. At $15.00 per 1,000 call minutes each direction (getstream.io/video/pricing), this is ten times the cost of the HD call itself, and it buys two seconds of added latency on ingest and roughly six on HLS output (livestreaming overview). Our webinars are interactive sessions with question-and-answer, not broadcasts. The only real use case — a consultant presenting from OBS — is covered by WHIP at no transcoding cost and by screensharing, which is already enabled, at no cost and no new code. Both are currently
enabled: trueon the call type and should be set todisabled.HLS broadcasting for webinars. Same reasoning, plus one more: HLS exists to fan out to viewer counts an SFU cannot carry as participants. No current product plan implies those numbers. It also converts attendees into viewers, destroying the interactivity that is the entire reason someone books a webinar rather than watching a recording.
Frame recording. Captures periodic stills at a configurable
capture_interval_in_secondsbetween 2 and 60, stored on Stream's CDN for two weeks, emittingcall.frame_recording_started/_stopped/_failed, and built explicitly for AI-assisted live video moderation (frame recording). Its purpose is catching abuse in calls between strangers. Our calls are between an identified paying consultee and a vetted consultant with a commercial relationship and a support path. Periodically screenshotting private paid consultations to check for misconduct is a serious privacy imposition in service of a threat we do not have. It isavailableon the call type today and should bedisabled. Its pricing is also undocumented, which is its own reason to leave it alone.Individual and raw recording tracks. Both read
availableon the live call type. They produce per-participant media files instead of a composite, which serves post-production editing workflows. We transfer a single composite recording to Supabase already (lib/stream/recording-transfer-service.ts); multiplying the file count multiplies storage, transfer time, retention obligations and the consent surface, in exchange for an editing capability nobody has requested. Their pricing is not published either. Set todisabled.End-to-end encryption.
encryption.modereadsdisabled. Turning it on sounds like an obvious win for a product handling sensitive consulting conversations, and it is not, because it is mutually exclusive with everything server-side: Stream cannot record, transcribe or moderate media it cannot decrypt. We record today, with a consent flow built in #1134 P1-7. Choosing E2EE means deleting recording. If a regulated use case ever demands it, that is a HIPAA-tier conversation with Stream sales and a different product, not a settings toggle.Geofencing.
geofencing.namesis empty. It pins media to named regions, which serves data-residency requirements. We have none — settlement is INR-only by design but that is a payments constraint, not a data-residency one. Pinning regions can only make latency worse for a user base we have not yet mapped. Revisit only if a customer contract demands residency in writing.Ring / incoming-call semantics. The call type carries
ringtimeouts (15s incoming, 15s auto-cancel, 18s missed). This models a telephony experience where one user calls another unexpectedly. Our sessions are booked, scheduled and entered from a dashboard at a known time. There is no ringing, and adding it would replace a calendar with an interruption.Call thumbnails.
thumbnails.enabledisfalse. Preview images of a call are a lobby and channel-list decoration. For scheduled 1:1 consultations there is no list of live calls to decorate, and generating imagery from private sessions is a privacy cost for a cosmetic gain.2K and 4K video. At $6.00 and $12.00 per 1,000 participant minutes they cost four and eight times HD (React pricing guide) to transmit detail that a talking head does not contain and that most consultee webcams cannot produce. 720p is the right tier and is what the call type already sends. If anything, SD at $0.75 deserves evaluation as a bandwidth-constrained fallback, which Dynascale may already be handling.
Transcription translation.
transcription.translation.enabledisfalse. It presupposes transcription, which is already post-MVP, and then adds cross-language consulting sessions — a market entry decision, not a feature toggle. Sequenced behind two things that have not happened.Message reminders. Covered in 2.9. They require Push V3 to deliver the notification, and we do not run Stream push. Adopting them means building a second notification pipeline to duplicate one we own.
Chat threads. Already an explicit product decision recorded in
ChatContainer.tsx, not an oversight: inline quote-reply covers the need and the SDK's thread panel is not mounted. Re-enabling would mean re-introducing UI thatCustomMessagedeliberately replaced. The only outstanding work is turningrepliesoff on the channel type in the Stream dashboard so the server agrees with the client.Shared locations. No consulting workflow needs the other party's physical location. It imports a privacy and compliance obligation for zero product value.
The Campaign API. Requires a paid Chat plan, overlaps the newsletter and double-opt-in work already built on the
Waitlistmodel, and ships with a documented warning that it "runs with administrative privileges and does not validate permissions—any valid user ID can be used as the sender_id" (campaign API). Sending marketing into a paid professional DM channel is a product mistake before it is an engineering one.Custom recording applications via
external_app_url. Building and hosting a second web app whose only job is to be screenshotted by Stream's recorder, then keeping it alive indefinitely because its failure silently corrupts recordings, is not a one-developer commitment.external_css_urlcovers the realistic branding need at a fraction of the risk.Custom call-quality alerting on the stats API.
get_active_calls_statusreturns real percentile telemetry (platform stats), but Stream's own dashboard already presents it, and we have Sentry on the Stream subsystem from #901. Building a third observability surface for a service with no production traffic is work that produces alerts nobody will read.Stream Feeds and Vision Agents. Both appear prominently in Stream's changelog and neither is a fit. Feeds is an activity-feed product for social applications; we have no social graph and no feed. Vision Agents is real-time AI video analysis with telephony integrations (Stream changelog) aimed at AI-participant use cases. Adding an AI participant to a paid human consulting session is a product pivot, not a feature.
Verification gaps
These claims could not be confirmed from primary sources and are flagged so nobody treats them as settled.
ended_atindependently of our pipeline, this must be tested against the dev app before shipping.max_duration_seconds. Not published.stream-chat-reactv14 migration guide. The versioned documentation path 404s to a direct fetch, so the v14 assessment rests on Stream's changelog summary rather than on the upgrade guide. The effort estimate for that upgrade is correspondingly soft.