
Stream Builder
- 530 installs
- 17 repo stars
- Updated August 4, 2026
- getstream/agent-skills
stream-builder is a Claude Code skill that integrates GetStream chat or activity feeds by configuring apps, tokens, channels, webhooks, and agent workflows that send or moderate real-time messages.
About
stream-builder is a GetStream agent skill from getstream/agent-skills for embedding real-time chat or activity feeds into applications. It walks developers through app configuration, user and server tokens, channel creation, webhook endpoints, and agent-driven send or moderate flows so messages propagate reliably across clients. Developers reach for stream-builder when adding team chat, social feeds, or AI agents that post, react to, or moderate Stream channels instead of building a custom WebSocket stack. The skill emphasizes production integration concerns—authentication, channel schemas, webhook verification, and agent orchestration—so backend and client teams share consistent Stream setup.
- Stream app and API key setup
- User token and permission models
- Channel types and membership rules
- Webhook event handling
- Agent moderation and send patterns
Stream Builder by the numbers
- 530 all-time installs (skills.sh)
- Ranked #802 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/getstream/agent-skills --skill stream-builderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 530 |
|---|---|
| repo stars | ★ 17 |
| Last updated | August 4, 2026 |
| Repository | getstream/agent-skills ↗ |
How do you integrate GetStream chat with agent moderation workflows?
Integrate GetStream chat or activity feeds—configure apps, tokens, channels, webhooks, and agent workflows that send or moderate real-time messages.
Who is it for?
Developers adding GetStream Chat or Activity Feeds to SaaS products who need tokens, channels, webhooks, and agent send or moderate flows in one integration pass.
Skip if: Teams building chat from scratch on raw WebSockets or projects that only need email notifications without real-time messaging infrastructure.
When should I use this skill?
A developer asks to set up GetStream chat, activity feeds, channel tokens, webhooks, or agent workflows for real-time messaging.
What you get
Configured GetStream app with tokens, channel definitions, webhook handlers, and agent workflows for sending or moderating real-time messages.
- Stream app configuration
- Channel and webhook integration code
Files
Stream Builder - scaffold + enhance
Read first (every session): Glob../stream/SKILL.md. If the path is empty, install withgetstream skills stream(no confirmation, just run). Then Read `../stream/RULES.md` - it includes a Peer skills table for installing/invoking any other pack skills on demand.
This skill covers three flows:
- Track A - Scaffold a new app: Steps 0-7 below. Use when the cwd is empty / new and the user said "build me a ... app".
- Track E - Enhance an existing app: see `enhance.md`. Skips scaffold + theme; reuses the same SDK wiring and component blueprints.
- Track F - Audit an existing integration (read-only): for "audit/review my video integration", "check my app against best practices", "is my video app production-ready?", "what am I missing before launch?". Load the Integration best-practices audit section in `references/VIDEO.md` and follow its protocol. Skip onboarding, auth, the CLI, and all build steps - this track does not scaffold, install, or run the CLI; it only reads the app and reports findings. Fix issues only if the user then asks.
---
Start
Track F (audit) skips this entire section. A read-only best-practices review runs no onboarding, no auth, no CLI, and none of Steps 0-7 - go straight to the Integration best-practices audit section in `references/VIDEO.md` and report findings. Only fall into the build flow below if the user asks you to fix what the audit found.
Announce the network plan once (Trust readout below), then immediately start executing Steps 0-7 - no interactive prompts at the start (the user has authorized the build by asking for it).
Trust readout (announce, then continue on the same turn - do not wait)
Before the first network command, print this verbatim to the user, then proceed straight into Step 0 without stopping for a reply:
Scaffolding now. Network calls you'll see:
- npx shadcn@latest ... (Vercel) - scaffold + UI components from npm.-npm install <stream-packages> --legacy-peer-deps- Stream SDKs from npm (stream-chat-react,@stream-io/video-react-sdk, etc.).
-getstream env- local CLI, no network; writes.env(gitignored by the Next.js scaffold's default; Task B verifies).
>
Interrupt me at any point if something looks wrong. The only step that pauses for explicit consent is the optional third-party skill packs in Task A.2.
Full per-command audit (publisher, why unpinned, what each writes): section Install trust & integrity below. The user's continued silence after the readout is implicit consent for this scaffold; an objection or stop instruction aborts the run.
Shadcn/ui is always installed during Step 3. Third-party frontend skills (vercel-react-best-practices, web-design-guidelines, frontend-design) are installed only with explicit user consent - see Task A.2 for the disclosure script. If the user declines, Step 4 proceeds using Stream references only. Precedence (when the skills are present): Stream references win for SDK wiring; frontend skills guide generic React / UI polish.
---
Install trust & integrity
The builder runs three classes of network-touching commands. Each is listed here so a reviewer can audit before approving. (The getstream CLI itself is installed by the user from getstream.io, not by the builder.)
| Command | Publisher | Why unpinned | What it writes |
|---|---|---|---|
npx shadcn@latest init ... (Task A) | Vercel - `shadcn-ui/ui` | Scaffolder; @latest is the maintainer's documented usage. Pinning ships outdated scaffolds. | Project files in cwd. Next.js scaffold's .gitignore ignores .env* by default. |
npx shadcn@latest add ... (Task A.1) | Vercel - same source as above | Same scaffolder; component sync depends on registry parity. | Component files under components/ui/. |
npm install <stream-packages> --legacy-peer-deps (Task C) | GetStream (npm) for @stream-io/* and stream-chat-react; transitive deps via standard npm trust | Latest published versions of GetStream's own SDKs - same trust model as the CLI itself. | Modules under node_modules/. Runtime SDKs + transitive deps. |
npx skills add <github> (Task A.2) | vercel-labs/agent-skills and anthropics/skills | Optional. Markdown-only skill packs; npx skills add is the published install path. | Markdown files in the user's skills directory. Gated by explicit user consent in Task A.2 - never runs without an affirmative answer. |
getstream env (Task B) | GetStream (local CLI) | n/a (local CLI, no network at this step) | .env in the project root with STREAM_API_KEY + STREAM_API_SECRET. Task B verifies .gitignore covers .env* before writing (Next.js scaffold's default already does). The agent never reads .env (RULES.md > Secrets). |
Reviewer checklist:
- All
npxinvocations resolve to the publishers listed above; substitute a different publisher and the install fails. npx skills addruns only after the disclosure prompt in Task A.2 and an explicit user "yes.".envis written by the Stream CLI directly, not by the agent, and is not transmitted into the conversation.- If the user wants to pin a specific shadcn version, replace
@latestwith@<version>in Tasks A and A.1.
---
Builder Steps
Execute phases in order (later steps depend on earlier ones). Do not run independent phases in parallel. Shell discipline (one bash -c per phase, no bash -ce, browser sign-in standalone) lives in the stream skill's `RULES.md` > Shell discipline.
Step 0: Package manager
Always use npm. Never use bun.
Step 1: Initialize the project
Run getstream init. It authenticates, then lets you select or create the org and app and writes the project credentials - follow its prompts and output. If the app uses Feeds, choose a Feeds v3 region when getstream init offers the region list (other regions default to legacy v2). If getstream isn't installed, ask the user to install it from https://getstream.io and wait - never fetch or run an install script. Browser sign-in must be its own invocation (RULES.md > Shell discipline).
Step 2: Theme pick
Ask the user which Shadcn theme they'd like before scaffolding:
Quick theme pick: I can use a random shadcn theme, or you can design your own at ui.shadcn.com/create and share the--presetvalue (e.g.--preset b1Gdi7z7r). Want a random one or do you have a preset?
STOP here and wait for the user's answer. Do not continue with scaffolding or any other steps until the user responds. Asking a question and continuing to work in parallel is confusing - the user misses the question as output scrolls past.
- User provides a preset -> store it for Task A scaffold command.
- User says random / doesn't care / wants to move on -> pick a random preset from
nova,vega,maia,lyra,mira,luma.
Step 3: Scaffold + .env + SDKs + Configure - SEQUENTIALLY
Scaffold order
Order:
1. Step 1: getstream init (auth + org/app + credentials). 2. Step 2: Theme pick (wait for answer). 3. Task A: Scaffold with Shadcn + Next.js using the chosen preset. 4. Task A.1: Add base Shadcn components. 5. Task A.2: Disclose + ask about third-party frontend skill installs; install only with user consent. 6. Continue with Task B (.env), Task C (SDKs), Task D (CLI config).
Task A: Scaffold - scaffolds Next.js + Tailwind + Shadcn/ui (Base UI) into the current directory. Use the theme preset chosen in Step 2.
The scaffold command creates a new directory, so we scaffold into a temporary .scaffold subdirectory and move everything up:
npx shadcn@latest init -t next -b base -n .scaffold --no-monorepo -p <random-preset> && mv .scaffold/* .scaffold/.* . 2>/dev/null; rm -rf .scaffoldTask A.1: Add base Shadcn components:
npx shadcn@latest add button input textarea card avatar badge separatorAdd more components as the use case requires (e.g. dialog, dropdown-menu, tabs, popover).
Task A.2: Frontend skills - third-party skill packs. You must disclose and ask before installing. Do NOT construct your own command variant.
Print this disclosure verbatim, then stop and wait for the user's answer:
I'd like to install three third-party skill packs that improve generic UI quality:
- vercel-react-best-practices - from `vercel-labs/agent-skills`- web-design-guidelines - from `vercel-labs/agent-skills`- frontend-design - from `anthropics/skills`>
The packs are markdown only - no scripts execute. If you say yes, I'll runnpx skills add ... -yonce per pack from those GitHub repos at their currentmainbranch (-yskips the installer's own confirmation since you've consented here). These aren't required - Stream reference files cover SDK wiring either way. Install them?
- User agrees -> run:
npx skills add https://github.com/vercel-labs/agent-skills --skill vercel-react-best-practices -y && npx skills add https://github.com/vercel-labs/agent-skills --skill web-design-guidelines -y && npx skills add https://github.com/anthropics/skills --skill frontend-design -y- User declines -> skip silently and continue to Task B. Do not retry, do not bring it up again this session.
- Install fails -> continue with Stream reference files only; mention the failure briefly.
Do not modify layout.tsx or globals.css after scaffold - use Shadcn's defaults as-is (RULES.md > Theme).
Task B: .env - run AFTER scaffold so the .env lands inside the project directory.
*First, verify `.env is gitignored** (the stream skill's [RULES.md](../stream/RULES.md) > Secrets). The Next.js scaffold's default already includes it; this is a safety net for projects whose .gitignore` was hand-edited or doesn't yet exist:
bash -c 'test -f .gitignore && grep -qE "^\.env" .gitignore || echo ".env*" >> .gitignore'Then write secrets:
getstream envgetstream env detects the Next.js project and writes NEXT_PUBLIC_STREAM_API_KEY + STREAM_API_SECRET to .env.local. The secret is server-side only - used by /api/token to mint tokens, never in the client bundle. The public API key may be read client-side from NEXT_PUBLIC_STREAM_API_KEY or returned via /api/token. The agent never reads .env.local (RULES.md > Secrets).
Task C: Install Stream SDKs + verify icons - Only what the use case needs:
# Chat: stream-chat stream-chat-react
# Video: @stream-io/video-react-sdk
# Feeds: @stream-io/feeds-react-sdk
# Server: @stream-io/node-sdk
npm install <packages> --legacy-peer-depsAfter installing SDKs, verify an icon package is available. Some Shadcn presets bundle one, others don't:
node -e "const p=['lucide-react','@phosphor-icons/react','@hugeicons/react'];console.log(p.some(m=>{try{require.resolve(m);return true}catch{return false}})?'ICONS_OK':'NO_ICONS')"If NO_ICONS, install lucide-react: npm install lucide-react --legacy-peer-deps. If an icon package is already present, use that one throughout the app - do not install a second.
Task D: Configure Stream - run the CLI commands from the relevant references/<Product>.md (App Integration -> Setup) for each product the use case needs.
Step 4: Generate code and UI
Load [`builder-ui.md`](builder-ui.md) and only the relevant `references/<Product>.md` header + references/<Product>-blueprints.md for the sections you are implementing - not every reference file. For multi-product apps (Chat + Video, Chat + Feeds, Video + Feeds, etc.), also load [`references/CROSS-PRODUCT.md`](references/CROSS-PRODUCT.md) before writing AppShell - it has the canonical multi-client provider hierarchy and an error -> cause -> fix table. If a use-case recipe matched (see Use-case recipes), load that recipe and its `load_with` references and follow it as the build plan instead of inferring the product set yourself.
Step 5: Verify
Type-check first (reports ALL errors at once, ~3s):
npx tsc --noEmitFix all type errors. Then run the full build:
npx next buildFix any remaining errors. Do NOT skip tsc --noEmit - it catches every type error in one pass, while next build stops at the first error per file and requires multiple rebuild cycles.
Step 6: Start dev server
Pick a random 5-digit port (10000-65535). Run the server using run_in_background:
PORT=$((RANDOM % 55536 + 10000))
npx next dev -p $PORTImportant: The dev server is a long-running process. When run in the background it will eventually emit a "completed" notification - this does not mean the server stopped. The server is still running and serving requests. Do not respond to the background-task completion notification by telling the user the server has stopped. If you receive that notification after Step 7, ignore it silently - do not output anything.
Step 7: Summary
Show what was created: org, app, resources, files. Include the local URL. Do NOT say "you can now start the dev server" - it's already running.
End with:
Open http://localhost:<PORT>, enter a username, and start testing. Open a second tab with a different username to test multi-user interactions.---
Use Case Matching
Only build with the products the user explicitly mentions. If unclear, ask.
| User says | Use case | Products |
|---|---|---|
| "Twitch", "YouTube Live", "Kick", "livestream" | Livestreaming | Video + Chat + Feeds |
| "Zoom", "Google Meet", "video call", "meeting" | Video Conferencing | Video [+ Chat] |
| "Slack", "Discord", "team chat", "channels" | Team Messaging | Chat |
| "WhatsApp", "iMessage", "DM", "messaging" | Direct Messaging | Chat [+ Video] |
| "Instagram", "Twitter", "social feed", "Reddit" | Social Feed | Feeds + Chat |
Moderation is configured via CLI during setup only. Never build moderation review UI in the app (RULES.md > Moderation is Dashboard-only) - review happens in the Stream Dashboard.
Use-case recipes
Some requests map to a use-case recipe: a drop-in build plan for a specific product (e.g. an AI support agent) that keeps this skill generic. Before Step 4, read `references/use-cases/_use-cases.yaml`. If the request matches a recipe's signals, load that recipe file and follow it as the build plan - the recipe declares the Stream products to scaffold, the product references to load_with, and the decisions to ask. If nothing matches, use the generic product blueprints above.
Adding a use-case is a new file under references/use-cases/ plus an entry in _use-cases.yaml - never a change to this SKILL.md.
Video apps - decide the `video_primary_use_case` here too. When the Use Case Matching table (or a recipe) selects Video, also decide the app's persistent video_primary_use_case value now, while you are already reasoning about the video flavor, using the table + precedence in `references/VIDEO.md` > Primary use case. Record it and carry it to Video setup (Task D), where it is written once via the CLI. Deciding it at this step stops it from silently defaulting to the call type later - e.g. a Whatnot-style live-shopping app uses the livestream call type but sets video_primary_use_case: live-shopping, not livestreaming. This is a label, not product selection: it is backend onboarding/display metadata that does not change which products you build or which blueprints you load - the table and recipes still decide that.
---
Page Flow
Every app needs a clear navigation structure. Users should always understand where they are and what they can do. Never drop a user into a camera/mic prompt, an empty state, or a feature-heavy screen without context.
Principle: Hub-first
After login, land on a hub - a home screen that shows what's happening and lets the user choose their path. The hub is the anchor; everything else is a destination the user navigates to intentionally.
Flow by use case
Livestreaming (Twitch, YouTube Live, Kick):
Login -> Feed hub (live streams + posts) -> Watch a stream (viewer: video + chat, no camera)
-> Go Live (explicit action -> then camera/mic setup -> streaming)- The feed hub shows live streams (if any) as prominent cards, plus regular posts below
- Clicking a live card opens the watch view - video player + chat as a viewer. No camera permissions.
- "Go Live" is a deliberate action (button in header or dedicated screen). Only THEN prompt for camera/mic. The streamer sees a setup/preview before going live.
- Viewers and streamers are the same user type - the difference is the action they take, not the page they land on.
Video Conferencing (Zoom, Google Meet):
Login -> Lobby (list of calls or "start a call") -> Join call (camera/mic preview -> join)- Land on a lobby or call list - not directly in a call.
- Joining a call shows a preview screen (camera/mic toggles) before connecting. The user opts in.
Team Messaging (Slack, Discord):
Login -> Channel list + active channel -> Browse/search channels- Land on the channel list with the most recent channel open (or a welcome state if no channels).
Direct Messaging (WhatsApp, iMessage):
Login -> Conversation list -> Open a conversation -> Start new conversationSocial Feed (Instagram, Twitter):
Login -> Feed hub (follow users + composer + tabs: Timeline | My Posts) -> Comments -> User profiles- The user posts to their own
user:<userId>feed and reads fromtimeline:<userId>(aggregates followed users' posts) - Feed hub tabs: Use a
Tabscomponent with two views: - Timeline (default) - shows
timeline:<userId>(posts from followed users) - My Posts - shows
user:<userId>(the current user's own posts) - Refresh button: Place a refresh/reload button next to the tabs. On click, re-call
feed.getOrCreate({ watch: true })on the active feed to re-fetch the latest activities. This gives users an explicit way to refresh after follows or if real-time events are missed. - A Follow User input (username + follow button) must be visible so users can populate their timeline
- Without following, the timeline is permanently empty - this component is not optional
- Follow wiring: The Follow component must receive the timeline feed instance and call
timelineFeed.follow('user:targetId')- notclient.follow(). Using the feed instance keepsuseFeedActivities()in sync so the timeline updates immediately after following.
Key rules
- Camera/mic: opt-in only. Never request permissions on page load. Only when the user takes an explicit action (Go Live, Join Call).
- No empty ambiguity. If there's no content yet, show a clear empty state that tells the user what to do ("No live streams yet - be the first to Go Live").
- Navigation is visible. The user should always be able to get back to the hub. Use the App Header or a sidebar for navigation.
- One primary action per screen. The hub's primary action is browsing/discovering. The watch screen's primary action is viewing. The Go Live screen's primary action is streaming. Don't mix them.
---
Cross-Product Integration
When building apps that combine multiple products, read each relevant references/<Product>.md App Integration section. Key patterns:
- Combined token route:
/api/tokenreturns tokens for each product ({ chatToken, videoToken, feedToken, apiKey }). Upsert only the requesting user - never seed demo users. - Video + Feeds (Livestreaming): Feed hub separates
type === "live"activities as prominent live cards. "Go Live" posts a live activity via/api/feed/live. "End Stream" removes it. - Video + Chat (Livestreaming): Chat alongside video on the watch screen. Use
livestreamchannel type - one channel per stream, keyed by call ID. Create the chat channel in the/api/tokenroute. - Moderation (all use cases): Run Moderation CLI setup commands from
references/MODERATION.md(App Integration -> Setup), adjusting channel type name. Never build moderation review UI (RULES.md > Moderation is Dashboard-only) - review happens in the Stream Dashboard.
---
Reference file paths
Blueprint files live under agent-skills/skills/stream-builder/references/ inside the Stream skill pack. Reference them as agent-skills/skills/stream-builder/references/FEEDS.md from the root of this repository. Do not use machine-specific absolute paths.
Builder - UI shell and theme (Step 4)
Load when executing Step 4 (after scaffold). Rules: the stream skill's `RULES.md` (login screen first, theme, reference authority).
Step 4: Generate ALL code files
Write every file sequentially. Follow the UI Guidelines below for all visual styling. See RULES.md > Reference authority - reference files are the only source of truth for SDK wiring. Before writing each component, load the relevant references/<Product>-blueprints.md section.
Login Screen (required for every app - RULES.md > Login Screen first)
Centered card on a neutral background. No sidebar, no nav - just the login form.
Layout (top to bottom, all centered inside the card):
- App icon / logo
- App name (use-case label)
- Single
usernameinput (required, full card width) Continueprimary button (no arrow glyph in label - see UI Guidelines > Button labels)- Hint text below the button, in
text-muted-foreground text-sm: "Open this URL in another tab with a different username to test multi-user features."
Behavior:
- Username input is required
- On submit:
GET /api/token?user_id={username}-> store credentials in React state (not localStorage - each tab must be independent) - After successful token fetch, render the main app UI (state gate, not redirect)
- App name / use-case label above the input
App Header (required for every app)
Once logged in, every app MUST show a persistent header bar:
- Left: App name (derived from use case)
- Right: Avatar circle (initial letter) + username + "Switch User" button
- "Switch User" clears all token/client state and returns to the Login Screen
- The header sits above all product UI (chat sidebar, video player, feed, etc.)
This ensures the developer always knows which user they are operating as.
---
UI Guidelines
Stack
- Next.js 16, Tailwind v4, TypeScript (match scaffold defaults).
- Shadcn/ui with Base UI - scaffolded via
shadcn init -t next -b base -p <preset>(random preset per project - see builder.md Task A). Use Shadcn components (Button,Input,Textarea,Card, etc.) for all standard UI. Add more vianpx shadcn@latest add <component>as needed. - Icons: Use whichever icon package the scaffold installed (check
package.json). If none present,lucide-reactis installed during Step 3 Task C. Standard PascalCase imports:
import { Heart, Send, Bookmark, MoreHorizontal } from "lucide-react". If the project uses a different icon package (e.g. @phosphor-icons/react), use that one instead - do not mix icon packages.
- Tailwind utility classes for custom styling beyond Shadcn components - never inline styles.
- Theme: RULES.md > Theme -
next-themeswith system default (class-based dark mode, scaffolded automatically). -webkit-font-smoothing: antialiasedon html (set by scaffold).
Theme
Use whatever globals.css Shadcn generates. Do not add custom variables, custom themes, or dark mode overrides. The scaffold includes next-themes with ThemeProvider (system default, class-based toggle) - use it as-is.
Design
Use Shadcn components, Tailwind utilities, and - if the user approved them in Step 3 Task A.2 - the frontend skills to build a polished UI. No further opinions; use your best judgement. Stream references provide structure and wiring; frontend skills (when present) provide generic design guidance.
Button labels
Never put arrow characters in button text - no ASCII arrow sequences (like ->, >>) and no unicode arrow glyphs (any codepoint that renders as an arrow or chevron) in the label. If a button needs an arrow visually, use a proper icon component (e.g. lucide-react's <ArrowRight />, <ChevronRight />) rendered alongside the label. Otherwise, leave the label plain (e.g. Continue, not Continue ->).
Stream SDK CSS & Providers
- Chat: Import
stream-chat-react/css/index.css(v14+ preferred alias; v13 useddist/css/v2/index.css). UseuseCreateChatClientfromstream-chat-reactto instantiate. Match theme:useTheme()->str-chat__theme-darkorstr-chat__theme-lightto<Chat>. - Video: Import
@stream-io/video-react-sdk/dist/css/styles.css. InstantiateStreamVideoClientwith the canonicaluseState+useEffectpattern (NOTuseMemo- seereferences/VIDEO.md). - Feeds: No CSS import - headless SDK. Wrap app in
<StreamFeeds client={client}>, then per-feed in<StreamFeed feed={feed}>. UseuseCreateFeedsClient()for client creation - gate rendering on `client !== null` (returnsnulluntil connected). Callfeed.getOrCreate({ watch: true })insidesetTimeout(50ms)+mountedguard (strict mode protection) before passing to<StreamFeed>. Seereferences/FEEDS.mdfor complete patterns.
Provider hierarchy: mount all Stream providers - <Chat>, <StreamVideo>, <StreamFeeds> - once at AppShell, in any order. Per-screen components render <Channel>, <StreamCall>, or <StreamFeed> from the existing root providers. Never re-instantiate Stream clients per screen - the cleanup of one screen's effect will disconnect a client another screen is still using. For multi-product apps, see `references/CROSS-PRODUCT.md` for the full skeleton.
Moderation
Never build moderation review UI in the app (RULES.md > Moderation is Dashboard-only). All review happens in the Stream Dashboard. The app's role is CLI setup only (blocklists, automod config in Step 3).
Reference Blueprints
See RULES.md > Reference authority. Load references/<Product>.md (header) for setup + gotchas, and references/<Product>-blueprints.md for structure and wiring of each component. Load only the product(s) relevant to the current use case.
Enhance an existing app (Track E)
For adding Stream products to an existing Next.js project. Reuses the references files and SDK patterns from the scaffold flow but skips the scaffold entirely.
Reviewing, not adding? If the user wants to audit/check an existing Stream Video integration against best practices ("is my video app production-ready?", "what am I missing?") rather than add a feature, run the Integration best-practices audit section in `references/VIDEO.md`. It is a read-only review with a fixed checklist + output contract - produce findings first, fix only if asked.
Rules: the stream skill's `RULES.md` (secrets, no auto-seeding, login screen first, package manager). Onboard first: run getstream init to authenticate and select or create the org + app before any npm installs, getstream env, or token routes. See `../stream/SKILL.md` > Stream CLI for usage. SDK wiring (shared with the scaffold flow): `sdk.md` and the relevant `references/<Product>.md` - enhance uses the same wiring patterns as scaffold; only the surrounding setup differs.
---
E1: Audit the existing project
Before writing any code, understand what's already in place:
1. Packages: check package.json for stream-chat, stream-chat-react, @stream-io/video-react-sdk, @stream-io/node-sdk. 2. Auth: does the app already have a /api/token route? If so, extend it with the new product's token - don't create a second token route. 3. Credentials: check for .env with STREAM_API_KEY / STREAM_API_SECRET. If missing, run getstream init (if the dir isn't a Stream project yet) then getstream env to write them - never read or print the secret. 4. UI framework: confirm Tailwind, Shadcn, or whatever the project uses. Do not install Shadcn or change the styling setup unless the user asks. 5. Directory structure: note whether the project uses app/ or src/app/ - match the existing convention.
E2: Install + configure
1. Install only the new SDKs: npm install <new-packages> --legacy-peer-deps (the stream skill's `RULES.md` > Package manager). 2. Configure via CLI: run setup commands from the relevant references/<Product>.md (App Integration -> Setup). Feeds needs feed groups created; Moderation needs blocklist + config. 3. Import CSS if the product needs it (Chat: stream-chat-react/css/index.css (v14+ preferred alias; v13 used dist/css/v2/index.css), Video: @stream-io/video-react-sdk/dist/css/styles.css).
E3: Integrate
1. Token route: extend the existing /api/token to return the new product's token alongside existing ones. Follow `sdk.md` for server-side instantiation patterns. 2. API routes: add product-specific routes from references/<Product>.md (App Integration -> API Routes). Feeds needs several (/api/feed/get, /api/feed/post, etc.); Chat and Video typically only need the token route. 3. Components: load the relevant references/<Product>-blueprints.md sections and build components using the existing project's patterns and styling conventions - not the `builder-ui.md` defaults. 4. State: if the app already manages user state (auth context, session), wire Stream tokens into that - don't add a separate Login Screen unless the app has no auth.
E4: Verify
npx tsc --noEmit
npx next buildFix any errors.
---
Key constraints
- Do not re-scaffold, re-initialize Shadcn, install frontend skills, or modify
globals.css/layout.tsx. - Do not overwrite or restructure existing files - add new files alongside them.
- Do not change the existing auth flow. Adapt Stream's token generation to fit the app's existing auth, not the other way around.
- If the project uses a different package manager (yarn, pnpm), match what it already uses - the npm-only rule applies to new scaffolds, not existing projects.
Chat - full component blueprints
Setup, routes, and gotchas: CHAT.md. Rules: ../../stream/RULES.md.
---
Full blueprints (load on demand)
---
Channel List
Sidebar listing channels the user belongs to. Shows last message preview, unread count, and online presence.
Blueprint
<div class="channel-list">
<div class="channel-list__header">
<img class="channel-list__user-avatar" src="" alt="" />
<h2 class="channel-list__title">Messages</h2>
<button class="channel-list__compose" aria-label="New message"></button>
</div>
<!-- OPTIONAL: search -->
<div class="channel-list__search">
<input class="channel-list__search-input" type="search" placeholder="Search" />
</div>
<div class="channel-list__items" role="listbox">
<button class="channel-list__item channel-list__item--active" role="option" aria-selected="true">
<!-- Modifiers: --active (selected), --unread (has unread), --muted -->
<div class="channel-list__item-avatar">
<img src="" alt="" />
<!-- CONDITIONAL: 1:1 channel -> other user's avatar; group -> channel image or stacked avatars -->
<!-- CONDITIONAL: other user is online -->
<span class="channel-list__item-presence channel-list__item-presence--online"></span>
</div>
<div class="channel-list__item-content">
<div class="channel-list__item-top">
<span class="channel-list__item-name"></span>
<time class="channel-list__item-time"></time>
</div>
<div class="channel-list__item-bottom">
<p class="channel-list__item-preview"></p>
<!-- CONDITIONAL: channel has unread messages -->
<span class="channel-list__item-unread">3</span>
</div>
</div>
</button>
</div>
<!-- States: channel-list__loading (skeleton), channel-list__empty, channel-list__error -->
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
channel-list__items | client.queryChannels(filter, sort, { watch: true, state: true }) | - | Returns array of channel objects |
channel-list__item-avatar (1:1) | Channel members | - | Other member's user.image |
channel-list__item-avatar (group) | channel.data.image | channel.update({ image }) | channel.data.image |
channel-list__item-name (1:1) | Channel members | - | Other member's user.name |
channel-list__item-name (group) | channel.data.name | channel.update({ name }) | channel.data.name |
channel-list__item-preview | Channel state | - | channel.state.messages[last].text - truncated |
channel-list__item-time | Channel state | - | channel.state.messages[last].created_at |
channel-list__item-unread | channel.countUnread() | channel.markRead() | Returns integer |
channel-list__item-presence | User presence events | - | user.online (boolean) |
--active modifier | Client-side selection state | - | Set when user clicks channel |
| New channel | - | client.channel(type, id, { name, members }) then channel.watch() | - |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Channel queries | client.queryChannels(filter, sort, options) | Available |
| Real-time updates | { watch: true } in query options | On by default - both watch and state default to true in queryChannels |
| Presence | Dashboard -> Channel Type -> "Connect Events" enabled | Off per channel type |
| Unread counts | channel.countUnread() | Available - relies on channel.markRead() being called |
| Search | client.search(filter, query) or client.queryChannels with name filter | Available |
---
Channel Header
Top bar of an active channel. Shows channel identity, members, and actions.
Blueprint
<header class="channel-header">
<div class="channel-header__info">
<img class="channel-header__avatar" src="" alt="" />
<!-- CONDITIONAL: 1:1 -> presence dot on avatar -->
<span class="channel-header__presence channel-header__presence--online"></span>
<div class="channel-header__meta">
<h3 class="channel-header__name"></h3>
<!-- CONDITIONAL: 1:1 channel -> "Online" / "Last seen 2h ago" -->
<!-- CONDITIONAL: group channel -> "3 members, 2 online" -->
<span class="channel-header__status"></span>
</div>
</div>
<div class="channel-header__actions">
<button class="channel-header__action channel-header__action--search" aria-label="Search"></button>
<button class="channel-header__action channel-header__action--members" aria-label="Members"></button>
<!-- OPTIONAL: video/audio call -->
<button class="channel-header__action channel-header__action--call" aria-label="Call"></button>
<button class="channel-header__action channel-header__action--menu" aria-label="More"></button>
</div>
</header>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
channel-header__avatar | channel.data or member data | - | channel.data.image (group) or other member's user.image (1:1) |
channel-header__name | channel.data or member data | - | channel.data.name (group) or other member's user.name (1:1) |
channel-header__status (1:1) | Presence events | - | user.online -> "Online"; user.last_active -> "Last seen X ago" |
channel-header__status (group) | channel.state.members, channel.state.watcher_count | - | Count members + online watchers |
channel-header__action--call | - | Initiates Stream Video call (see VIDEO.md) | Cross-product integration |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Presence / last active | "Connect Events" enabled on channel type | Off |
| Watcher count | { watch: true, presence: true } on channel.watch() | Must pass explicitly |
| Video/audio calls | Stream Video product enabled | Separate product |
---
Message List
Scrollable container for messages. Handles date separators, scroll-to-bottom, and real-time message injection.
Blueprint
<div class="message-list" role="log" aria-live="polite">
<!-- CONDITIONAL: older messages available -->
<div class="message-list__load-older">
<button class="message-list__load-older-btn">Load older messages</button>
<!-- Or: IntersectionObserver sentinel at top for infinite scroll -->
</div>
<!-- Date separator -->
<div class="message-list__date-separator">
<span class="message-list__date-label">March 17, 2026</span>
</div>
<!-- System/event message -->
<div class="message-list__event">
<span class="message-list__event-text">Jane added Alex to the channel</span>
</div>
<!-- Messages grouped by sender (consecutive messages from same user) -->
<div class="message-list__group message-list__group--other">
<!-- Modifiers: --own (current user) | --other -->
<!-- First message in group shows avatar + name, rest are compact -->
<div class="message-list__item message-list__item--first">
<!-- Insert Message component -->
</div>
<div class="message-list__item message-list__item--continuation">
<!-- Insert Message component (no avatar/name, compact spacing) -->
</div>
</div>
<!-- CONDITIONAL: typing indicator -->
<div class="message-list__typing">
<!-- See Typing Indicator component -->
</div>
<!-- CONDITIONAL: user has scrolled up, new messages below -->
<button class="message-list__scroll-to-bottom">
<!-- CONDITIONAL: unread count badge -->
<span class="message-list__new-count">5</span>
</button>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Messages (initial) | channel.watch() or channel.query({ messages: { limit: 25 } }) | - | channel.state.messages |
| Messages (older) | channel.query({ messages: { limit: 25, id_lt: oldestMessageId } }) | - | Prepend to message list |
| Messages (real-time) | channel.on('message.new', callback) | - | Append new message to list |
| Message groups | Client-side grouping | - | Group consecutive messages by message.user.id within a time window |
| Date separators | Client-side | - | Insert when message.created_at crosses a day boundary |
| System events | channel.on('member.added', ...), channel.on('member.removed', ...) | - | Render as message-list__event |
| Typing indicator | channel.on('typing.start', ...), channel.on('typing.stop', ...) | - | channel.state.typing - map of userId -> event |
| Scroll to bottom | Client-side scroll position tracking | - | Show when scrollTop < threshold |
message-list__new-count | Track messages received while scrolled up | - | Client-side counter |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Message history | channel.watch() or channel.query() | Available |
| Real-time | channel.watch() establishes websocket | Automatic when channel is watched |
| Typing events | "Typing Events" enabled on channel type in Dashboard | On for most types |
| Read events | "Read Events" enabled on channel type | On for most types |
| System events | Automatic on member add/remove | Available |
---
Message
The core content unit in chat. A single message with author info, text, attachments, reactions, and thread.
Blueprint
<div class="message">
<!-- Modifiers: message--own | message--other | message--deleted | message--pinned | message--highlighted | message--system -->
<!-- message--first (first in group, shows avatar/name) -->
<!-- message--continuation (same sender, compact) -->
<!-- CONDITIONAL: message--first in group only -->
<a class="message__actor" href="/user/{user.id}">
<img class="message__avatar" src="" alt="" />
</a>
<div class="message__content">
<!-- CONDITIONAL: message--first in group only -->
<div class="message__header">
<span class="message__author"></span>
<time class="message__time" datetime=""></time>
</div>
<!-- CONDITIONAL: message.pinned === true -->
<div class="message__pinned-badge">
Pinned by <span class="message__pinned-by"></span>
</div>
<div class="message__bubble">
<!-- CONDITIONAL: message.quoted_message exists (reply/quote) -->
<div class="message__quoted">
<span class="message__quoted-author"></span>
<p class="message__quoted-text"></p>
</div>
<!-- Parse @mentions -> <a class="message__mention">, URLs -> <a class="message__link"> -->
<p class="message__text"></p>
<!-- CONDITIONAL: message.attachments has type "image" -->
<div class="message__images">
<!-- Modifiers: message__images--single | --grid -->
<figure class="message__image-item">
<img src="" alt="" />
</figure>
</div>
<!-- CONDITIONAL: message.attachments has type "video" -->
<div class="message__video">
<video class="message__video-player" src="" controls></video>
</div>
<!-- CONDITIONAL: message.attachments has type "file" -->
<div class="message__files">
<a class="message__file" href="" download>
<span class="message__file-icon"></span>
<span class="message__file-name"></span>
<span class="message__file-size"></span>
</a>
</div>
<!-- CONDITIONAL: message.attachments has og_scrape_url (link preview) -->
<a class="message__og" href="" target="_blank" rel="noopener">
<img class="message__og-image" src="" alt="" />
<div class="message__og-content">
<span class="message__og-title"></span>
<span class="message__og-description"></span>
<span class="message__og-domain"></span>
</div>
</a>
</div>
<!-- CONDITIONAL: message.deleted_at exists -->
<div class="message__deleted">This message was deleted.</div>
<!-- Reactions row (inline, beneath bubble) -->
<!-- CONDITIONAL: message has any reactions -->
<div class="message__reactions">
<!-- One pill per reaction type -->
<button class="message__reaction">
<!-- Modifier: message__reaction--own when user has reacted with this type -->
<span class="message__reaction-emoji"></span>
<span class="message__reaction-count"></span>
</button>
<!-- Add reaction button -->
<button class="message__reaction message__reaction--add" aria-label="Add reaction"></button>
</div>
<!-- CONDITIONAL: message.reply_count > 0 -->
<button class="message__thread-reply">
<div class="message__thread-avatars">
<!-- Stacked avatars of thread participants -->
<img class="message__thread-avatar" src="" alt="" />
</div>
<span class="message__thread-count"></span>
<time class="message__thread-last" datetime=""></time>
</button>
<!-- Message status (own messages only) -->
<!-- CONDITIONAL: message--own -->
<div class="message__status">
<!-- Modifiers: message__status--sending | --sent | --delivered | --read -->
<!-- Read: show stacked read receipt avatars -->
</div>
<!-- Hover/long-press action bar -->
<div class="message__actions">
<button class="message__action message__action--react" aria-label="React"></button>
<button class="message__action message__action--reply" aria-label="Reply in thread"></button>
<button class="message__action message__action--quote" aria-label="Quote"></button>
<!-- CONDITIONAL: message.user.id === currentUserId -->
<button class="message__action message__action--edit" aria-label="Edit"></button>
<button class="message__action message__action--delete" aria-label="Delete"></button>
<!-- Always visible -->
<button class="message__action message__action--pin" aria-label="Pin"></button>
<button class="message__action message__action--flag" aria-label="Flag"></button>
</div>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
message__avatar | In message payload | - | message.user.image |
message__author | In message payload | - | message.user.name |
message__time | In message payload | - | message.created_at |
message__text | In message payload | - | message.text |
message__pinned-badge | In message payload | - | message.pinned, message.pinned_by.name |
message__quoted | In message payload | - | message.quoted_message.user.name, message.quoted_message.text |
message__image-item | In message payload | - | message.attachments[].image_url where type === 'image' |
message__video-player | In message payload | - | message.attachments[].asset_url where type === 'video' |
message__file | In message payload | - | message.attachments[].asset_url, .title, .file_size where type === 'file' |
message__og-* | In message payload | - | message.attachments[].og_scrape_url, .title, .text, .image_url |
message__deleted | In message payload | - | message.deleted_at (truthy = deleted) |
message__reaction | In message payload | - | message.reaction_groups (keyed by type; each has count, sum_scores, first_reaction_at, last_reaction_at) - preferred. message.reaction_counts still works. Also message.own_reactions[] |
| Reaction - add | - | channel.sendReaction(message.id, { type: 'like' }) | Supports { enforce_unique: true } option as third arg to replace all user's existing reactions |
| Reaction - remove | - | channel.deleteReaction(message.id, 'like') | Removes current user's reaction of that type |
message__thread-count | In message payload | - | message.reply_count |
message__thread-avatars | In message payload | - | message.thread_participants[].image |
message__thread-last | In message payload | - | message.latest_reactions or thread's last reply timestamp |
message__status (read) | channel.state.read | - | Map of userId -> { last_read, user } - compare with message.created_at |
| Edit | - | client.updateMessage({ id: message.id, text: newText }) | - |
| Delete | - | client.deleteMessage(message.id) | Sets message.deleted_at. Pass { hardDelete: true } for permanent deletion |
| Pin | - | client.pinMessage(message, timeoutOrExpiration) | Accepts a message object or message id. Second arg is optional: timeout in seconds, expiration date, or null for no expiry |
| Unpin | - | client.unpinMessage(message) | Accepts a message object or message id |
message__reaction-groups | In message payload | - | message.reaction_groups - keyed by type, each has count, sum_scores, first_reaction_at, last_reaction_at. Recommended replacement for reaction_counts |
message__mentioned-users | In message payload | - | message.mentioned_users - enriched user objects for @mentions in the message |
| Flag | - | client.flagMessage(message.id) | See MODERATION.md |
| Mute user | - | client.muteUser(userId, null, { timeout: 60 }) | Three args: userId, null, options object. timeout is in minutes |
| Quote | - | Send new message with quoted_message_id: message.id | - |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Attachments | "Uploads" enabled on channel type | On |
| Reactions | "Reactions" enabled on channel type in Dashboard | On |
| Replies/threads | "Replies" enabled on channel type | On |
| Read receipts | "Read Events" enabled on channel type | On for most types |
| URL enrichment | "URL Enrichment" enabled on channel type | On - auto-scrapes OG data server-side |
| Pinning | "Pinning" enabled on channel type | Off |
| Quoting | quoted_message_id on sendMessage | Available - no config needed |
| Message editing | - | Available - own messages by default, admin can edit any |
| Message deletion | - | Available - own messages by default, admin can delete any |
---
Message Input
Text input for composing and sending messages. Handles attachments, mentions, slash commands, and edit mode.
Blueprint
<div class="message-input">
<!-- Modifier: message-input--editing (when editing an existing message) -->
<!-- Modifier: message-input--disabled (when user lacks send permission) -->
<!-- Modifier: message-input--thread (when in thread view) -->
<!-- CONDITIONAL: editing a message -->
<div class="message-input__edit-banner">
Editing message
<button class="message-input__edit-cancel" aria-label="Cancel edit"></button>
</div>
<!-- CONDITIONAL: replying with quote -->
<div class="message-input__quote-preview">
<span class="message-input__quote-author"></span>
<p class="message-input__quote-text"></p>
<button class="message-input__quote-remove" aria-label="Remove quote"></button>
</div>
<!-- CONDITIONAL: user has selected files to upload -->
<div class="message-input__attachments">
<div class="message-input__attachment">
<!-- Modifiers: --image | --file | --uploading | --error -->
<img class="message-input__attachment-preview" src="" alt="" />
<button class="message-input__attachment-remove" aria-label="Remove"></button>
<div class="message-input__attachment-progress">
<div class="message-input__attachment-progress-bar" style="width: 0%"></div>
</div>
</div>
</div>
<div class="message-input__composer">
<div class="message-input__tools-left">
<button class="message-input__tool message-input__tool--attach" aria-label="Attach file"></button>
</div>
<div class="message-input__text-area">
<div class="message-input__text" contenteditable="true" role="textbox" aria-multiline="true" data-placeholder="Send a message"></div>
<!-- CONDITIONAL: user types "@" + characters -->
<div class="message-input__mention-dropdown">
<button class="message-input__mention-option">
<img class="message-input__mention-avatar" src="" alt="" />
<span class="message-input__mention-name"></span>
</button>
</div>
<!-- CONDITIONAL: user types "/" (slash commands) -->
<div class="message-input__command-dropdown">
<button class="message-input__command-option">
<span class="message-input__command-name">/giphy</span>
<span class="message-input__command-desc">Post a random GIF</span>
</button>
</div>
</div>
<div class="message-input__tools-right">
<button class="message-input__tool message-input__tool--emoji" aria-label="Emoji"></button>
<button class="message-input__send" aria-label="Send" disabled></button>
</div>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
message-input__text | - (user input) | Becomes message.text | - |
message-input__mention-dropdown | channel.queryMembers({ name: { $autocomplete: query } }) | - | Match typed query against channel members |
message-input__command-dropdown | channel.getConfig() | - | channel.config.commands[] - name + description |
message-input__attachment (image) | Local blob preview | channel.sendImage(file) -> CDN URL | Collect into message.attachments[] with type: 'image' |
message-input__attachment (file) | Local blob preview | channel.sendFile(file) -> CDN URL | Collect into message.attachments[] with type: 'file' |
| Attachment remove (image) | - | channel.deleteImage(url) | Deletes uploaded image from CDN when user removes before sending |
| Attachment remove (file) | - | channel.deleteFile(url) | Deletes uploaded file from CDN when user removes before sending |
| Send (new) | - | channel.sendMessage({ text, attachments, quoted_message_id?, mentioned_users? }) | mentioned_users is an array of user IDs referenced via @mentions in the text |
| Send (edit) | - | client.updateMessage({ id, text, attachments }) | - |
| Send (thread) | - | channel.sendMessage({ text, parent_id: parentMessage.id }) | - |
| Typing events | - | channel.keystroke() on input, channel.stopTyping() on pause | Debounced - SDK handles interval |
--disabled | channel.data.own_capabilities | - | Check if 'send-message' is in capabilities array |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| File uploads | "Uploads" enabled on channel type | On |
| Slash commands | Commands configured on channel type in Dashboard | /giphy available by default |
| @Mentions | Channel members queryable | Available - searches channel members |
| Typing indicators | "Typing Events" enabled on channel type | On for most |
| Message length | channel.config.max_message_length | 5000 chars default |
| Slow mode | channel.data.cooldown (seconds) | Off - set per channel |
---
Thread
Reply thread on a specific message. Opens as a side panel or overlay.
Blueprint
<div class="thread">
<header class="thread__header">
<h3 class="thread__title">Thread</h3>
<span class="thread__count"></span>
<button class="thread__close" aria-label="Close thread"></button>
</header>
<!-- Parent message (the message being replied to) -->
<div class="thread__parent">
<!-- Insert Message component (with thread-reply button hidden) -->
</div>
<div class="thread__separator">
<span class="thread__reply-count"></span>
</div>
<!-- Reply list (same structure as Message List, but for thread replies) -->
<div class="thread__replies" role="log">
<div class="thread__reply">
<!-- Insert Message component -->
</div>
</div>
<!-- Thread-specific message input -->
<div class="thread__input">
<!-- Insert Message Input component with message-input--thread modifier -->
<!-- OPTIONAL: "Also send to channel" checkbox -->
<label class="thread__send-to-channel">
<input type="checkbox" />
Also send to #channel-name
</label>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
thread__parent | Already in message list | - | The message with reply_count > 0 |
thread__replies | channel.getReplies(parentMessage.id, { limit: 25 }) | - | Returns { messages: [...] } |
thread__replies (older) | channel.getReplies(parentId, { limit: 25, id_lt: oldestReplyId }) | - | Cursor pagination |
thread__replies (real-time) | channel.on('message.new', cb) - filter where message.parent_id === parentId | - | Append to reply list |
thread__reply-count | Parent message | - | parentMessage.reply_count |
| Reply - send | - | channel.sendMessage({ text, parent_id: parentMessage.id }) | - |
| Reply - send to channel | - | channel.sendMessage({ text, parent_id: parentMessage.id, show_in_channel: true }) | Shows reply in main channel too |
| All threads | client.queryThreads() | - | Lists all threads the current user participates in - supports pagination and filtering |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Replies/threads | "Replies" enabled on channel type in Dashboard | On |
| Thread participants | Automatic | Tracked in message.thread_participants |
---
Typing Indicator
Shows who is currently typing in the channel.
Blueprint
<!-- CONDITIONAL: channel.state.typing has entries (excluding current user) -->
<div class="typing-indicator">
<div class="typing-indicator__avatars">
<img class="typing-indicator__avatar" src="" alt="" />
<!-- Max 2-3 avatars -->
</div>
<div class="typing-indicator__dots">
<span class="typing-indicator__dot"></span>
<span class="typing-indicator__dot"></span>
<span class="typing-indicator__dot"></span>
</div>
<span class="typing-indicator__text">
<!-- 1 user: "Jane is typing" -->
<!-- 2 users: "Jane and Alex are typing" -->
<!-- 3+: "3 people are typing" -->
</span>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Typing users | channel.on('typing.start', cb), channel.on('typing.stop', cb) | channel.keystroke() / channel.stopTyping() | channel.state.typing - map of userId -> { user }, excludes current user. For threads: channel.keystroke(threadId) sends thread-specific typing events |
typing-indicator__avatar | In typing event | - | event.user.image |
| Auto-expiry | Client-side | - | Remove user from typing state after ~7s with no new typing.start event |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Typing events | "Typing Events" enabled on channel type | On for most types |
---
Emoji Reaction Picker
Overlay for selecting a reaction to add to a message. Typically triggered from message action bar or existing reaction row.
Blueprint
<div class="reaction-picker">
<!-- Quick reactions row (most common) -->
<div class="reaction-picker__quick">
<button class="reaction-picker__emoji" data-type="like">👍</button>
<button class="reaction-picker__emoji" data-type="love">❤</button>
<button class="reaction-picker__emoji" data-type="haha">😂</button>
<button class="reaction-picker__emoji" data-type="wow">😲</button>
<button class="reaction-picker__emoji" data-type="sad">😢</button>
<button class="reaction-picker__emoji" data-type="angry">😡</button>
</div>
<!-- OPTIONAL: full emoji picker with categories and search -->
<button class="reaction-picker__more" aria-label="More reactions"></button>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Emoji click | - | channel.sendReaction(message.id, { type: 'like' }) | Type is the data-type attribute. Pass { enforce_unique: true } as third arg to replace user's existing reactions |
| Toggle off | Check message.own_reactions for existing reaction of same type | channel.deleteReaction(message.id, 'like') | - |
| Available types | - | Any string works as reaction type | No configuration needed for custom types |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Reactions | "Reactions" enabled on channel type | On |
---
Search
Search messages across channels or within a specific channel.
Blueprint
<div class="search">
<div class="search__input-area">
<span class="search__icon"></span>
<input class="search__input" type="search" placeholder="Search messages" />
<!-- CONDITIONAL: query is non-empty -->
<button class="search__clear" aria-label="Clear search"></button>
</div>
<!-- CONDITIONAL: search has results -->
<div class="search__results">
<button class="search__result">
<img class="search__result-avatar" src="" alt="" />
<div class="search__result-content">
<div class="search__result-top">
<span class="search__result-author"></span>
<span class="search__result-channel">#general</span>
<time class="search__result-time"></time>
</div>
<p class="search__result-text">
<!-- Highlight matching text with <mark class="search__highlight"> -->
</p>
</div>
</button>
</div>
<!-- States: search__loading, search__empty ("No results for ...") -->
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
search__results | client.search({ members: { $in: [userId] } }, query, { limit: 20 }) | - | Returns { results: [{ message }] } |
search__results (in-channel) | client.search({ cid: channel.cid }, query, { limit: 20 }) | - | Filter by specific channel |
search__result-avatar | In result | - | result.message.user.image |
search__result-author | In result | - | result.message.user.name |
search__result-channel | In result | - | result.message.channel.name or result.message.channel.id |
search__result-text | In result | - | result.message.text - add highlights client-side |
| Result click | - | Navigate to message in channel | result.message.channel + result.message.id |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Search | "Search" enabled on channel type | On |
| Cross-channel | client.search() with filter across channels | Available |
Chat - Setup & Integration
Stream Chat provides pre-built UI components via React, React Native, Flutter, Swift, and Kotlin SDKs. This file covers setup, server routes, client patterns, and gotchas. For full component structure and wiring, see CHAT-blueprints.md.
Rules: ../../stream/RULES.md (secrets, no auto-seeding, login screen first, strict mode protection).
- Blueprint - HTML with BEM classes defining structure and conditional rendering
- Wiring - API calls to read/write each element, exact property paths
- Requirements - Dashboard settings, API params, and prerequisites
Quick ref
- Packages:
stream-chat,stream-chat-react; importstream-chat-react/css/index.css(v14+ preferred alias; v13 useddist/css/v2/index.css). - First: App Integration -> Setup (CLI / channel types) before UI.
- Per feature: Jump to section (Channel List, Message List, ...) when implementing that screen.
- Below the next rule: full blueprints - do not load past it until you implement that component.
Full component blueprints: CHAT-blueprints.md - load only the section you are implementing.
---
App Integration
Everything needed to wire the UI components above into a working Next.js application.
Setup
Packages: stream-chat + stream-chat-react (client), stream-chat (server via StreamChat.getInstance)
No CLI commands needed - built-in channel types (messaging, team, livestream) work out of the box.
Server Routes
| Route | Method | Params | Action | Response |
|---|---|---|---|---|
/api/token | GET | ?user_id=xxx | client.upsertUsers([{ id, name, role: 'user' }]), client.createToken(userId) | { chatToken, apiKey } |
See RULES.md > No auto-seeding.
import { StreamChat } from 'stream-chat';
const client = StreamChat.getInstance(process.env.STREAM_API_KEY!, process.env.STREAM_API_SECRET!);Client Patterns
- Login Screen first: See RULES.md > Login Screen first + builder-ui.md > Login Screen.
- App Header: Show the current username + avatar (initial letter) + "Switch User" in a persistent header above the chat layout. See `builder-ui.md` -> App Header.
- Use `useCreateChatClient`: the SDK ships an official hook that handles strict-mode, instantiation,
connectUser, and cleanup. Never wireconnectUser/disconnectUsermanually - they race with strict-mode double-mount and produce "You can't use a channel after client.disconnect was called".
import { useCreateChatClient } from "stream-chat-react";
const chatClient = useCreateChatClient({
apiKey,
tokenOrProvider: chatToken,
userData: { id: userId, name },
});
if (!chatClient) return <Loading />;- Hoist `<Chat>` to AppShell: mount
<Chat client={chatClient}>once at the app root, alongside<StreamVideo>/<StreamFeeds>. Per-screen components only render<Channel channel={...}>from the existing client. Never instantiate a new `StreamChat` per screen - the cleanup of one screen's effect will disconnect the client another screen is still using. See `CROSS-PRODUCT.md` for the full multi-product AppShell skeleton. - Channel switching: the client is long-lived; only swap the
channelprop on<Channel>when the conversation changes. On per-channel unmount callchannel.stopWatching()- neverclient.disconnectUser(). - Theme:
useTheme()fromnext-themes- passstr-chat__theme-darkorstr-chat__theme-lightto<Chat>based onresolvedTheme. - Strict mode: See RULES.md > Strict mode protection.
useCreateChatClientalready handles this for you.
Gotchas
- Always generate real tokens server-side via
client.createToken()- neverdevToken() StreamChat.getInstance(apiKey, apiSecret)is fine server-side (singleton OK)client.channel(type, id, { name, image, members })- the 3rd arg accepts custom channel data (ChannelData); Stream's own tutorial doesclient.channel('livestream', 'spacex', { name, image }).- SDK uses module augmentation for custom data types. A custom channel field like
nameand a customchannel.sendEvent({ type: 'bid.placed', ... })both raise a type error by default. Declare custom fields and events using module augmentation and interface merging:
import "stream-chat"
declare module "stream-chat" {
interface CustomChannelData {
name?: string // add your custom channel fields here
}
interface CustomEventTypes {
"bid.placed": true // your custom event type
}
interface CustomEventData {
payload?: Record<string, unknown> // your custom event payload shape
}
}- Listen for
user.bannedevent to show banned state in UI - Import
stream-chat-react/css/index.cssfor default styles - the preferred aliased path (dist/css/index.cssalso resolves; v14+, the/v2/subpath was removed) MessageInputwas renamed/removed in v14 - useMessageComposerfromstream-chat-reactinstead- Token endpoint as
GET /api/token?user_id=xxx upsertUserstakes an array of user objects:client.upsertUsers([{ id, name, role }])- NOT an object keyed by ID<Chat>lives at app root;<Channel>is what swaps per conversation. Don't construct/destructStreamChatper screen.
Cross-product AppShell - canonical pattern
When using two or more of Chat / Video / Feeds in the same app, mount all clients once at AppShell and provide them at the root. Per-screen components only render <Channel>, <StreamCall>, or <StreamFeed> from the existing providers - never re-instantiate the clients.
Source of truth: video/react/10-advanced/06-chat-with-video.md from the Stream docs (the messenger-clone reference app).
AppShell skeleton
"use client";
import { useEffect, useState } from "react";
import { Chat, useCreateChatClient } from "stream-chat-react";
import { StreamVideo, StreamVideoClient } from "@stream-io/video-react-sdk";
import {
StreamFeeds,
useCreateFeedsClient,
type Feed,
} from "@stream-io/feeds-react-sdk";
import { useTheme } from "next-themes";
import "stream-chat-react/css/index.css";
import "@stream-io/video-react-sdk/dist/css/styles.css";
type Auth = {
apiKey: string;
userId: string;
name: string;
chatToken: string;
videoToken: string;
feedToken: string;
};
export default function AppShell({ auth, children }: { auth: Auth; children: React.ReactNode }) {
const { resolvedTheme } = useTheme();
// CHAT - official hook handles strict-mode + lifecycle
const chatClient = useCreateChatClient({
apiKey: auth.apiKey,
tokenOrProvider: auth.chatToken,
userData: { id: auth.userId, name: auth.name },
});
// FEEDS - official hook handles strict-mode + lifecycle
const feedsClient = useCreateFeedsClient({
apiKey: auth.apiKey,
tokenOrProvider: auth.feedToken,
userData: { id: auth.userId, name: auth.name },
});
// VIDEO - canonical useState + useEffect (NOT useMemo)
const [videoClient, setVideoClient] = useState<StreamVideoClient>();
useEffect(() => {
const c = new StreamVideoClient({
apiKey: auth.apiKey,
user: { id: auth.userId, name: auth.name },
token: auth.videoToken,
});
setVideoClient(c);
return () => {
c.disconnectUser().catch(console.error);
setVideoClient(undefined);
};
}, [auth.apiKey, auth.userId, auth.name, auth.videoToken]);
if (!chatClient || !feedsClient || !videoClient) return <Loading />;
const themeClass = resolvedTheme === "dark" ? "str-chat__theme-dark" : "str-chat__theme-light";
return (
<Chat client={chatClient} theme={themeClass}>
<StreamVideo client={videoClient}>
<StreamFeeds client={feedsClient}>{children}</StreamFeeds>
</StreamVideo>
</Chat>
);
}The order of <Chat> / <StreamVideo> / <StreamFeeds> doesn't matter - they don't depend on each other. Each provides a context that the per-screen components read.
Per-screen pattern
Inside any screen (Hub, Watch, GoLive, etc.):
import { useChatContext, Channel, Window, MessageList, MessageComposer } from "stream-chat-react";
import { useStreamVideoClient, StreamCall } from "@stream-io/video-react-sdk";
import { useFeedsClient, StreamFeed } from "@stream-io/feeds-react-sdk";
function WatchScreen({ callId }: { callId: string }) {
const { client: chatClient } = useChatContext(); // from <Chat>
const videoClient = useStreamVideoClient(); // from <StreamVideo>
const feedsClient = useFeedsClient(); // from <StreamFeeds>
// create a per-screen channel/call/feed from the long-lived clients
const [channel, setChannel] = useState(null);
useEffect(() => {
if (!chatClient) return;
const ch = chatClient.channel("livestream", callId);
ch.watch().then(() => setChannel(ch));
return () => { ch.stopWatching().catch(() => {}); };
}, [chatClient, callId]);
// ... etc
}Cleanup is per-resource, not per-client:
- Channel:
channel.stopWatching()(NEVERchatClient.disconnectUser()). - Call:
call.leave()(NEVERvideoClient.disconnectUser()). - Feed: usually no cleanup needed; the
<StreamFeeds>provider keeps state alive.
Common error -> cause -> fix
| Symptom | Cause | Fix |
|---|---|---|
User token is not set... disconnect was called (video) | useMemo for StreamVideoClient; strict-mode disconnects the same instance reused on remount | useState + useEffect with empty cleanup; setClient(undefined) |
You can't use a channel after client.disconnect was called (chat) | new StreamChat() created per screen; cleanup races with channel.watch() | Hoist <Chat> to root via useCreateChatClient; per-screen only does client.channel(...).watch() + stopWatching() |
user_id is required for server side requests | Server-side client.feeds.* mutation missing user_id | Pass acting user's id; required for addActivity, updateActivity, addComment, etc (NOT deleteActivity). See FEEDS.md |
No permission to publish VIDEO / AUDIO (livestream) | livestream call_member/host roles default to *-owner grants only | Grant unrestricted send-video + send-audio to `user`, `call_member`, AND `host` roles; join with data: { members: [{ user_id, role: "host" }] }. See VIDEO.md |
| "Setting up your camera..." never clears | useEffect bails on strict-mode remount due to useRef lock | Use mounted-flag cleanup; setCall after join, then enable camera/mic in independent try/catch blocks |
MessageInput undefined import (chat) | Renamed in stream-chat-react v14 | Use MessageComposer from stream-chat-react |
Module not found: stream-chat-react/dist/css/v2/index.css | v14 removed the /v2/ subpath | Import stream-chat-react/css/index.css (preferred alias; dist/css/index.css also works) |
Token route
Single /api/token endpoint that mints all needed tokens in one round-trip:
import { NextRequest, NextResponse } from "next/server";
import { StreamClient } from "@stream-io/node-sdk";
import { StreamChat } from "stream-chat";
const apiKey = process.env.STREAM_API_KEY!;
const apiSecret = process.env.STREAM_API_SECRET!;
const videoClient = new StreamClient(apiKey, apiSecret);
const chatClient = StreamChat.getInstance(apiKey, apiSecret);
export async function GET(req: NextRequest) {
const userId = req.nextUrl.searchParams.get("user_id");
if (!userId) return NextResponse.json({ error: "user_id required" }, { status: 400 });
const sanitized = userId.toLowerCase().replace(/[^a-z0-9_-]/g, "_");
await Promise.all([
videoClient.upsertUsers([{ id: sanitized, name: userId, role: "user" }]),
chatClient.upsertUsers([{ id: sanitized, name: userId, role: "user" }]),
]);
return NextResponse.json({
apiKey,
userId: sanitized,
name: userId,
chatToken: chatClient.createToken(sanitized),
videoToken: videoClient.generateUserToken({ user_id: sanitized }),
feedToken: videoClient.generateUserToken({ user_id: sanitized }),
});
}Only upsert the requesting user - never seed demo users (RULES.md > No auto-seeding).
Feeds v3 - Full Component Blueprints
Setup, routes, and gotchas: FEEDS.md. Rules: ../../stream/RULES.md.
The Feeds SDK is headless - all components below are built entirely with your own UI (Shadcn/Tailwind). The SDK provides hooks and state management only.
---
Post Composer
Text input for creating new activities. Includes avatar, textarea, and post button.
Blueprint
<div class="post-composer">
<div class="post-composer__row">
<img class="post-composer__avatar" src="" alt="" />
<div class="post-composer__body">
<textarea class="post-composer__input" placeholder="What's on your mind?"></textarea>
<div class="post-composer__actions">
<!-- OPTIONAL: attachment button -->
<button class="post-composer__attach" aria-label="Add attachment"></button>
<button class="post-composer__submit" disabled>Post</button>
</div>
</div>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
--avatar | Current user | - | userId from auth state (first letter for fallback) |
--input | - | Local state | Controlled textarea. Use the Shadcn `<Textarea>` component with its default styling (border, focus ring, background). Do NOT strip defaults with border-0, bg-transparent, shadow-none, or focus-visible:ring-0 - the textarea should look like a standard input inside the card. |
--submit enabled | Text is non-empty | feed.addActivity({ type: 'post', text }) | Returns StreamResponse<AddActivityResponse> - created activity at result.activity |
--attach (optional) | - | client.uploadImage({ file }) or client.uploadFile({ file }) -> include URL in attachments | FeedsClient.uploadImage() / FeedsClient.uploadFile() |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Post to feed | Feed instance from client.feed(group, id) with getOrCreate({ watch: true }) | Required |
| File uploads | - | Available via client.uploadImage() / client.uploadFile() |
---
Post Card
Individual activity card showing author, content, reactions, comments, and actions.
Blueprint
<div class="post-card">
<div class="post-card__header">
<img class="post-card__avatar" src="" alt="" />
<div class="post-card__meta">
<span class="post-card__author"></span>
<time class="post-card__time" datetime=""></time>
</div>
<button class="post-card__menu" aria-label="More options">
<!-- Dropdown: Delete (own post) or Report (other's post) -->
</button>
</div>
<div class="post-card__content">
<p class="post-card__text"></p>
<!-- CONDITIONAL: has attachments -->
<div class="post-card__attachments">
<!-- Images, files, etc. -->
</div>
</div>
<!-- CONDITIONAL: has poll -->
<div class="post-card__poll">
<!-- Poll component -->
</div>
<div class="post-card__actions">
<button class="post-card__action post-card__action--like" aria-pressed="false">
<!-- aria-pressed="true" when user has liked -->
<span class="post-card__action-icon"></span>
<span class="post-card__action-count"></span>
</button>
<button class="post-card__action post-card__action--comment">
<span class="post-card__action-icon"></span>
<span class="post-card__action-count"></span>
</button>
<button class="post-card__action post-card__action--bookmark" aria-pressed="false">
<span class="post-card__action-icon"></span>
</button>
</div>
<!-- CONDITIONAL: comments expanded -->
<div class="post-card__comments">
<!-- Comments Section component -->
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
--avatar | activity.user | - | activity.user.image or first letter of activity.user.name ?? activity.user.id |
--author | activity.user | - | activity.user.name ?? activity.user.id |
--time | activity.created_at | - | Date - format as relative time |
--text | activity.text | - | activity.text (optional - may be undefined) |
--attachments | activity.attachments | - | Attachment[] with .type, .image_url, .asset_url |
| Like count | activity.reaction_groups | - | activity.reaction_groups?.like?.count ?? 0 |
| Has liked | activity.own_reactions | - | activity.own_reactions.some(r => r.type === 'like') |
| Like toggle | - | client.addActivityReaction({ activity_id, type: 'like' }) / client.deleteActivityReaction({ activity_id, type: 'like' }) | Toggle based on hasLiked. Guard: const client = useFeedsClient(); if (!client) return null; |
| Comment count | activity.comment_count | - | activity.comment_count (number) |
| Has bookmarked | activity.own_bookmarks | - | activity.own_bookmarks.length > 0 |
| Bookmark toggle | - | client.addBookmark({ activity_id }) / client.deleteBookmark({ activity_id }) | Toggle based on hasBookmarked |
| Delete (own) | - | client.deleteActivity({ id: activity.id }) | Only show for own posts (activity.user.id === currentUserId) |
| Report (other's) | - | See Report Modal | Only show for other users' posts |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Like reactions | - | Available - addActivityReaction always available |
| Bookmarks | - | Available - addBookmark always available |
| Delete activity | User must be activity author or admin | Authors can delete own activities |
| Comments | Feed must have comments enabled | Enabled by default |
---
Comments Section
Inline comments for an activity, with a comment input.
Blueprint
<div class="comments-section">
<div class="comments-section__list">
<div class="comments-section__item">
<img class="comments-section__avatar" src="" alt="" />
<div class="comments-section__body">
<div class="comments-section__meta">
<span class="comments-section__author"></span>
<time class="comments-section__time" datetime=""></time>
</div>
<p class="comments-section__text"></p>
</div>
</div>
</div>
<!-- CONDITIONAL: has more comments -->
<button class="comments-section__load-more">Load more comments</button>
<div class="comments-section__input-row">
<img class="comments-section__input-avatar" src="" alt="" />
<input class="comments-section__input" placeholder="Write a comment..." />
<button class="comments-section__submit" disabled aria-label="Send"></button>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Comments list | useActivityComments({ feed, activity }) | - | Returns { comments, has_next_page, is_loading_next_page, loadNextPage }. `comments` starts as `undefined` - MUST call `loadNextPage()` once on mount (useEffect + ref guard) to trigger initial fetch. |
--author | comment.user | - | comment.user.name ?? comment.user.id |
--time | comment.created_at | - | Date - format as relative time |
--text | comment.text | - | comment.text (optional) |
--load-more | has_next_page | onClick={() => loadNextPage()} | loadNextPage is async (request?) => Promise<void> - wrap for onClick, do NOT pass directly |
--submit | - | client.addComment({ object_id: activity.id, object_type: 'activity', comment: text }) | Returns StreamResponse<AddCommentResponse> - comment at result.comment. Field is comment, NOT text; uses object_id + object_type, NOT activity_id |
| Reply to comment | - | client.addComment({ parent_id: comment.id, comment: text }) | parent_id auto-inherits object_id and object_type from parent |
| Delete comment | - | client.deleteComment({ id: comment.id }) | Only for own comments or admins |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Load comments | Feed + activity passed to useActivityComments(). Must call `loadNextPage()` on mount - hook does NOT auto-fetch. Use useEffect + useRef guard to call once. | Required |
| Add comments | - | Available via client.addComment() |
| Nested replies | Pass parent_id to addComment() | Available |
| Real-time updates | Feed must be watched (getOrCreate({ watch: true })) | Comments appear in real-time when watched |
---
Feed List
Scrollable list of activities from a feed, with loading and empty states.
Blueprint
<div class="feed-list">
<!-- Loading state -->
<div class="feed-list__loading">
<span class="feed-list__spinner"></span>
</div>
<!-- Empty state -->
<div class="feed-list__empty">
<span class="feed-list__empty-icon"></span>
<p class="feed-list__empty-text">No posts yet. Be the first to share something!</p>
</div>
<!-- Activities -->
<div class="feed-list__items">
<div class="feed-list__item">
<!-- Post Card component -->
</div>
</div>
<!-- CONDITIONAL: has more activities -->
<button class="feed-list__load-more">Load more</button>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Activities | useFeedActivities(feed) | - | Returns { activities?, is_loading?, has_next_page?, loadNextPage }. **All fields except loadNextPage are optional (`T \ |
| Loading state | is_loading | - | Show spinner when is_loading === true |
| Empty state | activities | - | Show when `!is_loading && (!activities \ |
--load-more | has_next_page | onClick={() => loadNextPage()} | loadNextPage is async () => Promise<void> - wrap for onClick, do NOT pass directly |
| Each item | activities[i] | - | ActivityResponse - pass to Post Card |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Feed data | <StreamFeed feed={feed}> wrapper or pass feed to hook directly | Required - hook reads from context or prop |
| Real-time | Feed created with getOrCreate({ watch: true }) | New activities appear automatically |
| Pagination | - | Cursor-based via loadNextPage() |
---
Follow Button
Toggle button to follow/unfollow a user's feed.
Blueprint
<button class="follow-btn" aria-pressed="false">
<!-- aria-pressed="true" + --following modifier when following -->
Follow
</button>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Is following | useOwnFollows(feed) | - | own_follows?.some(f => f.target === targetFid) - check if any of current user's feeds follow this one |
| Follow | - | feed.follow('user:targetId') | On the current user's timeline feed instance. Do NOT use client.follow() - it won't update reactive hook state. |
| Unfollow | - | feed.unfollow('user:targetId') | On the current user's timeline feed instance. Do NOT use client.unfollow(). |
| Follower count | useFeedMetadata(feed) | - | follower_count |
| Following count | useFeedMetadata(feed) | - | following_count |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Follow/unfollow | Feed instance required | Available |
| Follow count | Feed must be loaded with getOrCreate() | Populated on load |
---
Notification Feed
Aggregated notifications for reactions, comments, follows, and mentions.
Blueprint
<div class="notification-feed">
<div class="notification-feed__header">
<h2 class="notification-feed__title">Notifications</h2>
<span class="notification-feed__badge"></span>
</div>
<div class="notification-feed__list">
<div class="notification-feed__group">
<!-- Modifier: --unread | --unseen -->
<div class="notification-feed__group-header">
<span class="notification-feed__group-verb"></span>
<time class="notification-feed__group-time" datetime=""></time>
</div>
<div class="notification-feed__group-activities">
<!-- Individual notification items -->
</div>
</div>
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Aggregated activities | useAggregatedActivities(feed) | - | Returns { aggregated_activities, is_loading, has_next_page, loadNextPage }. loadNextPage is async - wrap for onClick. |
| Unread/unseen counts | useNotificationStatus(feed) | - | { unread, unseen, last_read_at, last_seen_at } |
| Badge count | useNotificationStatus(feed) | - | unseen or unread count |
| React key | aggregatedActivity.group | - | String identifier - use as key prop. There is no `.id` property. |
| Group verb (derived) | aggregatedActivity.activities[0].type | - | Derive verb from first activity's type, e.g. "like", "comment", "post". There is no `.verb` property on `AggregatedActivityResponse`. |
| Group actors | aggregatedActivity.activities | - | ActivityResponse[] - array of activities in this group |
| Mark read/seen | - | feed.markActivity({ mark_read: [activityId], mark_seen: [activityId] }) | Via Feed API. Also: feed.markActivity({ mark_all_read: true, mark_all_seen: true }) to mark all. |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Notification feed | Feed group with notification config (track_seen, track_read) | notification group has this by default |
| Aggregation | Feed group with aggregation.format configured | notification group has default format |
| Real-time | Feed created with getOrCreate({ watch: true }) | Badge updates in real-time |
---
User Profile Card
User info with follow button, follower/following counts, and recent activity.
Blueprint
<div class="user-profile">
<img class="user-profile__avatar" src="" alt="" />
<h3 class="user-profile__name"></h3>
<div class="user-profile__stats">
<span class="user-profile__stat">
<strong class="user-profile__stat-count"></strong> followers
</span>
<span class="user-profile__stat">
<strong class="user-profile__stat-count"></strong> following
</span>
</div>
<!-- Follow Button component -->
<div class="user-profile__feed">
<!-- Feed List filtered to this user's activities -->
</div>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Avatar | User data | - | user.image or initial letter |
| Name | User data | - | user.name ?? user.id |
| Followers count | useFeedMetadata(userFeed) | - | follower_count |
| Following count | useFeedMetadata(userFeed) | - | following_count |
| User's activities | useFeedActivities(userFeed) | - | Activities on the user's personal feed |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| User feed | client.feed('user', userId) with getOrCreate() | Required |
| Metadata | Returned by getOrCreate() response | Populated on load |
---
Live Activity Card
Used in livestreaming apps (Video + Feeds). A live activity represents an active stream and appears at the top of the feed, separate from regular posts.
Blueprint
<!-- CONDITIONAL: activity.type === "live" - render LiveCard instead of standard Post Card -->
<div class="live-card">
<div class="live-card__badge">
<span class="live-card__dot"></span> <!-- Pulsing red dot via CSS animation -->
LIVE
</div>
<div class="live-card__info">
<img class="live-card__avatar" src="" alt="" />
<div class="live-card__meta">
<span class="live-card__author"></span>
<span class="live-card__title"></span>
</div>
</div>
<button class="live-card__watch">Watch</button>
</div>Wiring
| Element | Read | Write | Property Path |
|---|---|---|---|
| Live activities | useFeedActivities(feed) - filter by type === "live" | - | activity.type === "live" |
live-card__author | Activity data | - | activity.user.name |
live-card__title | Activity data | - | activity.text |
live-card__watch | - | Navigate to watch view | activity.custom.callId |
| Go Live (create, client-side) | - | feed.addActivity({ type: 'live', text: title, custom: { callId } }) | Returns StreamResponse<AddActivityResponse> - save result.activity.id for cleanup |
| Go Live (create, server-side) | - | client.feeds.addActivity({ feeds: ['user:' + userId], type: 'live', text: title, custom: { callId } }) | Server route (/api/feed/live). client.feeds.* - NOT client.* directly. Returns { activity: { id } } |
| End Stream (remove, client-side) | - | client.deleteActivity({ id: liveActivityId }) | Use the activity ID saved from Go Live |
| End Stream (remove, server-side) | - | client.feeds.deleteActivity({ id: liveActivityId }) | Server route. client.feeds.* - NOT client.* directly |
Requirements
| Feature | Requirement | Default |
|---|---|---|
| Live activity type | Use type: "live" to distinguish from posts | Convention - not enforced by API |
| Custom fields | Store callId in activity.custom to link feed activity to video call | - |
| Rendering | Feed List should partition activities: type === "live" at top, rest below | Client-side logic |
Feeds v3 - Setup & Integration
Stream Feeds v3 is a headless SDK - hooks, providers, and state management with zero pre-built UI components. All UI is built with your own components (Tailwind/Shadcn). For full component structure and wiring, see FEEDS-blueprints.md.
Rules: ../../stream/RULES.md (secrets, no auto-seeding, login screen first, strict mode protection).
- Blueprint - HTML with BEM classes defining structure and conditional rendering
- Wiring - API calls to read/write each element, exact property paths
- Requirements - Dashboard settings, API params, and prerequisites
Quick ref
- Packages:
@stream-io/feeds-react-sdk(client - re-exports@stream-io/feeds-client+ React bindings),@stream-io/node-sdk(server - token generation + user upsert only) - No CSS import - SDK is headless, all styling is yours
- First: App Integration -> Setup (CLI / feed groups) before UI.
- Per feature: Jump to section (Feed List, Post Card, ...) when implementing that screen.
Full component blueprints: FEEDS-blueprints.md - load only the section you are implementing.
---
App Integration
Everything needed to wire the Feeds SDK into a working Next.js application.
Setup
Packages: @stream-io/feeds-react-sdk (client - re-exports @stream-io/feeds-client + React bindings), @stream-io/node-sdk (server - token generation, user upsert)
CLI commands (run during scaffold):
# List existing feed groups (v3 apps come with defaults: user, timeline, notification, foryou, story, stories):
getstream api ListFeedGroups
# Create custom feed group if needed:
getstream api CreateFeedGroup --request '{"id":"<name>","default_visibility":"visible","activity_selectors":[{"type":"current_feed"}]}'Default feed groups on a Feeds v3 app:
user- personal feed (activity_selector:current_feed). Post activities here.timeline- aggregated feed of followed users (activity_selector:following)notification- aggregated notifications with seen/read trackingforyou- algorithmic feed (popular + following + follow suggestions)story/stories- stories support
Server Routes
Most feed mutations (post, react, comment, bookmark) happen client-side via the FeedsClient from @stream-io/feeds-react-sdk. The server is used for token generation, user upsert, and cross-product mutations (e.g. posting live activities from an API route).
| Route | Method | Params | Action | Response |
|---|---|---|---|---|
/api/token | GET | ?user_id=xxx | client.upsertUsers([{ id, name, role: 'user' }]), client.generateUserToken({ user_id }) | { feedToken, apiKey, userId } |
See RULES.md > No auto-seeding.
import { StreamClient } from '@stream-io/node-sdk';
const client = new StreamClient(process.env.STREAM_API_KEY!, process.env.STREAM_API_SECRET!);Token generation:
const feedToken = client.generateUserToken({ user_id: userId });
// NOT client.createToken() - that's deprecatedServer-side feed mutations (via @stream-io/node-sdk):
All feed operations on the server-side StreamClient are namespaced under client.feeds.* - NOT client.* directly. This is different from the client-side FeedsClient where methods are on the client directly.
Server-side mutations require user_id
Every client.feeds.* mutation requires a user_id field naming the acting user (the activity/comment/reaction author). Forgetting it returns:
Stream error code 4: <Method> failed with error: "user_id is required for server side requests"This applies to (Node SDK):
addActivity(required)updateActivity/updateActivityPartial(required)restoreActivity(required)addComment/deleteComment(required for ownership)addActivityReaction/deleteActivityReaction(required for ownership)addBookmark/deleteBookmark(required for ownership)pinActivity/unpinActivity(required)upsertActivities(each activity needsuser_id)deleteActivities(batch -user_idat request level)
Exception: client.feeds.deleteActivity({ id, hard_delete }) does NOT take user_id in the Node SDK type - admin clients delete by activity ID directly. (The OpenAPI docs show user_id for some other language SDKs, but the TypeScript SDK omits it.)
// Add activity (server-side) - user_id REQUIRED
const result = await client.feeds.addActivity({
user_id: userId, // <- REQUIRED
feeds: [`user:${userId}`],
type: 'post',
text: 'Hello world',
custom: { callId: '...' },
});
// result.activity.id - the created activity's ID
// Delete activity (server-side) - admin delete, no user_id
await client.feeds.deleteActivity({ id: activityId });
await client.feeds.deleteActivity({ id: activityId, hard_delete: true });
// Update activity (server-side) - user_id REQUIRED
await client.feeds.updateActivityPartial({
id: activityId,
user_id: userId, // <- REQUIRED
set: { text: 'Updated' },
});Key difference from client-side API: On the server, addActivity requires a feeds array specifying target feeds. The client-side feed.addActivity() implicitly targets the feed it's called on.
Client Patterns
- Login Screen first: See RULES.md > Login Screen first + builder-ui.md > Login Screen.
- App Header: Show the current username + avatar (initial letter) + "Switch User" in a persistent header. See `builder-ui.md` -> App Header.
- Instantiate: Use
useCreateFeedsClient()hook - it handlesconnectUser()internally. - Provider pattern:
import { useCreateFeedsClient, StreamFeeds, StreamFeed } from '@stream-io/feeds-react-sdk';
const client = useCreateFeedsClient({
apiKey,
tokenOrProvider: token, // string or async () => string
userData: { id: userId, name: userId },
// options?: { base_url?, timeout? }
});
if (!client) return <Loading />; // null until connected
<StreamFeeds client={client}>
<StreamFeed feed={feed}>
{/* Components using useFeedActivities(), useActivityComments(), etc. */}
</StreamFeed>
</StreamFeeds>- Feed initialization - must call `getOrCreate()`:
Each user posts to their own user:<userId> feed and reads from timeline:<userId> (which aggregates posts from followed users). Do NOT use a shared feed like user:community - users don't have permission to post to feeds they don't own.
// User's own feed (post here)
const userFeed = client.feed('user', userId);
await userFeed.getOrCreate({ watch: true });
// User's timeline (read here - shows posts from followed users)
const timelineFeed = client.feed('timeline', userId);
await timelineFeed.getOrCreate({ watch: true });
// User's timeline follows user's feed so user can see their own posts on their own timeline
await client.getOrCreateFollow({ source: timelineFeed.feed, target: userFeed.feed });- Strict mode:
useCreateFeedsClient()handles connection internally. Butfeed.getOrCreate()must be wrapped insetTimeout(50ms)+mountedguard pattern (RULES.md > Strict mode protection). - Gate rendering on
client !== null-useCreateFeedsClient()returnsnulluntil connected. - Context hooks:
useFeedsClient()- returnsFeedsClient | undefined(undefined if no<StreamFeeds>parent). Always guard:if (!client) return null;useFeedContext()- returns the Feed from the nearest<StreamFeed>parent.
Key Types
ActivityResponse (what you render in posts - verified from SDK source):
| Field | Type | Notes |
|---|---|---|
id | string | Unique activity ID |
type | string | Activity type (e.g. "post") |
text | `string \ | undefined` |
user | UserResponse | The author. Has .id, .name?, .image?, .custom |
created_at | Date | Creation timestamp |
updated_at | Date | Last update timestamp |
comment_count | number | Number of comments |
reaction_count | number | Total reaction count |
bookmark_count | number | Number of bookmarks |
share_count | number | Number of shares |
reaction_groups | Record<string, FeedsReactionGroupResponse> | Reactions grouped by type. Each value has { count, first_reaction_at, last_reaction_at }. Use activity.reaction_groups?.like?.count |
own_reactions | FeedsReactionResponse[] | Flat array. Check activity.own_reactions?.some(r => r.type === 'like') |
own_bookmarks | BookmarkResponse[] | Current user's bookmarks. own_bookmarks.length > 0 = bookmarked |
latest_reactions | FeedsReactionResponse[] | Recent reactions |
comments | CommentResponse[] | Latest comments (replies excluded) |
attachments | Attachment[] | Media attachments |
custom | Record<string, any> | Custom data |
visibility | `'public' \ | 'private' \ |
restrict_replies | `'everyone' \ | 'people_i_follow' \ |
feeds | string[] | Feed IDs containing this activity |
hidden | boolean | If hidden via activity feedback |
popularity | number | Popularity score |
score | number | Ranking score |
preview | boolean | Preview flag |
deleted_at | `Date \ | undefined` |
edited_at | `Date \ | undefined` |
CommentResponse:
| Field | Type | Notes |
|---|---|---|
id | string | Unique comment ID |
object_id | string | ID of the parent object (activity) |
object_type | string | Type of parent object ("activity") |
text | `string \ | undefined` |
user | UserResponse | Comment author |
created_at | Date | Creation timestamp |
reply_count | number | Number of replies |
reaction_count | number | Total reactions |
own_reactions | FeedsReactionResponse[] | Current user's reactions |
latest_reactions | `FeedsReactionResponse[] \ | undefined` |
reaction_groups | `Record<string, FeedsReactionGroupResponse> \ | undefined` |
parent_id | `string \ | undefined` |
attachments | `Attachment[] \ | undefined` |
custom | `Record<string, any> \ | undefined` |
status | `'active' \ | 'deleted' \ |
upvote_count | number | Upvotes |
downvote_count | number | Downvotes |
mentioned_users | UserResponse[] | Mentioned users |
FeedsReactionResponse:
| Field | Type | Notes |
|---|---|---|
activity_id | string | Reacted activity |
type | string | Reaction type (e.g. "like") |
user | UserResponse | Who reacted |
created_at | Date | When created |
updated_at | Date | When updated |
comment_id | `string \ | undefined` |
custom | `Record<string, any> \ | undefined` |
FeedsReactionGroupResponse:
| Field | Type | Notes |
|---|---|---|
count | number | Number of reactions in this group |
first_reaction_at | Date | Time of first reaction |
last_reaction_at | Date | Time of most recent reaction |
AggregatedActivityResponse (what you render in notification groups - verified from SDK source):
| Field | Type | Notes |
|---|---|---|
group | string | Grouping identifier - use as React key |
activities | ActivityResponse[] | Activities in this aggregation. Derive the "verb" from activities[0].type (e.g. "like", "comment", "post"). |
activity_count | number | Number of activities in this aggregation |
user_count | number | Number of unique users in this aggregation |
user_count_truncated | boolean | Whether user count was truncated due to group size limit |
created_at | Date | When the aggregation was created |
updated_at | Date | When the aggregation was last updated |
score | number | Ranking score for this aggregation |
is_read | `boolean \ | undefined` |
is_seen | `boolean \ | undefined` |
There is no `.id` or `.verb` property. Use group as key and derive verb from activities[0].type.
Client Methods (Mutations)
All methods below are on the client-side FeedsClient (from useCreateFeedsClient() or useFeedsClient()). All return Promise<StreamResponse<T>> where StreamResponse<T> = T & { metadata: RequestMetadata }.
// Activities - via Feed instance (preferred for single-feed posts)
const result = await feed.addActivity({ type: 'post', text: 'Hello world' });
// result type: StreamResponse<AddActivityResponse>
// result.activity.id - the created activity's ID (nested inside response)
// result.activity - full ActivityResponse of the created post
// Activities - via FeedsClient (for multi-feed posts)
await client.addActivity({ feeds: ['user:community'], type: 'post', text });
// Delete activity
await client.deleteActivity({ id: activityId });
// Hard delete:
await client.deleteActivity({ id: activityId, hard_delete: true });
// Update activity
await client.updateActivity({ id: activityId, text: 'Updated text' });
// Partial update
await client.updateActivityPartial({ id: activityId, set: { text: 'new' }, unset: ['custom.old_field'] });
// Reactions
await client.addActivityReaction({ activity_id: activityId, type: 'like' });
await client.addActivityReaction({ activity_id: activityId, type: 'like', enforce_unique: true });
await client.deleteActivityReaction({ activity_id: activityId, type: 'like' });
// Comment reactions
await client.addCommentReaction({ id: commentId, type: 'like' });
await client.deleteCommentReaction({ id: commentId, type: 'like' });
// Comments - note: field is `comment`, NOT `text`; uses `object_id`+`object_type`, NOT `activity_id`
const commentResult = await client.addComment({ object_id: activityId, object_type: 'activity', comment: 'Nice post!' });
// commentResult.comment.id - the created comment's ID (nested inside response)
// Replies - parent_id auto-inherits object_id and object_type:
await client.addComment({ parent_id: parentCommentId, comment: 'Reply text' });
// Update comment:
await client.updateComment({ id: commentId, comment: 'Edited text' });
// Delete:
await client.deleteComment({ id: commentId });
await client.deleteComment({ id: commentId, hard_delete: true });
// Bookmarks
await client.addBookmark({ activity_id: activityId });
await client.addBookmark({ activity_id: activityId, folder_id: 'saved' });
await client.deleteBookmark({ activity_id: activityId });
// Follows (via Feed instance) - PREFERRED for UI code
// Calling follow/unfollow on the feed instance keeps the SDK's reactive state
// (useFeedActivities, useOwnFollows, etc.) in sync so the timeline updates immediately.
await timelineFeed.follow('user:tom');
await timelineFeed.unfollow('user:tom');
// Follows (via FeedsClient) - server-side or non-reactive contexts only
// WARNING: client.follow() updates the server but does NOT notify hooks/providers.
// The timeline feed's useFeedActivities() will NOT refresh. Do NOT use this in
// components that display feed data - use feed.follow() instead.
await client.follow({ source: 'timeline:alice', target: 'user:tom' });
await client.unfollow({ source: 'timeline:alice', target: 'user:tom' });
// File uploads
await client.uploadImage({ file: fileObject });
await client.uploadFile({ file: fileObject });
// Activity feedback (hide/report) - uses boolean flags, NOT a `type` field
await client.activityFeedback({ activity_id: activityId, hide: true });
// Also available: show_less: true, show_more: true
// Pin/unpin - via FeedsClient (requires feed_group_id + feed_id separately)
await client.pinActivity({ activity_id: activityId, feed_group_id: 'user', feed_id: 'community' });
await client.unpinActivity({ activity_id: activityId, feed_group_id: 'user', feed_id: 'community' });
// Or via Feed instance (feed context is implicit):
await feed.pinActivity({ activity_id: activityId });
await feed.unpinActivity({ activity_id: activityId });
// Query feeds
await client.queryFeeds({ filter: { ... }, limit: 25 });React Hooks
Hook overloads: Most hooks have two overloads. Pass feed explicitly for a guaranteed return type. Omit feed (uses <StreamFeed> context) and the return type may be T | undefined if no provider exists.
Async pagination: All loadNextPage functions are async (() => Promise<void>). They cannot be passed directly to onClick handlers - wrap them: onClick={() => loadNextPage()}.
| Hook | Returns | Notes |
|---|---|---|
useCreateFeedsClient({ apiKey, tokenOrProvider, userData, options? }) | `FeedsClient \ | null` |
useFeedsClient() | `FeedsClient \ | undefined` |
useFeedActivities(feed?) | { activities?, is_loading?, has_next_page?, loadNextPage } | **All fields except loadNextPage are optional (`T \ |
useActivityComments({ feed?, activity?, parentComment? }) | { comments, has_next_page, is_loading_next_page, loadNextPage, comments_pagination } | `comments` starts as `undefined` - you MUST call `loadNextPage()` once on mount to trigger initial fetch. Pass feed and activity explicitly. loadNextPage is (request?) => Promise<void>. |
useFollowers(feed?) | { followers?, follower_count?, has_next_page, is_loading_next_page, loadNextPage } | With required feed: always returns data. Without: may return undefined. |
useFollowing(feed?) | { following?, following_count?, has_next_page, is_loading_next_page, loadNextPage } | Same overload pattern as useFollowers. |
useMembers(feed?) | { members?, member_count?, has_next_page, is_loading_next_page, loadNextPage } | Feed members |
useFeedMetadata(feed?) | `{ created_by, follower_count, following_count, created_at, updated_at } \ | undefined` |
useOwnFollows(feed?) | `{ own_follows } \ | undefined` |
useOwnFollowings(feed?) | `{ own_followings } \ | undefined` |
useOwnCapabilities(feed?) | readonly FeedOwnCapability[] | Current user's capabilities on this feed |
useAggregatedActivities(feed) | { aggregated_activities, is_loading, has_next_page, loadNextPage } | For notification/aggregated feeds. With required feed: guaranteed. Without: `T \ |
useNotificationStatus(feed?) | { unread, unseen, last_read_at, last_seen_at, read_activities, seen_activities } | For notification feeds. Same overload pattern. |
useClientConnectedUser() | `ConnectedUser \ | undefined` |
useWsConnectionState() | `{ is_healthy: boolean \ | undefined }` |
Provider Components
| Component | Props | Purpose |
|---|---|---|
<StreamFeeds client={client}> | client: FeedsClient | Top-level provider - wraps the entire app |
<StreamFeed feed={feed}> | feed: Feed | Per-feed provider - enables context hooks |
<StreamActivityWithStateUpdates activityWithStateUpdates={awsu}> | activityWithStateUpdates: ActivityWithStateUpdates | For activity detail pages. Create with client.activityWithStateUpdates(id). NOT `activityId` - requires the full object. |
Real-time Events
// On a Feed instance (returns unsubscribe function):
const unsub = feed.on('feeds.activity.added', (event) => { /* new activity */ });
feed.on('feeds.comment.added', (event) => { /* new comment */ });
feed.on('feeds.activity.reaction.added', (event) => { /* new reaction */ });
feed.on('feeds.bookmark.added', (event) => { /* bookmark added */ });
// On FeedsClient:
client.on('feeds.follow.created', (event) => { /* new follow */ });
client.on('connection.changed', (event) => { /* connection state */ });
client.on('moderation.flagged', (event) => { /* content flagged */ });Gotchas
- `activity.user` - the author. NOT
activity.actor(does not exist in v3). - `activity.reaction_groups` - reactions grouped by type. Each value:
{ count, first_reaction_at, last_reaction_at }. NOTreaction_counts. - `activity.own_reactions` - flat
FeedsReactionResponse[]array. Check with.some(r => r.type === 'like'). NOT a Record keyed by type. - `activity.own_bookmarks` -
BookmarkResponse[]. Check.length > 0for bookmarked state. - `addComment()` uses
{ object_id, object_type, comment }- NOT{ activity_id, text }. - `deleteActivity()` uses
{ id }- NOT{ activity_id }. - `deleteComment()` uses
{ id }- NOT{ comment_id }. - `addCommentReaction()` uses
{ id, type }whereidis the comment ID. - `useCreateFeedsClient()` handles
connectUser()internally - do NOT callconnectUser()separately. - `useFeedsClient()` returns `FeedsClient | undefined` - NOT
null. Always guard before calling methods on it. - `useFeedActivities()` returns optional fields -
activities,is_loading,has_next_pageare allT | undefined. - `loadNextPage()` is async - all pagination functions return
Promise<void>. Wrap for onClick:onClick={() => loadNextPage()}. Do NOT pass directly as an event handler. - `feed.addActivity()` returns `StreamResponse<AddActivityResponse>` - the created activity is at
result.activity, NOTresultdirectly. Access ID viaresult.activity.id. - `client.addComment()` returns `StreamResponse<AddCommentResponse>` - the comment is at
result.comment, NOTresultdirectly. - `StreamActivityWithStateUpdates` takes
activityWithStateUpdatesprop (the full object), NOTactivityId. - No CSS import needed - SDK is headless.
- `generateUserToken()` on server - NOT
createToken()(deprecated). - `useActivityComments()` does NOT auto-load -
commentsstarts asundefined. You MUST callloadNextPage()once on mount (viauseEffect+ ref guard) to trigger the initial fetch. Without this, comments will never appear. - `upsertUsers` takes an array:
client.upsertUsers([{ id, name, role }])- NOT keyed by ID. - `AggregatedActivityResponse` has no `.id` or `.verb` - use
.groupas React key, derive verb from.activities[0].type. See Key Types above. - `feed.follow()` vs `client.follow()` in UI code - always use
timelineFeed.follow('user:targetId')in components.client.follow({ source, target })updates the server but does NOT trigger hook re-renders - the timeline will stay empty until a manual refresh. The feed instance method keepsuseFeedActivities()and other hooks in sync. - Server-side `StreamClient` vs client-side `FeedsClient` - on the server (
@stream-io/node-sdk), all feed operations are namespaced underclient.feeds.*(e.g.client.feeds.addActivity(),client.feeds.deleteActivity()). Do NOT useclient.addActivity()orclient.deleteActivity()directly - those don't exist onStreamClient. The client-sideFeedsClient(from@stream-io/feeds-react-sdk) has methods directly on the client (e.g.client.addActivity()). - Server-side mutations require `user_id` - every
client.feeds.*mutation (addActivity, updateActivity, addComment, addActivityReaction, addBookmark, pinActivity, etc.) needs auser_idfield. Exception:deleteActivityis admin-by-id only. See "Server-side mutations requireuser_id" above for the full list.
Moderation - CLI setup + end-user actions
Cross-product moderation for Chat, Feeds, and Video. Moderation review (queue, flagged items, approve/ban) is handled exclusively in the [Stream Dashboard](https://beta.dashboard.getstream.io) - never build review UI in the app (RULES.md > Moderation is Dashboard-only).
Rules: ../../stream/RULES.md (moderation is Dashboard-only).
Quick ref
- Builder default: Use Setup below for CLI-only config during scaffold. Do not build any moderation review UI.
- End-user actions (report, block, mute): see MODERATION-blueprints.md - Report Modal + Block/Mute Controls sections only.
- Review Queue, Flagged Item, Auto-Mod Status blueprints exist in the blueprints file as reference material but must not be used by the builder. Review happens in the Dashboard.
---
App Integration
Setup
Packages: @stream-io/node-sdk (server), stream-chat (server - for chat message deletion)
CLI commands (run when moderation is included):
# Create blocklist (NOT idempotent - check first to avoid 400 error):
getstream api ListBlockLists 2>&1 | grep -q '"profanity"' || \
getstream api CreateBlockList --request '{"name":"profanity","words":[<generate a comprehensive list of common profanity>]}'
# Attach blocklist to the channel type being used (e.g., livestream, team, messaging):
getstream api UpdateChannelType --name livestream --request '{"max_message_length":5000,"blocklist":"profanity","blocklist_behavior":"flag","automod":"disabled","automod_behavior":"flag"}'
# Enable blocklist in moderation config (required for review queue population):
getstream api UpsertConfig --request '{"key":"chat","block_list_config":{"enabled":true,"rules":[{"name":"profanity","action":"flag"}]}}'
# If Feeds is also used - chat config does NOT cover feeds:
getstream api UpsertConfig --request '{"key":"feeds","block_list_config":{"enabled":true,"rules":[{"name":"profanity","action":"flag"}]}}'IMPORTANT: Do NOT use 2>/dev/null || true to suppress CreateBlockList errors - this also swallows the CLI's refusals and confirmation prompts, causing silent failure. The blocklist must exist before UpsertConfig runs, or it will fail with "Blocklist not found".
Server Routes
No moderation-specific server routes needed - review happens in the Dashboard. The app only needs the standard /api/token route from the primary product (Chat, Feeds, etc.).
Gotchas
- Blocklist alone doesn't populate review queue - MUST also
UpsertConfigwithblock_list_config.enabled: true - Need BOTH
key: "chat"ANDkey: "feeds"configs if both products are used - chat config doesn't cover feeds - Use
flagnotblockforblocklist_behavior- flag delivers content AND flags it in review queue CreateBlockListis NOT idempotent - returns 400 if exists. Check withListBlockListsfirst. Do NOT use2>/dev/null || true- it swallows CLI confirmation prompts- Generate real profanity for the blocklist - not placeholders like "badword1"
- Custom rules (
upsert_moderation_rule) return 403 on free plans - use blocklist + config instead - Flagged content review: Stream Dashboard - never in-app
# Use-case recipes for the stream-builder skill.
#
# The builder reads this manifest before Step 4 (Generate code and UI). If the user's
# request matches a recipe's `signals`, the builder loads that recipe file and follows
# it as the build plan: the recipe declares which Stream products to scaffold, which
# product references to load alongside it (`load_with`), and the decisions to ask.
# If nothing matches, the builder uses the generic product blueprints.
#
# This keeps the builder generic: adding a use-case is a new file here + a manifest
# entry below, never an edit to stream-builder/SKILL.md.
#
# Schema per entry:
# use_case: kebab-case id (matches the recipe file's frontmatter)
# file: path relative to this directory
# signals: trigger phrases (lowercased substrings of the user's request)
# products: Stream products the recipe scaffolds (chat | video | feeds | moderation)
use_cases:
- use_case: ai-support-agent
file: ai-support-agent.md
signals:
- "ai support agent"
- "support bot"
- "help desk"
- "rag chat"
- "customer support ai"
- "ai customer support"
products: [chat]
SDK reference - cross-cutting patterns
Rules: the stream skill's `RULES.md` (secrets, strict mode protection, package manager). CLI: onboard with getstream init before any workflow that needs the getstream CLI; see `../stream/SKILL.md` > Stream CLI for usage. Product-specific SDK wiring, gotchas, and client patterns: see `references/*.md` App Integration sections.
---
Token endpoint pattern (all products)
GET /api/token?user_id=xxx - upsert the requesting user only (RULES.md > No auto-seeding), return per-product tokens.
Combined token route when multiple products are used:
// Returns whichever tokens the use case needs:
{ chatToken, videoToken, feedToken, apiKey }Server-side client instantiation
| Product | Package | Instantiation |
|---|---|---|
| Chat | stream-chat | StreamChat.getInstance(apiKey, apiSecret) - singleton OK server-side |
| Video | @stream-io/node-sdk | new StreamClient(apiKey, apiSecret) |
| Feeds (token only) | @stream-io/node-sdk | new StreamClient(apiKey, apiSecret) - token generation + user upsert only |
Client-side instantiation
| Product | Package | Instantiation |
|---|---|---|
| Chat | stream-chat + stream-chat-react | new StreamChat(apiKey) - never getInstance() on client (RULES.md > Strict mode protection) |
| Video | @stream-io/video-react-sdk | new StreamVideoClient({ apiKey, user: { id, name }, token }) |
| Feeds v3 | @stream-io/feeds-react-sdk | useCreateFeedsClient({ apiKey, tokenOrProvider, userData }) - returns `FeedsClient \ |
CSS imports
// Chat (v14+ preferred alias; v13 used dist/css/v2/index.css)
import 'stream-chat-react/css/index.css';
// Video
import '@stream-io/video-react-sdk/dist/css/styles.css';Theme hook (next-themes)
Use useTheme() from next-themes (scaffolded automatically) to read resolvedTheme and pass to Stream Chat:
import { useTheme } from "next-themes";
const { resolvedTheme } = useTheme();
const theme = resolvedTheme === "dark" ? "str-chat__theme-dark" : "str-chat__theme-light";
<Chat client={client} theme={theme}>searchParams narrowing
searchParams.get() returns string | null - guard before passing to SDK methods.
upsertUsers format
Both StreamChat and StreamClient take an array of user objects:
client.upsertUsers([{ id, name, role: 'user' }]) // NOT an object keyed by IDFeeds v3 - client-side React SDK
All feed mutations (post, react, comment, bookmark, follow) happen client-side through FeedsClient from @stream-io/feeds-react-sdk. The server-side @stream-io/node-sdk is used only for the /api/token route (user upsert + token generation).
Key type contracts (verified from SDK source):
| Hook / Method | Return type | Watch out |
|---|---|---|
useCreateFeedsClient() | `FeedsClient \ | null` |
useFeedsClient() | `FeedsClient \ | undefined` |
feed.addActivity() | StreamResponse<AddActivityResponse> | Activity at result.activity, ID at result.activity.id - NOT result.id |
client.addComment() | StreamResponse<AddCommentResponse> | Comment at result.comment - NOT result directly |
loadNextPage() (all hooks) | () => Promise<void> | Async - wrap for onClick: onClick={() => loadNextPage()} |
useFeedActivities() | { activities?, is_loading?, has_next_page?, loadNextPage } | All fields except loadNextPage are `T \ |
See references/FEEDS.md for complete type reference.
Moderation - CLI setup only
Moderation is configured via CLI during scaffold - NOT built as in-app UI. Review happens in the Stream Dashboard. CLI commands: see references/MODERATION.md (App Integration -> Setup).
Related skills
How it compares
Use stream-builder when standardizing on GetStream for chat or feeds; skip it when the architecture mandates a self-hosted messaging layer without third-party Stream SDKs.
FAQ
What GetStream products does stream-builder cover?
stream-builder covers GetStream Chat and Activity Feeds integration. It helps configure apps, tokens, channels, and webhooks so backends and agents can send or moderate real-time messages across connected clients.
Can stream-builder wire agent workflows to Stream?
stream-builder supports agent workflows that send or moderate messages on Stream channels. It focuses on token auth, channel setup, and webhook patterns agents need instead of only static REST examples.
When should developers use stream-builder?
Developers should use stream-builder when adding GetStream-powered chat or feeds to an application and need coordinated app config, user tokens, channels, and webhook handlers rather than a custom socket layer.