Build, integrate, audit, or upgrade Stream Chat, Video, and Feeds in React Native CLI and Expo / Expo Router apps, and answer RN / Expo how-to questions about them. Scaffold a new app, add Stream to an existing project, review an integration, upgrade an SDK major, migrate from Sendbird (@sendbird/chat, @sendbird/uikit-react-native), or build an AI assistant / ChatGPT-style app on the Chat SDK's ai-components (replacing the deprecated @stream-io/chat-react-native-ai), or one on Stream Agents. Covers channel lists, messages, MessageComposer, threads, the attachment picker, audio messages, offline support, push notifications, video calls, CallContent and ParticipantView, ringing, livestreams, audio rooms, StreamFeeds activity / timeline / notification feeds, comments, reactions, follows, and theming or matching a design, with stream-chat-react-native, stream-chat-expo, @stream-io/video-react-native-sdk, and @stream-io/feeds-react-native-sdk. React Native and Expo only - web React uses stream-react.
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Don't write Stream SDK code from memory. APIs change between majors, so your
memory is stale. Read the docs first, via the getstream docs command:
cat $(getstream docs chat-sdk/react-native)/INDEX.mdClient and UI: chat-sdk/react-native, video/react-native, activity-feeds/react-native; the low-level Chat client (permissions, query filters, channel types) is chat/react-native. Node.js backend (token route, webhooks): chat/node, activity-feeds/node, moderation/node. Previous Chat majors are available too (chat-sdk/react-native/v).
Chat SDK majors can differ deeply (state, channel lists, threads, configuration). Before any Chat code, pin the installed major, read only that major's docs (never the unversioned id on trust), and treat SDK names in this skill and in your memory as leads to confirm there - follow chat-versions.md.
Video versions its docs too: getstream docs list shows which
video/react-native major is Latest and which is a beta. Read the installed
@stream-io/video-react-native-sdk major and pass the versioned id
(video/react-native/v) when it isn't the Latest one.
A how-to question needs no setup: answer it from the pinned major's docs and say which major the answer is for.
Start with getstream init (binds the working dir to a Stream app, existing or
new) and getstream env --target expo (writes EXPO_PUBLIC_STREAM_API_KEY to
.env). getstream env writes .env to the folder getstream init ran in,
which isn't always the app folder (an app scaffolded in a subfolder): move it
next to the app's package.json, or Metro never reads it. A bare RN CLI app
has no env target: put the public API key in the app's config, never the
secret.
For new apps, Expo (npx create-expo-app@latest, Expo Router) is the default
unless the user asks for RN CLI. In an existing app, read the lane before
editing: Expo or RN CLI (app.json / app.config.*, ios/ + android/),
the Expo SDK version, Expo Router or React Navigation, and the package manager.
Keep all of them. In a non-empty directory that isn't an RN app, ask before
scaffolding a child app there.
Install the SDKs, with the peers listed on each product's Installation page:
stream-chat plus one wrapper for the lane - stream-chat-expo on
Expo, stream-chat-react-native on RN CLI, never both (see
chat-versions.md for which major)@stream-io/video-react-native-sdk (both lanes)@stream-io/feeds-react-native-sdk (both lanes)@stream-io/node-sdkDon't bump an already-installed Stream major. Expo apps use npx expo install
so versions match the Expo SDK.
Writes through getstream api (product configuration: channel types, feed
groups, moderation policies, and the Video label below) are app-wide. Before the
first one, run getstream status and tell the user which app it will change, so
a stale CLI binding doesn't write to the wrong (possibly production) app.
For Video apps, label the use case once, without asking the user (metadata, no runtime effect):
getstream api UpdateApp --request '{"video_primary_use_case":"livestreaming"}'Allowed values: video-calling, voice-calling, livestreaming,
audio-rooms, ai-agents, live-shopping, other. Derive it from what the
app is, not from the call type in use (Whatnot runs on the livestream call
type but is live-shopping). Any commerce signal means live-shopping. Unsure
means other.
Native setup that costs real runs:
expo-dev-client, npx expo prebuild), never Expo Go. Every native package
added later means another prebuild and native build, so decide the full set
of capability packages (picker, camera, audio recording, video playback,
offline, ringing) before the first build.babel-preset-expo adds the worklets plugin itself -
don't create a babel.config.js (a hand-written one can break Metro with
Cannot read properties of undefined (reading 'transformFile')). On RN CLI
the Reanimated / worklets plugin goes last in babel.config.js.react-native-reanimated@4.5.0 + react-native-worklets@0.10.0,
a pair that crashes on animation paths (not at boot, so a clean launch proves
nothing). Require reanimated >= 4.5.1 and worklets >= 0.10.2, installed after
the other npx expo install calls, which re-pin the bad pair. Check the
installed versions, not the launch."reanimated": { "staticFeatureFlags": { "FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS": false } }
in the root package.json (the default breaks bottom sheets, the message
overlay, and context menus). It is read at pod install: rebuild native after.@react-navigation/*
package. Don't install one, even for useHeaderHeight: recent Expo Router
(SDK 58) exports it from expo-router/react-navigation.react-native-teleport (a Chat peer) has react-dom as an optional peer:
when the app already has react-dom (the default Expo template does), pin it
to the app's exact react version, or the install resolves a mismatch.SafeAreaProvider from react-native-safe-area-context at the
root, never SafeAreaView from react-native. On RN 0.85 / Expo 56 the
package's SafeAreaView can fail to apply insets; useSafeAreaInsets() plus
padding works. Android draws edge-to-edge (edgeToEdgeEnabled: Expo
app.json, RN CLI 0.81+ gradle.properties).Tokens are minted server-side: a route uses the secret and returns the token
with the apiKey. The route derives the Stream user id from its own session
(cookie, JWT, OAuth subject), never from a user_id the app sends - that lets
any signed-in user impersonate any other. The secret never reaches the app
bundle, app.json, or native config. An app that already has auth or a token
endpoint gets it extended, not a second one.
Start with a login screen. Don't auto-connect, don't hardcode user credentials, don't seed demo users or content on app load. The token route upserts only the requesting user.
Without a backend: a new Stream app has dev tokens off, so devToken() is
rejected. Don't turn auth checks off to get around it (an app-wide security
setting). Mint a token per test user with getstream token <user_id> (one
call per user) into a __DEV__-gated, gitignored module, let the login screen
pick from that roster, and say a real deployment needs a token route.
EXPO_PUBLIC_* values are inlined at bundle time: restart Metro with --clear
after editing .env.
Clients mount once, at the app root, above the navigator. Don't
disconnectUser() in a screen's cleanup - it kills the client every other
screen shares. A call screen's cleanup is call.leave(). A channel screen needs
no cleanup: don't stopWatching() a channel when its screen closes - the
channel list still shows it and needs its events.
useCreateChatClient() (returns null while connecting - render
nothing until it's ready). With offline support this blocks the cached UI;
see the offline bullet under Chat.useCreateFeedsClient() (returns undefined while connecting).StreamVideoClient.getOrCreateInstance() in a useEffect with
disconnectUser() cleanup, never new StreamVideoClient() - a second
instance breaks push and ringing.Providers nest; they are never siblings: GestureHandlerRootView >
SafeAreaProvider > Stream providers > navigator. Chat's OverlayProvider sits
above the navigator, or the long-press menu, attachment picker, and image
gallery render under the screen. Navigation params carry ids (channel.cid, a
call id, an activity id), never a Channel, Call, or activity object.
Start with prebuilt Stream SDK components. Customize them via documented props, theme, and hooks. Feeds has no prebuilt UI - build from its hooks.
Land on a channel list, lobby, or feed by default - never directly in a call or a camera prompt. Camera/mic permission is requested only on an explicit action (Join, Go Live). Empty states say what to do next.
Chat:
<WithComponents overrides={...}>, placed
above the navigator. To change a shared slot (such as EmptyStateIndicator,
which the channel list, message list, and thread all read) on one screen
only, wrap just that screen's component in its own WithComponents; nested
ones merge with their parent. A component passed as a <Channel> prop is
silently ignored and looks like a stale bundle. WithComponents reads its
overrides once, at mount: an override that must change at runtime (theme,
state) reads it inside the component (useTheme(), a hook), not by swapping
the overrides object. Some slots (the
MessageContent*View slots, MessageSpacer, MessageText, Input) are
optional overrides, not defaults: grep both DEFAULT_COMPONENTS and
OptionalComponentOverrides in the installed core before saying a slot
doesn't exist.<Channel>'s keyboardVerticalOffset
doesn't mean 0 - the keyboard view falls back to its own default (86.5 on
iOS, -300 on Android). With the header rendered inside <Channel>, pass
keyboardVerticalOffset={0} and topInset={0}. Under a native navigation
header, pass the header height to both - topInset is what the attachment
picker uses for its snap point. On Expo Router, take the height from
useHeaderHeight in expo-router/react-navigation when the installed
version exports it, else (Platform.OS === 'ios' ? 44 : 56) + insets.top.
On iOS 26 the native header is transparent by default (content scrolls under
it, so no offset is right): set headerTransparent: false on the stack. A
gap between the composer and the picker means topInset is wrong; don't
close it with bottomInset. Check by focusing the input so the real keyboard
rises (setText doesn't raise it).channel.data?.own_capabilities. Outside <Channel>, read them with
useChannelOwnCapabilities(channel); the context hook throws there.supportedReactions on <Channel>. Extend the
exported reactionData instead of rebuilding it, and mark the picker's
entries isMain: true - entries without it never show in the picker.openThread(message) plus your navigation to the thread screen; start an
edit with setEditingState after the menu's Modal has closed, then focus the
input (an edit started while the Modal is open silently does nothing).enableOfflineSupport on <Chat> plus
@op-engineering/op-sqlite). It only helps if the UI renders before the
connection does: useCreateChatClient returns null until connected, so an
offline launch shows a blank screen. Create the client yourself and call
connectUser without awaiting it before rendering <Chat>, as
basics/offline-support.md shows. On sign-out call
client.offlineDb?.resetDB() before disconnectUser(), or the next user on
the device sees the previous user's data.Feeds:
@stream-io/feeds-react-native-sdk. Some docs snippets import
the web package (@stream-io/feeds-react-sdk); don't copy that.client.follow(), so the feed hooks update. Follow your own user feed too, or
your own posts never appear in your timeline.queryActivities (a one-off query that never updates).client.activityWithStateUpdates(id) must ask for
comments in its get() call, or useActivityComments stays empty. Dispose
the handle on unmount.foryou feed group returns nothing until its activity_selectors are
configured (getstream api UpdateFeedGroup), and a popular selector also
needs activities that clear its min_popularity.client.createDevice() (native token, not
the Expo push token) and removed with client.deleteDevice() before
disconnecting - not Video's addDevice.Video:
client.call(type, id, { reuseInstance: true }) - the same call may already
exist from a ring, a deep link, or push, and without the flag the SDK builds a
duplicate. Descendants read it with useCall().if (call.state.callingState !== CallingState.LEFT) await call.leave(). The
default hang-up already leaves, and a second leave() throws. Hang-up
handlers only navigate.callManager.start/stop for normal calls; only a view-only audience
(livestream viewer, audio-room listener) changes audioRole to listener.StreamVideoRN.setPushConfig needs createStreamVideoClient,
built with the same options as the app's client - a mismatch breaks ringing.
Non-ringing call pushes are app-owned and on iOS use the APNs token, not the
VoIP token.variants.insets from
useSafeAreaInsets()). Don't also wrap CallContent and its siblings in
SafeAreaView - that doubles the padding.Push, ringing, and Chat with Video follow the docs runbooks: Chat's
guides/push-notifications.md, Video's advanced/incoming-calls/, and
Video's advanced/chat-with-video.md (one API key and token for both, nested
providers).
Moderation:
CreateBlockList 400s if the list already exists - check ListBlockLists
first. Fill blocklists with real words, not badword1 placeholders.The prebuilt components ship features users expect. Keep each one on unless the user turns it off by name. A design reference that doesn't show a feature, or "keep it minimal", is not turning it off: keep the feature and style it to match, or ask.
Override the smallest slot that does the job, and add to existing
WithComponents overrides instead of replacing them. Replacing a whole region
drops everything it rendered by default:
The defaults, and what usually turns them off:
message.text in a plain <Text>, or a
custom MessageText that doesn't build on the SDK's, turns it off. Check:
send **bold**, a list, and a code block.isMain. Check: long-press, react, and see the reaction.AttachmentPicker and override only
AttachmentPickerSelectionBar / AttachmentPickerContent; don't build a
separate modal. The picker's toggle lives inside the default AttachButton
and isn't exported: a custom attach button reproduces it (design-matching.md,
Build). Check: open the picker, attach an image, send.OutputButtons (it swaps send, mic, and stop by state)
instead of hand-rolling buttons. Audio recording is off by default
(audioRecordingEnabled on <Channel>). Check: type, send, and record if
enabled.isMessageAIGenerated on <Chat> (streamed text), and the stop
button the default composer shows while the AI works (replacing the composer
drops it). Check: as in ai-integration.md.Type-check (from an absolute cd into the project, never piped - a pipe
reports the pipe's exit status, so a failing check reads as green), build
native, run the app on a simulator or emulator, and test multi-user flows as
two different users: two simulators, a simulator and an emulator, or - with one
device - the second user acting server-side
(getstream api SendMessage ... "user_id":"<other user>") while you watch the
message arrive live; that user must exist and be a channel member (log in as
them once first). That is a verification step, not app seeding. Drive the
interactions (tap, long-press, type, send); a screenshot doesn't prove a tap
works. Follow simulator-verification.md for the run loop.
Run the check for each default in Defaults to keep. Report what was verified and what wasn't, and list each default that is off with the user's words that turned it off.
When asked, perform a read-only audit: check the code against the docs and the
rules in Building, report findings, and fix only if asked. Video has a
checklist page for this: advanced/integration-best-practices.md in
video/react-native. For a pre-launch review, also follow the stream skill's
integration-review.md.
When asked, read the upgrade notes for both the installed and the target major (use the versioned docs), apply them, then verify as if you're building an app. For Chat, follow the AI migration guide for the exact pair of majors (chat-versions.md), one major per pass.
RN upgrades also carry native work, in this order: prerequisites the guide
names (New Architecture, React Native / Expo floors), new native peers, then
the code. Bump the lane's wrapper and stream-chat together; Stream packages
version independently, so each gets its own target. Confirm every renamed
symbol in the installed stream-chat-react-native-core source, not the
wrapper. Rebuild native (pod install, or npx expo prebuild --clean and a new
development build) before testing.
Feeds v2 to v3 is a data migration as well as a code one: use the stream-feeds-migration skill for the mapping.
When the request carries a target appearance - a screenshot, a Figma frame, "make it look like WhatsApp" - follow design-matching.md.
A named app without a reference ("a Slack-like team chat", "a WhatsApp-style DM app") is guidance on structure - the shape, built from prebuilt components - not a look to match. Only explicit design reference warrants design-matching.md.
For the code migration, follow sendbird-migration.md. When the user also wants their existing data moved (users, channels, message history), follow the stream skill's sendbird-data-migration.md.
The model runs on a backend agent, never in the app: no LLM provider key or
Stream secret in the bundle, app.json, or native config.
getstream agents, an
agents/<name>/ folder, @stream-io/stream-agents): follow stream-agents.md.This skill covers Stream. For general React Native and Expo work, use these skills if they're installed:
Where general advice conflicts with this skill, this skill wins for Stream code:
WithComponents, the
theme, props, hooks), not by rebuilding it as your own components. Defaults
to keep applies.If these skills aren't installed, carry on without them. Don't install skills yourself; mention them once in the final summary:
npx skills add vercel-labs/agent-skills --skill vercel-react-native-skills
npx skills add expo/skillsGetStream/agent-skills
85f8ed2
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.