
Stream Swift
- 371 installs
- 17 repo stars
- Updated August 4, 2026
- getstream/agent-skills
stream-swift is a Claude Code skill that integrates Stream Chat, Video, and Feeds into Swift, SwiftUI, and UIKit iOS apps by routing each request to live official Stream docs and curated setup runbooks.
About
stream-swift is a GetStream-maintained Claude Code skill that orchestrates Stream Chat, Video, and Feeds integration for Swift, SwiftUI, and UIKit iOS and macOS projects. Instead of bundling stale SDK dumps, it converts any getstream.io docs URL to its .md twin, fetches up to 3 live pages per request, and applies verbatim code to the user’s Xcode project. Curated runbooks cover CLI credential setup (setup.md), push and CallKit (push.md), pre-built UI theming via ~100 ViewFactory slots (design-matching.md), custom livestream UI (custom-ui.md), and Sendbird-to-Stream migration (sendbird-migration.md). The docs-map routes Chat’s dual layers—StreamChatSwiftUI components versus low-level StreamChat State Layer—and products spanning messaging, video calls, and activity feeds. Use stream-swift when adding realtime chat or video to an existing Swift app, not for Android, web-only SDKs, or generic SwiftUI tutorials.
- Native Swift Stream Chat integration
- SwiftUI and UIKit UI patterns
- Token auth and user session setup
- Channel, thread, and composer implementations
- Apple platform realtime performance practices
Stream Swift by the numbers
- 371 all-time installs (skills.sh)
- Ranked #343 of 1,039 Mobile Development 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-swiftAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 371 |
|---|---|
| repo stars | ★ 17 |
| Last updated | August 4, 2026 |
| Repository | getstream/agent-skills ↗ |
How do you integrate Stream Chat in SwiftUI?
Add Stream Chat or Feeds to native iOS and macOS Swift apps with SwiftUI/UIKit patterns, token auth, and performant realtime channel UIs.
Who is it for?
iOS developers adding Stream Chat, Video, or Feeds to SwiftUI or UIKit apps who need live official docs applied inside an existing Xcode project.
Skip if: Skip stream-swift for Android/Kotlin Stream SDK work, greenfield app scaffolding without an Xcode project, or SDK answers from training data without fetching docs.
When should I use this skill?
User asks to add Stream Chat, migrate from Sendbird, wire Video/CallKit push, match a messenger UI design, or implement Feeds on iOS.
What you get
Stream packages in Package.swift, initialized ChatClient with user tokens, channel or call screens wired, and cited getstream.io .md doc pages used during implementation.
- Stream SDK integration code
- Token-authenticated client setup
- Cited Stream docs implementation notes
By the numbers
- Fetches at most 3 live .md doc pages per request
- Documents ~100 ViewFactory customization slots for Chat SwiftUI
- Ships 6 curated runbooks: RULES, setup, push, design-matching, custom-ui, sendbird-migration
Files
Stream Swift - docs orchestrator for iOS
This skill is small on purpose. It does not bundle SDK reference dumps - the official iOS docs are the source of truth and they cover the entire surface (Chat, Video, Feeds; SwiftUI and UIKit; polls, drafts, voice, AI, push, screen share, CallKit, SIP, transcriptions, moderation, and more).
Your job is to orchestrate: classify the request, point to the exact docs page, fetch it live, and apply it inside the user's project - while obeying the curated rules and pitfalls that the docs do not shout about.
Rules (read once per session): `RULES.md` - non-negotiable rules + iOS pitfalls. Read before writing any code.
---
The docs convention (the core mechanism)
Every Stream docs page has a Markdown twin that coding agents can read directly: take the page URL, drop the trailing `/`, add `.md`.
https://getstream.io/chat/docs/sdk/ios/basics/integration/ -> https://getstream.io/chat/docs/sdk/ios/basics/integration.mdAlways fetch the .md variant - it is clean Markdown with verbatim code, no page chrome.
Per-product index pages list every doc page with its .md URL. Fetch these to discover or confirm a page:
| Product | Live index (always current) | Page URL shape |
|---|---|---|
| Chat (iOS SDK: UI + State Layer) | https://getstream.io/cli/docs/chat-sdk-ios.md | https://getstream.io/chat/docs/sdk/ios/...md |
| Chat (low-level client API reference) | https://getstream.io/cli/docs/chat-ios-swift.md | https://getstream.io/chat/docs/ios-swift/...md |
| Video | https://getstream.io/cli/docs/video-ios.md | https://getstream.io/video/docs/ios/...md |
| Feeds | https://getstream.io/cli/docs/activity-feeds-ios.md | https://getstream.io/activity-feeds/docs/ios/...md |
URL grounding: only fetch a page URL that you got from `docs-map.md` or from a live index fetch in this conversation. Do not invent doc paths from memory - many look guessable but are wrong. If a page is not in the map, fetch the live index and pick from it.
---
Step 0: Classify the request (always first)
From the user's words alone, resolve three things:
1. Product - Chat, Video, Feeds, or a combination. 2. Framework - SwiftUI, UIKit, or mixed (default to SwiftUI when the user is starting fresh and has not said). 3. Mode - one of:
- How-to / reference ("how do I add reactions?", "what does CallViewModel do?", "theming") -> go straight to Docs lookup. No setup, no credentials.
- Integrate ("add Chat to my app", "wire Video into this project") -> run Setup (`setup.md`) then Docs lookup for the feature.
- New app ("build a livestream app", "new SwiftUI chat app") -> Setup then Docs lookup, scoped to the requested screens.
- Migrate from Sendbird ("migrate my app from Sendbird to Stream", "replace the Sendbird Chat SDK with Stream", "we're switching off Sendbird / SendbirdUIKit / SendbirdSwiftUI") -> run `sendbird-migration.md`: detect the existing Sendbird integration shape, swap packages + init, re-implement each touchpoint against Stream while changing as little app code as possible, then re-apply theming and verify design + functional fidelity per screen against the Sendbird original. It reuses Setup's CLI credential flow (`setup.md`) and the region-by-region rigor of `design-matching.md`. The runbook covers the code/SDK migration; when it's done and verified, it asks whether to also migrate the Sendbird data (users, channels, message history, reactions) and, if so, hands off to the shared language-agnostic data-migration runbook `../stream/sendbird-data-migration.md` (§10).
- Push setup ("add push notifications for chat", "ringing should wake the app on a call", VoIP, CallKit) -> run `push.md`: create the Stream push provider(s) via the CLI and wire the client capabilities + code. Uses the Stream CLI like Setup.
There is also a styling-depth flag, orthogonal to the mode above: if the request carries a target appearance - an attached screenshot, a Figma link, or "make it look like WhatsApp / iMessage / Telegram / Slack / <app>" - then before Docs lookup, reproduce it region by region. For Chat, first run the strategy decision below (components vs custom): if it lands on components, run `design-matching.md`; if it lands on custom (livestream/overlay/bespoke), run `custom-ui.md` instead - same region-by-region rigor, different mechanism. The rest of this paragraph is the components case (the common one). A reference design is a checklist of regions (header, composer buttons, where the timestamp + read receipts sit, bubble shape/tail, date separators, attachments...), and most of them differ from Stream's defaults structurally, not just by color. Do not stop at the wallpaper and bubble color - that is the known failure mode. Decompose every region first (capturing its dimensions, not just colors), then route each to one of three axes - theming token (Appearance), Styles modifier (factory.styles, for padding/insets/corner-radius/chrome), or ViewFactory slot (structure). Routing padding to theming, or structure to a color, is the core failure. Recurring traps the doc guards against: (1) overriding a composite slot (makeMessageItemView, the header, the composer, makeComposerInputTrailingView) silently drops the sub-features the default rendered - the incoming-message avatar, grouping, reactions, replies, status, or the send/voice/edit/slow-mode button - unless you read the default's body and reproduce them; (2) the channel header modifier is applied to a divider above the composer, so a header rendered there via safeAreaInset/VStack lands at the bottom - use .toolbar placements instead, and never fake the header in the app root for one channel; (3) the *composer send/voice button lives inside the field (`makeComposerInputTrailingView`), so moving buttons to the right of the field needs a relayout, not just `makeTrailingComposerView`; (4) bubble/collage padding is `Styles` (`makeMessageAttachmentsViewModifier`), not theming. The match is not done until you build, run, seed data that triggers every region, compare region-by-region against the reference on the real navigation path, and iterate ([`design-matching.md`](design-matching.md) Step 5), reverting any throwaway verification scaffold - the UI must be as close to the reference as possible, not approximately like it. Implement every region, the composer included - never deliver a partial match with the rest labelled "known cosmetic gaps"; a region left at the SDK default is a FAIL, not a footnote. And work in batches to stay fast*: ground the pinned SDK version + checkout once, read all the source you need in one pass, implement all regions, then build once on a persistent -derivedDataPath against one pinned simulator - don't rebuild-and-screenshot after every small edit (see `design-matching.md` "Work in batches").
If product and framework are explicit, do not probe - proceed. If genuinely ambiguous between "wire it in" and "just explain", ask one short question:
Want me to wire this into the project, or just map the docs page and pattern?
Chat only: pick the UI strategy first (before any code)
Stream Chat ships two layers, and choosing between them is an architecture decision that dwarfs any styling choice - getting it wrong wastes a day either way. So decide deliberately, and default to the pre-built components:
| Strategy | Use when the design is... | Mechanism | Runbook |
|---|---|---|---|
Pre-built UI components (StreamChatSwiftUI / StreamChatUI) — the default | A messenger: bubbles, or a channel list → conversation, or per-message avatar/timestamp/receipts/reactions/attachments. Social, marketplace, workplace, support, DMs. "Make it look like WhatsApp / iMessage / Telegram / Slack." | Customize via theming + ViewFactory + Styles | `design-matching.md` |
| Custom UI on the low-level client + State Layer — the exception | Not a messenger: a flat bubble-less author-inline feed, an overlay/ticker on video, high-volume ephemeral livestream chat (Twitch / YouTube / Kalshi), live shopping. Every message rendered identically; bespoke app chrome around it. | Build SwiftUI directly on StreamChat (+ optional StreamChatCommonUI); no StreamChatSwiftUI | `custom-ui.md` |
Lean hard toward components. They're built to be customized, and the litmus test is: if theming + a few `ViewFactory` slots + `Styles` could get there, it's a components job - even strong reskins like WhatsApp. Pick custom only when matching the design would mean replacing the message row, composer, header, AND list all at once - i.e. you'd be using the SDK purely as a data source, not for any of its views. Over-choosing custom (rebuilding a worse messenger by hand, losing avatars/grouping/reactions/threads/attachments/typing/receipts/pickers) is the common, expensive mistake; over-choosing components costs a few hours of fighting layout. When unsure, build the components version first - it's faster to confirm-or-reject. The full signal rubric + litmus test is in `custom-ui.md` Step 0.
Workplace / Slack-style hybrid is a components job too - and usually a Liquid Glass one. A Slack/Teams/Discord surface is a channel list + message list + composer, so it stays on the pre-built components - but two things differ from a messenger and must be matched, not punted: (1) the message row is flat and left-aligned (avatar-top rounded-square, an author·custom-status·timestamp header line, body, bottom reaction pills, and a thread-reply summary), with no incoming/outgoing bubble split - reproduce it by overriding makeMessageItemView (composite slot - reproduce its sub-features); (2) the header and chrome are custom and typically glassy - recommend the SDK's ready-made Liquid Glass look (factory.styles = LiquidGlassStyles(), the Styles-axis baseline for translucent/floating-composer designs; renders on iOS 26+/Swift 6.2+). Workplace apps are also thread-first, so wire the thread-reply slots and thread list. Full archetype + the Liquid Glass mechanism are in `design-matching.md`.
The strategy also picks the channel type and permission model (e.g. messaging membership-gated for social/marketplace vs livestream public + anonymous viewers) - see `RULES.md` "Permissions". Decide both axes together.
If it's genuinely unclear, ask one question:
Does this chat look like a standard messenger (channel list + bubbles), or a bespoke surface like livestream/overlay chat? It decides whether we customize the pre-built components or build custom UI on the low-level client.
---
Step 1: Docs lookup (every request ends here)
1. Open `docs-map.md`. Find the row matching product + framework + feature -> it gives the exact .md URL(s). 2. If the feature is not in the map, fetch the live index for the product (table above) and pick the best-matching page. 3. Fetch the `.md` page(s) with WebFetch. Fetch at most 3 pages per request; if more are needed, hand the user the index URL. 4. Apply the page's guidance to the user's project: use its code verbatim where possible, adapt only to fit the existing app shape (lifecycle, navigation, package manager). 5. Cite the page you used: Source: [Title](https://getstream.io/...). Never answer SDK specifics from training data - if you did not fetch it this conversation, fetch it now or say you could not find it. 6. If the docs do not fully cover it - a specific UI customization, an exact ViewFactory signature, an undocumented option, real wiring - escalate to the SDK source code + example apps (see "When the docs fall short" in `docs-map.md`). The source is the final source of truth; read the version the project actually pins, and say where you found it rather than presenting it as documented. For matching a reference design, this escalation is the norm, not the exception: most of the ~100 ViewFactory slots are undocumented, so read ViewFactory.swift (every slot) + DefaultViewFactory.swift (what each renders) + the relevant Options/*.swift - `design-matching.md` routes each region to its slot and shows the grep commands. When you override a composite slot, also read the default view it returns (e.g. MessageContainerView for makeMessageItemView) and enumerate every sub-feature it renders, so you reproduce them instead of silently dropping them. 7. Apply best practices. Use the API mindfully - no queryChannels spam, no rendering loops, authenticate once - per `RULES.md` "Mindful API usage", and read the vertical's best-practices page (e.g. livestream) before scaling.
---
Setup (integrate / new app only)
When the mode is integrate or new app, run `setup.md` once per session before feature work. It covers, in order:
1. Project signals - detect Xcode project / Swift package / Podfile / empty dir. 2. Credentials - API key + user token via the Stream CLI (or user-pasted), optional seed channels. 3. Install - add the right Stream packages with the project's existing package manager. 4. Wire the client - initialize once at app launch, connect the user.
Then return here for Docs lookup on the specific screens.
If there is no iOS project at all, do not scaffold one from the CLI - tell the user to create the app in Xcode first, then continue.
---
What this skill no longer carries
There are no references/*.md blueprint files. Anything that used to live there now comes from the live docs via the map. This keeps the skill current automatically and covers far more than the old bundled set. The only curated, non-doc content is `RULES.md` (rules + pitfalls), `setup.md` (the CLI-driven setup flow), `push.md` (the CLI + code runbook for push / VoIP / CallKit, which automates what the docs only describe), `design-matching.md` (the procedure + region->ViewFactory-slot map for reproducing a reference design with the pre-built components), `custom-ui.md` (the procedure for the custom low-level path - the components-vs-custom decision, and how to build a livestream/bespoke surface on the StreamChat client + State Layer), and `sendbird-migration.md` (the repeatable Sendbird -> Stream Chat migration procedure - detect the integration shape, swap packages/init/views/channels/messages, and match the source app's design per screen). The middle two cover both Chat UI strategies; the decision in `SKILL.md` routes between them.
Stream Swift - building a CUSTOM chat UI on the low-level client (livestream / bespoke surfaces)
Some chat UIs are not a messenger. Livestream chat (Twitch / YouTube / Kalshi), live-shopping ticker chat, an overlay on a video, a betting feed, a high-volume "drop" room - these look nothing like channel-list + bubbles + composer, and the pre-built StreamChatSwiftUI components fight you the whole way. For these you drop the components and build your own SwiftUI on the low-level `StreamChat` client + its State Layer.
This page is the procedure for that path - the mirror image of `design-matching.md` (which is for customizing the pre-built components). Run it when the decision below lands on "custom". It is grounded against StreamChat 5.5.x; confirm every symbol against the project's pinned version (see "Grounding" at the end and `docs-map.md` "When the docs fall short").
---
Step 0: Decide components vs. custom - and LEAN HARD toward components
This decision is the whole ballgame. Get it wrong toward custom and you throw away avatars, grouping, reactions, threads, attachments, typing, read state, slow-mode, the composer with its attachment pickers and voice notes - hundreds of built, tested views - to rebuild a worse messenger by hand. Get it wrong toward components and you spend a day fighting the framework to force a shape it was never meant to take. The first mistake is far more common and far more expensive, so the default is components. Custom is the exception you justify, not the reflex.
Default to the pre-built components ([`design-matching.md`](design-matching.md)). Choose custom only when the design is genuinely not a messenger. Decide from concrete signals, not vibes:
| Signal in the screenshot / requirements | Points to |
|---|---|
| Message bubbles (incoming left / outgoing right), or could be styled into them | Components |
| A channel list → tap → conversation, or any 1:1 / small-group DM | Components |
| Per-message avatar + name + timestamp + read receipts, replies, reactions, attachments | Components |
| The ask is "make our chat look like WhatsApp / iMessage / Telegram / Slack / Messenger / Discord DMs" | Components (it's a tweak - theming + ViewFactory + Styles) |
| Workplace / support / marketplace / social / dating chat | Components |
| A flat, author-inline, bubble-less feed (name in bold then text on one wrapping line), à la live chat | Custom |
| No outgoing/incoming distinction - every message rendered identically, regardless of sender | Custom |
| Chat is an overlay / ticker / sidebar on top of video or a non-chat surface, or shares the screen with bespoke app chrome (odds, product cards, reactions raining up) | Custom |
| Very high volume + ephemeral (hundreds of msgs/min, public viewers, nothing persisted) | Custom |
| Anonymous / guest read-only viewers vastly outnumber posters | Custom (and livestream channel type) |
The litmus test: if you could get there by theming + a few `ViewFactory` slots + `Styles`, it is a components job. Only when matching the design through the components would mean overriding the message row, the composer, the header, AND the list all at once into shapes they resist - i.e. you're using the SDK only as a data source, not for any of its views - does custom win. When the count of components you'd have to fully replace approaches "all of them", that is the signal to drop to the client instead. When genuinely unsure, build the components version first - it's faster to confirm-or-reject than to discover mid-custom-build that a tweak would have done.
If still ambiguous, ask one question (from `SKILL.md`):
Does this chat look like a standard messenger (bubbles / channel list), or a bespoke surface like livestream/overlay chat? It decides whether we customize the pre-built components or build custom UI on the low-level client.
State the decision and the signals that drove it before writing code. If the answer is components, stop here and go to `design-matching.md`. The rest of this page is the custom path.
---
Step 1: Link the right products (custom ≠ StreamChatSwiftUI)
The custom path uses `StreamChat` (the client + State Layer) and, optionally, `StreamChatCommonUI` (design tokens, Appearance, image loading - see Step 6). It does not use StreamChatSwiftUI. Add StreamChat to the target (and StreamChatCommonUI only if you'll use its helpers); do not add StreamChatSwiftUI, and do not import StreamChatSwiftUI anywhere - no ViewFactory, no ChatChannelListView, none of it applies here. (A ViewFactory/ColorPalette/Styles mention is the tell that you've drifted back onto the components path by mistake.)
An ObservableObject service that holds @Published state needs import Combine once Stream modules are imported (the re-export isn't always enough) - else "init(wrappedValue:) is not available due to missing import of defining module 'Combine'".
---
Step 2: Pick the State Layer object - LivestreamChat for high volume, else Chat
The modern async State Layer (under Sources/StreamChat/StateLayer/) gives you observable state objects you render yourself. Two channel objects, created from the connected ChatClient:
- `LivestreamChat` -
chatClient.makeLivestreamChat(for: cid). In-memory, no local DB, tuned for high-volume livestream channels; documented as more performant thanChatbut with fewer features (no read updates, no threads). This is the right default for a livestream/ticker feed. It adds livestream-only controls:pause()/resume()(freeze the feed while the user scrolls up - new messages don't reflow the list),state.skippedMessagesAmount(a "N new messages" pill while paused),state.isPaused,enableSlowMode(cooldownDuration:)/disableSlowMode(). - `Chat` -
chatClient.makeChat(for: cid). Full-feature single-channel state (messages, reads, threads, typing, members, watchers) for a bespoke surface that still needs those features (e.g. a custom-styled support console). Heavier thanLivestreamChat.
Both expose @MainActor state (LivestreamChatState / ChatState), an ObservableObject with @Published var messages: [ChatMessage], @Published var channel: ChatChannel?, members/watchers, etc. Lifecycle on both:
let livestream = chatClient.makeLivestreamChat(for: ChannelId(type: .livestream, id: "game5"))
try await livestream.get() // fetch the most recent page (resets state.messages/channel)
try await livestream.watch() // open the WebSocket so new messages stream into state.$messages
// send:
try await livestream.sendMessage(with: "let's go")
// paginate older:
try await livestream.loadOlderMessages()(Chat collapses these into try await chat.get(watch: true). LivestreamChat splits get() and watch() - call both.)
⚠️ These names are verified in 5.5.x source.LivestreamChatis recent - on an older pinned version it may not exist; then useChat, or the delegate-basedChatChannelController(controller.messages+ChatChannelControllerDelegate.didUpdateMessages). Confirm in the pinned source before coding (Grounding, below).
---
Step 3: Connect the client - tune it for the vertical, and pick the viewer auth
Initialize the client once in an owned service (@MainActor ObservableObject), never in a view body (see `RULES.md` "Client lifetime"). For livestream, tune the config and the connect call:
var config = ChatClientConfig(apiKey: .init(apiKey))
config.isLocalStorageEnabled = false // no offline DB: per-message disk writes bottleneck a high-volume ephemeral feed
let chatClient = ChatClient(config: config)Viewer auth - match it to whether the viewer posts (see [`RULES.md`](RULES.md) "Permissions"):
- Read-only viewers (the majority on a livestream):
try await chatClient.connectAnonymousUser()- no MAU cost, can readlivestreamchannels, cannot write. Do not mint a full per-user JWT for every anonymous viewer. - Pre-account but can post a little:
try await chatClient.connectGuestUser(userInfo:). - A real, signed-in user who posts:
try await chatClient.connectUser(userInfo:token:)with a backend/CLI token.
Channel type: use `livestream` (public read/write without a membership gate; supports guest + anonymous). Don't make a messaging channel world-readable to fake it. Per `RULES.md` "Case-specific tuning", also disable read events, typing indicators, connect events, file uploads and custom messages, and enable slow mode under load - the API auto-throttles typing/read past ~100 watchers and messages past ~5/sec, so design for it. Read the livestream best-practices page (`docs-map.md`) before scaling.
---
Step 4: Render the feed - observe state, render newest-at-bottom
Observe the state object directly with @ObservedObject - don't re-@Published its messages into another object (that just copies a hot array on every update). The view owns the channel object via its parent (the service); the view observes its state:
struct LiveChannelView: View {
let livestream: LivestreamChat
@ObservedObject private var state: LivestreamChatState
init(livestream: LivestreamChat) {
self.livestream = livestream
_state = ObservedObject(wrappedValue: livestream.state)
}
// render state.messages …
}Message row - this is where livestream diverges most from a messenger. No bubbles, no left/right split, no read receipts. The common shape is avatar + bold author name inline with the message text, wrapping as one paragraph. Concatenate Text so it flows and wraps as a single block:
(Text(authorName + ":").font(.system(size: F, weight: .bold))
+ Text(" " + message.text).font(.system(size: F)))
.foregroundStyle(textColor) // MEASURED — see below; usually NOT pure black
.fixedSize(horizontal: false, vertical: true)Pull fields off ChatMessage: .text, .author (ChatUser → .name, .imageURL, .id), .createdAt, .id, .type (skip/branch on .system/.deleted if your design shows them). Resolve the author label as author.name ?? author.id (names can be nil).
A custom feed is still a design match - apply [`design-matching.md`](design-matching.md)'s rigor to it, do NOT eyeball. "Render a name + text" is not the spec; the reference's exact font size, weight, color, avatar size, and row spacing are. Building a custom row does not exempt you from measuring - it's the opposite, because here there are no SDK defaults to fall back on, so every number is yours to get right. The recurring miss on this path is shipping.system(size: 17)+.foregroundStyle(.primary)by reflex. Instead:
- Font size `F`: MEASURE it off the reference (the cap/line-height method in `design-matching.md` "How to actually get the dimensions right" - sips for scale, then measure ink height in points). Live-chat text is typically ~14-15pt, not the 17 you'll guess.- Weight: measure the username and the body SEPARATELY - they are usually different weights, and the body is lighter than your reflex. The single biggest "the font is off" cause on this path is painting the whole row in one weight (or defaulting the body to.regularwithout checking). The author name is the emphasis (often.bold/.semibold); the message body is lighter - frequently `.light`, not `.regular` (a measured reference: name stroke ≈2.0pt vs body stroke ≈1.0pt at 15pt - the body is half the name's stroke, i.e. two weight steps down). Measure each per `design-matching.md` "Weight is its own dimension" (horizontal dark-run width = stroke thickness), then map the stroke-to-font-size ratio to a SwiftUIFont.Weightand set them independently:
>
| stroke ÷ font size | SF Font.Weight ||---|---|
| ≈0.045-0.06 | .light | | ≈0.07-0.08 | .regular | | ≈0.09-0.10 | .medium | | ≈0.11-0.12 | .semibold | | ≈0.13+ | .bold |>
(e.g. body 1.0pt ÷ 15pt ≈ 0.067 → between light and regular; if it reads lighter than your.regularrender, use.light. Name 2.0 ÷ 15 ≈ 0.13 →.bold.) Re-measure your own render's stroke and iterate -.regularbody often renders heavier than the reference, so stepping to.lightis common. Don't stop at "name bold, body regular" by reflex.
- Text color: SAMPLE it (`design-matching.md` "Follow EVERY color from the reference"). It is very often a soft near-black (measured cores ~#191919/#1C1C1C), NOT pure black /.primary/.label(which paint#000). Sample the darkest stroke cores and use that exact value;.primaryis a guess and usually wrong. Check whether the username and message share one color or differ.
- Avatar size: MEASURE the diameter. Livestream avatars are small (≈20-26pt - about one text line tall), far smaller than the ~32-40pt you'd reach for. Oversized avatars are the most obvious custom-feed tell. Match the measured diameter and the small leading inset (≈4-12pt).
- Avatar vertical alignment + row spacing are part of the spec. Center the avatar on the first text line (the name line), not the whole multi-line block. Anti-pattern (a real bug to avoid): do NOT nudge the text down with `.padding(.top, n)` to "align" it - that de-centers the avatar (it shoves the name below the avatar's center and only works for single-line messages). Instead top-align the row and constrain the avatar to the first line's height so it centers within it, e.g. HStack(alignment: .top) { avatar.frame(height: UIFont.systemFont(ofSize: F).lineHeight); nameAndBodyText } - the avatar centers on the first-line box while the body still wraps beneath the name. Then verify the centering by measurement (avatar center-Y vs the name glyphs' center-Y ≈ 0), per `design-matching.md`'s centering rule - don't eyeball it.>
Then verify with the same-scale side-by-side crop (`design-matching.md` Step 7): stack your rendered feed against the reference at native @3x and compare size/weight/color/avatar - numbers alone won't catch "it still looks bigger/heavier."
Ordering + scroll. state.messages follows the object's ordering; for a "newest at the bottom, above the composer" feed render oldest→newest and pin to the bottom. For modest volume, sort by createdAt ascending for display; for true high volume, consume the SDK's native order rather than re-sorting each update. Use ScrollViewReader + .defaultScrollAnchor(.bottom) and scroll to the last id .onChange(of: messages.count). Page older history in when the user reaches the top (loadOlderMessages()), guarded by state.isLoadingOlderMessages / state.hasLoadedAllOldestMessages.
High-volume niceties (LivestreamChat): when the user scrolls up, call livestream.pause() so incoming messages stop reflowing the list; show a "▾ N new messages" pill from state.skippedMessagesAmount; on tap, try await livestream.resume() and scroll to bottom.
---
Step 5: Compose + send
There is no SDK composer here - build a plain field + button and post through the state object. Keep the field state local (@State var text), clear it optimistically, send in a Task:
private func send() {
let body = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !body.isEmpty else { return }
text = ""
Task { try? await livestream.sendMessage(with: body) }
}If the design shows it, surface slow-mode cooldown from state.remainingCooldownDuration (disable the button + show the countdown). Anonymous viewers can't post - hide or disable the composer for them.
---
Step 6: StreamChatCommonUI - use it when it earns its keep, not by reflex
StreamChatCommonUI (importable standalone alongside StreamChat, without StreamChatSwiftUI) carries the shared, non-component primitives:
- `Appearance` with
colorPalette,fonts/fontsSwiftUI,images(icon glyphs), and design tokens (DesignSystemTokens:spacingXs/spacingMd,radiusLg/radius3xl, icon sizes). Use these when you want your custom surface to align with other Stream surfaces in the same app (e.g. a components-based channel list elsewhere) - same spacing/radius/colors. - `MediaLoader` protocol (+ a
StreamMediaLoader) for cached image loading. There is no prebuilt async-image SwiftUI view and no prebuilt avatar view here - just the loader protocol.
Decision: reach for it only when it saves real work. For a self-contained custom surface (one livestream screen with no other Stream UI), plain SwiftUI is usually cleaner and more robust: AsyncImage for avatars (with a colored-initial Circle fallback so the feed looks right while images load or offline), system fonts, your own colors. Pull in StreamChatCommonUI when you specifically want (a) Stream's design tokens so the surface matches a sibling components screen, (b) its image cache/pipeline, or (c) its glyphs. Don't add a dependency on its Appearance/MediaLoader for a screen that a dozen lines of stock SwiftUI render fine. (The user's steer: "use StreamChatCommonUI if you think it's necessary" - necessity, not default.)
Verify the exact StreamChatCommonUI symbols in the pinned source before importing - the token/loader API is recent and moves between versions. If you only need avatars + text, you likely need none of it.---
Step 7: Pitfalls specific to the custom path
- This is a single immersive screen, not a pushed channel. There's no channel-list→push and usually no
NavigationStack- build a custom header view (back button, title, live badge, actions) as an ordinary view at the top of yourVStack. (Contrast the components path, where the header ismakeChannelHeaderViewModifieron a real nav bar - none of that applies here.) Don't reach for.toolbar/nav-bar machinery for a full-bleed overlay surface. - A centered title MUST be bounded so it can't overlap the trailing controls. The trap: a
ZStackof a centered title plus a leading/trailingHStack, with the title given only symmetric.padding(.horizontal, n). A long title (e.g. "Game 5: New York at San Antonio") then extends under the share/action icons - because symmetric padding doesn't reserve the icons' actual width. Fix with a 3-zone layout that reserves equal side widths = the wider side cluster, so the title centers on screen and physically cannot reach either side:
let side: CGFloat = 76 // ≥ the wider of {back button} and {icon cluster / sponsor}
HStack(alignment: .top, spacing: 8) {
backButton.frame(width: side, alignment: .leading)
VStack(spacing: 4) { title.lineLimit(1).minimumScaleFactor(0.7); liveBadge }
.frame(maxWidth: .infinity) // centered, bounded by the two side frames
trailingCluster.frame(width: side, alignment: .trailing)
}Measure the title font from the reference too (it's usually a modest ~16-17pt); minimumScaleFactor only covers the rare over-long title, it is not a substitute for the reserved side widths.
- The app's own chrome is not Stream. A betting bar, product cards, a video player, reactions - those are your app's views sitting above/below the chat. Build them as normal SwiftUI; only the message feed + composer talk to the SDK. Keep the SDK out of those files.
- Don't hardcode the channel title from the screenshot. Resolve it from the model:
channel.name ?? extraData["name"]then a sensible fallback. (Many Stream apps - including this skill's test app - store the channel name undercustom/extraData["name"]because top-levelchannel.nameis disabled; reading onlychannel.nameyields a blank title. See `design-matching.md` "blank-name trap".) - Observe on the main actor; don't create state objects in a view body. The state objects are
@MainActor; create theLivestreamChat/Chatonce in the service after connect, not per redraw (a body-created channel object re-fetches every frame - `RULES.md` "Client lifetime" / "No rendering loops"). - `get()` resets the feed; `watch()` is what makes it live. Calling only
get()shows a static snapshot that never updates. Call both (orchat.get(watch: true)onChat). - Re-publishing `state.messages` into your own `@Published` defeats the optimization. Observe
statedirectly. For a hot feed, an extra array copy per message is exactly the overheadLivestreamChatexists to avoid. - Mind the message `type`.
livestreamfeeds still containsystem/deletedmessages; render or filter them deliberately rather than drawing them as normal lines.
---
Step 8: Verify against the reference (mandatory, same rigor as the components path)
A custom UI is not done until it builds, runs against real seeded data, and matches the reference region by region (see `design-matching.md` Step 5 for the discipline - it applies here too): 1. Seed a `livestream` channel with realistic, varied content (multiple authors, short and long/wrapping messages, emoji) via the CLI - so the feed exercises avatar fallback, name+text wrapping, ordering, and scroll-to-bottom. (CLI shape: UpdateUsers to create authors, GetOrCreateChannel type=livestream …, SendMessage … message='{"text":"…","user_id":"…"}' per line, server-side.) 2. Build once on a pinned, already-booted simulator reusing the project's DerivedData (so the Stream packages don't re-resolve); install, launch, wait for connect + the initial fetch to finish before screenshotting (a too-early shot catches the "connecting" state). 3. Measure the reference (sips; iOS shots are @2x/@3x → divide to points) for the repeating elements - avatar diameter, row spacing, composer height, any app-chrome bar - and match them, don't eyeball (see `design-matching.md` "How to actually get the dimensions right"). 4. Compare every region - header (back/title/badge/actions/sponsor), the feed row layout, the composer, and the app chrome - PASS/FAIL, and iterate until each passes. Implement every region, the composer and app chrome included - no "known gaps" (`RULES.md` design-match rule applies to custom UIs too). 5. Delete any throwaway verification scaffolding before delivery.
---
Grounding (do not guess State Layer signatures)
Per `RULES.md` "Docs discipline": confirm every symbol above against the project's pinned version - the State Layer API (especially LivestreamChat, the *State @Published surface, and StreamChatCommonUI's tokens/loader) is recent and differs across versions. Read the live docs first (`docs-map.md` "Chat - Custom UI": client/state-layer/state-layer-overview.md, client/livestream-chat.md, and the ios-swift/livestream-best-practices.md), then escalate to the pinned source for exact signatures:
# pinned version, then the matching checkout (verify the tag - a sibling project's DerivedData may hold a different version):
CO="<DerivedData>/SourcePackages/checkouts/stream-chat-swift/Sources/StreamChat"
grep -rn "func makeLivestreamChat\|func makeChat\|func connectAnonymousUser\|func connectGuestUser" "$CO/StateLayer" "$CO/ChatClient.swift"
sed -n '1,120p' "$CO/StateLayer/LivestreamChatState.swift" # the @Published surface you'll bind toSay where you found anything source-derived rather than presenting it as documented.
Stream Swift - docs routing map
Map a request to the exact iOS docs page, then fetch its .md. This table is the curated 80% - the high-frequency intents. For anything not here, fetch the live product index and pick from it:
| Product | Live index (lists every page) |
|---|---|
| Chat - iOS SDK (UI components + State Layer) | https://getstream.io/cli/docs/chat-sdk-ios.md |
| Chat - low-level client API reference | https://getstream.io/cli/docs/chat-ios-swift.md |
| Video | https://getstream.io/cli/docs/video-ios.md |
| Feeds | https://getstream.io/cli/docs/activity-feeds-ios.md |
Chat has two doc trees: the iOS SDK (.../sdk/ios/... - pre-built UI plus the client/ State Layer) and the low-level API reference (.../ios-swift/... - the full client capability surface). See the Chat section below for which to use.
All page URLs end in .md (the Markdown twin - see `SKILL.md`). Fetch at most 3 pages per request. Cite what you used. Do not guess paths - if it is not below, use the index.
---
Chat
First decide the layer (see "pick the UI strategy" in `SKILL.md`):
- Standard-messenger vertical (social, marketplace, workplace, support, DMs) -> Pre-built UI (sections below); reference design -> `design-matching.md`.
- Livestream / live-shopping / overlay chat -> Custom UI on the low-level client + State Layer (its own section below) - do not use the pre-built components; run `custom-ui.md`.
Matching a reference design (screenshot / Figma / "make it look like <app>")? Do not pick rows à la carte and stop at theming. First run `design-matching.md`: it decomposes the design into every region (header, composer button set, timestamp + read-receipt placement, bubble shape/tail, date separators, attachments, ...) and maps each to its ViewFactory slot or theming token. The rows below are where it sends you per region; the full ~100-slot surface lives in ViewFactory.swift (read it - most slots are undocumented).
Install is shared by both layers: https://getstream.io/chat/docs/sdk/ios/basics/integration.md
Chat - Pre-built UI (SwiftUI / UIKit)
Start here:
- Getting started, SwiftUI:
https://getstream.io/chat/docs/sdk/ios/swiftui/getting-started.md - Getting started, UIKit:
https://getstream.io/chat/docs/sdk/ios/uikit/getting-started.md - Overview:
https://getstream.io/chat/docs/sdk/ios.md
Chat - SwiftUI
| Want to ... | Page (.md) |
|---|---|
| Channel screen overview | .../swiftui/chat-channel-components/overview.md |
| Message list | .../swiftui/chat-channel-components/message-list.md |
| Channel header | .../swiftui/chat-channel-components/channel-header.md |
| Message composer | .../swiftui/chat-channel-components/message-composer-overview.md |
| Composer commands / slash | .../swiftui/chat-channel-components/composer-commands.md |
| Custom attachments in composer | .../swiftui/chat-channel-components/message-composer.md |
| Channel list item | .../swiftui/channel-list-components/channel-list-item.md |
| Channel list header | .../swiftui/channel-list-components/channel-list-header.md |
| Channel list search | .../swiftui/channel-list-components/channel-list-search.md |
| Channel list helper views | .../swiftui/channel-list-components/helper-views.md |
| Channel list tap handling | .../swiftui/channel-list-components/list-tap-events.md |
| Channel filters and sorting | .../swiftui/channel-list-components/query-filters.md |
| Swipe actions | .../swiftui/channel-list-components/swipe-actions-channels.md |
| Reactions | .../swiftui/message-components/message-reactions.md |
| Threads | .../swiftui/message-components/message-threads.md |
| Inline replies | .../swiftui/message-components/inline-replies.md |
| Attachments | .../swiftui/message-components/attachments.md |
| Typing indicators | .../swiftui/message-components/typing-indicators.md |
| Read indicators | .../swiftui/message-components/read-indicators.md |
| Message display options | .../swiftui/message-components/message-display-options.md |
| Custom avatar | .../swiftui/message-components/custom-avatar.md |
| Thread list | .../swiftui/thread-list.md |
| Theming (colors, fonts, images) | .../swiftui/theming.md |
| ViewFactory / customizing components | .../swiftui/view-customizations.md |
| Polls | .../swiftui/polls.md |
| Draft messages | .../swiftui/drafts.md |
| Voice recording | .../swiftui/voice-recording.md |
| Localization | .../swiftui/localization.md |
| Cookbook: create-channel flow | .../swiftui/swiftui-cookbook/creating-channels.md |
| Cookbook: custom channel list | .../swiftui/swiftui-cookbook/custom-channel-list.md |
| Cookbook: custom message list | .../swiftui/swiftui-cookbook/custom-message-list.md |
| Cookbook: custom composer | .../swiftui/swiftui-cookbook/custom-composer.md |
| Cookbook: blocking users | .../swiftui/swiftui-cookbook/blocking-users.md |
(Prefix every row with https://getstream.io/chat/docs/sdk/ios.)
Chat - UIKit
| Want to ... | Page (.md) |
|---|---|
| Customizing components | .../uikit/custom-components.md |
| Theming | .../uikit/theming.md |
| Channel list | .../uikit/components/channel-list.md |
| Channel | .../uikit/components/channel.md |
| Message list | .../uikit/components/message-list.md |
| Message view | .../uikit/components/message.md |
| Message composer | .../uikit/components/message-composer.md |
| Thread / thread list | .../uikit/components/thread.md / .../uikit/components/thread-list.md |
| Reactions | .../uikit/views/reactions.md |
| Avatar | .../uikit/views/avatar.md |
| Message actions | .../uikit/guides/customize-message-actions.md |
| Attachments / custom attachments | .../uikit/guides/working-with-attachments.md / .../uikit/guides/custom-attachments.md |
| Polls / drafts | .../uikit/polls.md / .../uikit/drafts.md |
| Navigation | .../uikit/navigation.md |
| Localization | .../uikit/localization.md |
(Prefix every row with https://getstream.io/chat/docs/sdk/ios.)
Chat - Custom UI (low-level client + State Layer)
Use this layer for livestream / live-shopping / overlay chat and any bespoke surface. There are no pre-built views here - you drive the SDK's state objects and render your own SwiftUI/UIKit. Run [`custom-ui.md`](custom-ui.md) first - it carries the components-vs-custom decision and the build procedure (which State Layer object - LivestreamChat for high volume vs Chat - viewer auth, feed rendering, composer, and StreamChatCommonUI usage). The pages below are what it routes to. Two sources:
(a) State Layer + Controllers - how to observe state and react in your own views. Prefix https://getstream.io/chat/docs/sdk/ios:
| Want to ... | Page (.md) |
|---|---|
| State overview (start here) | .../client.md |
State Layer (modern async/await: Chat, ChannelList, MessageSearch, ...) | .../client/state-layer/state-layer-overview.md |
Livestream chat object (LivestreamChat - in-memory, high-volume) | .../client/livestream-chat.md |
| Controllers (delegate-based alternative) | .../client/controllers/controllers-overview.md |
| Channels state and filtering | .../client/controllers/channels.md |
| Listening to events | .../client/controllers/events.md |
| Offline support | .../client/offline-support.md |
| Extra data | .../client/extra-data.md |
| Livestream chat (high-volume channel patterns) | .../client/livestream-chat.md |
(b) Low-level API reference - the full client capability surface (the ios-swift tree). Prefix https://getstream.io/chat/docs/ios-swift:
| Want to ... | Page (.md) |
|---|---|
| Introduction / init and users | .md (root) / /init-and-users.md |
| Tokens and authentication | /tokens-and-authentication.md |
| Query / create channels, pagination | /query-channels.md, /creating-channels.md, /channel-pagination.md |
| Channel types and features | /channel-features.md |
| Send messages | /send-message.md |
| Threads and replies | /threads.md |
| Reactions | /send-reaction.md |
| Typing indicators | /typing-indicators.md |
| Read and delivery status / unread counts | /message-delivery-and-read-status.md, /unread.md |
| Events | /event-object.md |
| Message search / query members | /search.md, /query-members.md |
| Permissions and policies | /chat-permission-policies.md |
| Polls / slow mode | /polls-api.md, /slow-mode.md |
| Livestream and live-shopping best practices | /livestream-best-practices.md |
| Marketplace app best practices | /marketplace-best-practices.md |
| Best practices overview | /best-practices.md |
For anything else in this tree, fetch the index: https://getstream.io/cli/docs/chat-ios-swift.md.
Chat - cross-cutting (any layer)
Prefix https://getstream.io/chat/docs/sdk/ios:
| Want to ... | Page (.md) |
|---|---|
| Push notifications | .../client/push-notifications.md |
| Moderation | .../guides/moderation.md |
| Logging | .../basics/logs.md |
| Go-live checklist | .../guides/go-live-checklist.md |
| Add video calls to chat | .../guides/video-integration.md |
| Location sharing | .../guides/location-sharing.md |
| AI integrations | .../ai-integrations/overview.md |
| Migrate 4.x -> 5.x | .../guides/migrating-from-4-to-5.md |
Chat - best practices and limits
Read before building or scaling a vertical (see `RULES.md` "Mindful API usage"). Prefix https://getstream.io/chat/docs/ios-swift:
| Topic | Page (.md) |
|---|---|
| Best practices overview | /best-practices.md |
| Livestream and live-shopping (disable read/typing/connect events, etc.) | /livestream-best-practices.md |
| Marketplace apps | /marketplace-best-practices.md |
| Query channels budget | /api-budget.md |
| Rate limits / fair usage | /rate-limits.md, /fair-usage-limits.md |
| Slow mode and throttling | /slow-mode.md |
Chat - permissions and roles
Pick the channel type per vertical, then tune policies (see `RULES.md` "Permissions"). Prefix https://getstream.io/chat/docs/ios-swift:
| Want to ... | Page (.md) |
|---|---|
| Channel types and their defaults (messaging / livestream / team / gaming / ai) | /channel-features.md |
| Permission policies, roles, scopes (how to grant/revoke) | /chat-permission-policies.md |
| Full permission/action reference | /permissions-reference.md |
| Guest + anonymous users (livestream viewers, pre-auth) | /authless-users.md |
| Multi-tenant / teams isolation | /multi-tenant-chat.md |
---
Push notifications (Chat push + Video VoIP / CallKit)
For setup, follow the runbook `push.md` (it automates the Stream CLI provider creation + client wiring). These are the pages it routes to:
| Want to ... | Page (.md) |
|---|---|
| Chat push: permission, register device, NSE | https://getstream.io/chat/docs/sdk/ios/client/push-notifications.md |
| Chat push providers / multi-bundle (config) | https://getstream.io/chat/docs/ios-swift/push-providers-and-multi-bundle.md |
| Chat push overview / register devices | https://getstream.io/chat/docs/ios-swift/push-introduction.md, https://getstream.io/chat/docs/ios-swift/push-devices.md |
| Chat push templates / testing | https://getstream.io/chat/docs/ios-swift/push-template.md, https://getstream.io/chat/docs/ios-swift/push-test.md |
| Video VoIP push for incoming calls | https://getstream.io/video/docs/ios/advanced/incoming-calls/push-notifications.md |
| Video CallKit integration (ringing) | https://getstream.io/video/docs/ios/advanced/incoming-calls/callkit-integration.md |
| Video ringing overview | https://getstream.io/video/docs/ios/advanced/incoming-calls/ringing.md, https://getstream.io/video/docs/ios/advanced/incoming-calls/overview.md |
---
Video
Start here:
- Install:
https://getstream.io/video/docs/ios/basics/installation.md - Quickstart:
https://getstream.io/video/docs/ios/basics/quickstart.md - Client and authentication:
https://getstream.io/video/docs/ios/guides/client-auth.md - Introduction:
https://getstream.io/video/docs/ios.md
Video - core
| Want to ... | Page (.md) |
|---|---|
| Join / create a call | .../guides/joining-creating-calls.md |
| Call and participant state | .../guides/call-and-participant-state.md |
| Calling state machine | .../guides/call-state.md |
| Camera and microphone | .../guides/camera-and-microphone.md |
| Query calls / members | .../guides/querying-calls.md / .../guides/querying-call-members.md |
| Configure call types | .../guides/configuring-call-types.md |
| Reactions / custom events / events | .../guides/reactions.md / .../guides/custom-events.md / .../guides/events.md |
| Permissions and moderation | .../guides/permissions-and-moderation.md |
| Noise cancellation | .../guides/noise-cancellation.md |
Video - UI components
| Want to ... | Page (.md) |
|---|---|
| UI overview | .../ui-components/overview.md |
| SwiftUI vs UIKit | .../ui-components/swiftui-vs-uikit.md |
| Customizing views (ViewFactory) | .../ui-components/customizing-views.md |
| UIKit customizations | .../ui-components/uikit-customizations.md |
| CallViewModel | .../ui-components/view-model.md |
| Theme | .../ui-components/video-theme.md |
| CallContainer / CallControls | .../ui-components/call/call-container.md / .../ui-components/call/call-controls.md |
| Active / incoming / outgoing call view | .../ui-components/call/active-call.md / .../ui-components/call/incoming-call.md / .../ui-components/call/outgoing-call.md |
| Participants / local video | .../ui-components/participants/call-participants.md / .../ui-components/participants/local-video.md |
| UI cookbook: lobby, pinning, layout, etc. | check index under .../ui-cookbook/... |
Video - livestream / streaming
| Want to ... | Page (.md) |
|---|---|
| Streaming overview | .../streaming/overview.md |
| Livestreaming guide | .../guides/livestreaming.md |
| Backstage (go live / stop live) | .../streaming/backstage.md |
| WebRTC viewer | .../streaming/webrtc.md |
| HLS viewer | .../streaming/hls.md |
| RTMP ingress / broadcasts | .../streaming/rtmp.md / .../streaming/rtmp-broadcasts.md |
| Mobile livestreaming (broadcast from device) | .../streaming/mobile-livestreaming.md |
| Watch a livestream (player UI) | .../ui-cookbook/livestream-player.md |
Video - advanced
| Want to ... | Page (.md) |
|---|---|
| Ringing | .../advanced/incoming-calls/ringing.md |
| CallKit integration | .../advanced/incoming-calls/callkit-integration.md |
| VoIP push for incoming calls | .../advanced/incoming-calls/push-notifications.md |
| Screen sharing | .../advanced/screensharing.md |
| Picture-in-picture | .../advanced/picture-in-picture.md |
| Add chat to a call | .../advanced/chat-integration.md |
| Deeplinking | .../advanced/deeplinking.md |
| Troubleshooting calls | .../advanced/troubleshooting-calls.md |
| Video / audio filters | .../advanced/apply-video-filters.md |
| Background modes | .../advanced/background-modes.md |
(Prefix every Video row with https://getstream.io/video/docs/ios.)
---
Feeds
Feeds has no pre-built UI - the SDK exposes state (feeds, activities, reactions, comments, follows); you build the views. Always read the relevant state page before assuming an API.
Start here:
- Install:
https://getstream.io/activity-feeds/docs/ios/installation.md - Quick start:
https://getstream.io/activity-feeds/docs/ios.md - Tokens and authentication:
https://getstream.io/activity-feeds/docs/ios/tokens-and-authentication.md - State layer:
https://getstream.io/activity-feeds/docs/ios/state.md
| Want to ... | Page (.md) |
|---|---|
| Feeds (create, read, query) | .../feeds.md |
| Feed groups (user, timeline, notification) | .../feed-groups.md |
| Activities (post, read, update) | .../activities.md |
| Reactions | .../reactions.md |
| Comments | .../comments.md |
| Follow / unfollow | .../follows.md |
| Notification feed | .../notification_feeds.md |
| For-you feed | .../for_you_feed.md |
| Feed views | .../feed-views.md |
| Bookmarks | .../bookmarks.md |
| Polls | .../polls.md |
| Stories | .../stories.md |
| Push notifications | .../push-notifications.md |
| File uploads | .../file_uploads.md |
| Moderation | .../moderation.md |
| Migrate v2 -> v3 | .../v2_to_v3_migration.md |
(Prefix every Feeds row with https://getstream.io/activity-feeds/docs/ios.)
---
Combined Chat + Video
This breaks builds via name collisions - read the pitfall in `RULES.md` first (file isolation). Then route per product above. The integration narratives:
- Chat side (add calls to messaging):
https://getstream.io/chat/docs/sdk/ios/guides/video-integration.md - Video side (add chat to calls):
https://getstream.io/video/docs/ios/advanced/chat-integration.md
---
When the docs fall short: source code + example apps
For a specific UI customization or feature the docs do not cover - an exact ViewFactory method signature, a protocol's full surface, an undocumented option, or how a flow is really wired - read the SDK source and the demo/example apps. The source is the final source of truth.
| SDK | Repo (github.com/GetStream/...) | Source | Demo / example apps |
|---|---|---|---|
| Chat low-level + UIKit | stream-chat-swift | Sources/ (StreamChat, StreamChatUI) | DemoApp, DemoAppPush, StreamChatSample, Examples |
| Chat SwiftUI | stream-chat-swiftui | Sources/ | DemoAppSwiftUI |
| Video (SwiftUI + UIKit) | stream-video-swift | Sources/ | DemoApp (SwiftUI), DemoAppUIKit |
| Feeds | stream-feeds-swift | Sources/ (StreamFeeds) | DemoApp |
How to search (prefer the version-accurate local checkout - no network, matches what the project compiles):
1. Local SwiftPM checkout - the exact source for the project's pinned version:
# List candidates WITH their actual tag — a sibling project's DerivedData can
# hold a DIFFERENT version of the same package, so never blindly take head -1.
for d in $(find ~/Library/Developer/Xcode/DerivedData /tmp -type d -path "*checkouts/stream-*-sw*" 2>/dev/null); do
echo "$(git -C "$d" describe --tags 2>/dev/null) -> $d"
done
# Pick the one whose tag == the version in Package.resolved, then grep it:
grep -rn "func makeMessageListModifier" <matching-checkout>/Sources(For swift build projects the checkout is under .build/checkouts/.) *Cross-check the tag against `Package.resolved` before reading — the API differs across majors/minors (Options-based slots, the `Styles` protocol, `chatBackground tokens, and the StreamChatCommonUI Appearance only exist on recent versions). The checkout the build actually compiles (this project's own DerivedData or your -derivedDataPath`) is authoritative; if source you read contradicts the live docs about a symbol's existence, you are probably on the wrong version.**
2. GitHub - for the demo/example apps (not included in the SPM checkout) and when no local checkout exists. Browse Sources/ for the API and the demo folder for real usage; read raw files from raw.githubusercontent.com/GetStream/<repo>/<tag>/....
Match the version the project pins (Package.resolved). Do not present source-derived APIs as if documented - say where you found them.
Stream Swift - push setup (Chat push + Video VoIP / CallKit)
Triggered by requests like "add push notifications for chat" or "ringing should wake the app on an incoming call". Goal: a seamless, mostly-automated setup. One APNs auth key (.p8) powers everything - regular alert push and VoIP - so there is one manual step (Apple has no key-creation CLI) and the rest is CLI + generated code.
Obey `RULES.md` throughout - especially: the .p8 goes to Stream via the CLI, never into the app bundle.
---
Step 0: Scope it
| User wants | Mechanism | Providers to create | Client work |
|---|---|---|---|
| Chat push (message while backgrounded) | APNs alert push | one named apn | permission + device register |
| Video ringing (incoming call wakes app) | VoIP push + CallKit | one named voip | PushKit + CallKitAdapter |
| A calling + messaging app (both) | both of the above | apn and voip (same key) | both |
Also decide whether a Notification Service Extension is needed (Step 5) - ask or infer from the use case.
---
Step 1: APNs auth key (.p8) - one-time manual step
Apple provides no CLI/API to generate an APNs auth key (the App Store Connect API does not expose key creation; fastlane cannot either), so this single step is manual. If the user already has a .p8, skip to Step 2.
In the Apple Developer portal: Certificates, Identifiers & Profiles -> Keys -> + -> enable "Apple Push Notifications service (APNs)" -> Register -> Download the .p8 (downloadable once). Capture three values:
- Key ID - 10 chars, shown on the key.
- Team ID - 10 chars, on the Membership page.
- Bundle ID - the app's bundle identifier (this is the APNs
topic).
A single token .p8 enables both alert and VoIP push - no separate VoIP certificate needed. (Legacy alternative: a VoIP Services Certificate .p12 via apn_p12_cert - only if the user insists; the token path below is simpler and preferred.)
---
Step 2: Create the Stream push provider(s) via CLI
The provider stores the key server-side; the SDK never sees it. Use UpsertPushProvider (POST /api/v2/push_providers). Build the body with jq --rawfile so the .p8 newlines survive, and pipe over stdin so the key is never pasted inline:
# Regular APNs provider (Chat push + Video alert push)
jq -n --rawfile key /path/AuthKey_<KEYID>.p8 \
'{push_provider:{type:"apn",name:"apn",apn_auth_type:"token",apn_auth_key:$key,apn_key_id:"<KEYID>",apn_team_id:"<TEAMID>",apn_topic:"<BUNDLE_ID>",apn_development:true}}' \
| getstream api UpsertPushProvider --request @-# VoIP provider (only for ringing video calls; same key, name "voip")
jq -n --rawfile key /path/AuthKey_<KEYID>.p8 \
'{push_provider:{type:"apn",name:"voip",apn_auth_type:"token",apn_auth_key:$key,apn_key_id:"<KEYID>",apn_team_id:"<TEAMID>",apn_topic:"<BUNDLE_ID>",apn_development:true}}' \
| getstream api UpsertPushProvider --request @-Notes:
apn_development:truetargets the sandbox (debug builds run from Xcode). Setfalsefor TestFlight / App Store builds - flip it per environment, or keep separate dev/prod apps.- Preview the request without sending: append
--dry-run. - Confirm:
getstream api ListPushProviders. Remove one:getstream api DeletePushProvider --type apn --name voip. - If the user prefers a file over stdin, write the JSON to a temp file and use
--request @/tmp/apn.json, then delete it. Never echo the.p8contents into the chat.
Exact field schema (from the live API): type, name, apn_auth_type (token | certificate), apn_auth_key, apn_key_id, apn_team_id, apn_topic, apn_development, apn_host, apn_p12_cert.
---
Step 3: Capabilities + entitlements (Xcode -> Signing & Capabilities)
- Chat push: add the Push Notifications capability.
- Video VoIP / CallKit: add Push Notifications + Background Modes with Voice over IP, Remote notifications, and Background processing checked.
---
Step 4: Client registration code
Fetch the exact, current code from the docs (routes in `docs-map.md`) and apply it to the project's lifecycle. The essentials:
Chat (alert push) - .../sdk/ios/client/push-notifications.md
Request authorization after connecting the user, then register the device in the app delegate:
UNUserNotificationCenter.current()
.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
guard granted else { return }
DispatchQueue.main.async { UIApplication.shared.registerForRemoteNotifications() }
}
func application(_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
guard chatClient.currentUserId != nil else { return }
chatClient.currentUserController().addDevice(.apn(token: deviceToken)) // multi-bundle: .apn(token:providerName: "apn")
}Video (VoIP + CallKit) - .../advanced/incoming-calls/push-notifications.md + .../advanced/incoming-calls/callkit-integration.md
Provider names here must match the dashboard names from Step 2 (apn / voip):
let notificationsConfig = PushNotificationsConfig(
pushProviderInfo: PushProviderInfo(name: "apn", pushProvider: .apn),
voipPushProviderInfo: PushProviderInfo(name: "voip", pushProvider: .apn)
)
// pass to the client: StreamVideo(apiKey:, user:, token:, pushNotificationsConfig: notificationsConfig, ...)
@Injected(\.callKitAdapter) var callKitAdapter
callKitAdapter.streamVideo = streamVideo
callKitAdapter.registerForIncomingCalls() // call after login, e.g. in onAppearRegister the VoIP token as it changes (observe callKitPushNotificationAdapter.$deviceToken -> streamVideo.setVoipDevice(id:), and deleteDevice(id:) on the previous token). The CallKitAdapter reports incoming calls to CallKit automatically from the VoIP push - no manual CXProvider wiring. For "Recents" deep-links, add the optional Intents Extension (INStartCallIntent) per the CallKit page.
---
Step 5: Notification customizations (optional) - decide, do not default
A Notification Service Extension (NSE) modifies the notification before it shows (Chat only):
- Add an NSE when the app wants real message previews: sender name + avatar, decrypted text, accurate unread/mute handling. Use
ChatRemoteNotificationHandler, an App Group, and setconfig.applicationGroupIdentifierso the extension and app share data. Route: the NSE section of the Chat push page. - Skip the NSE when a generic alert ("You have a new message") is enough, or for ringing video calls (VoIP + CallKit present the call UI directly - no NSE).
If unclear, ask one question:
Do you want rich notifications (sender, message text, avatar), or is a simple "new message" alert enough? Rich previews need a Notification Service Extension.
---
Step 6: Verify
getstream api ListPushProviders-> the provider(s) exist with the rightnameandapn_topic.getstream api CheckPush ...tests the push config server-side; or do an end-to-end test: a real message from a second user (chat) / a ringing call from a second user (video).- Real device required - APNs and especially VoIP do not work on the simulator.
- A device token registers only after connecting the user (chat) / initializing
StreamVideowith the config (video). If push is silent, confirm the device appears viagetstream api ListDevicesand thatapn_developmentmatches the build (sandbox vs production).
Stream Swift - non-negotiable rules + iOS pitfalls
Every rule below is stated once. Read it before writing code. The docs cover how to use each API; this file covers what the docs do not shout about and what breaks builds.
---
Secrets and auth
- Never hardcode a Stream API secret in app code,
Info.plist, or chat. The client holds only the API key and a user token; the secret stays server-side / in the CLI. - Token model: backend-issued token when a backend exists; CLI token (
getstream token <user_id>or... --ttl <duration>) for local/demo (preferred when no backend); a static pasted token only when the user insists. - Never invent or fabricate credentials. Never use
devToken()in production - it lets any client impersonate any user. - In generated code, reference credentials via named constants (
Config.apiKey,Config.userToken) in a dedicated config file - never embed raw values inline.
No wrapper or bridge abstractions
Do not introduce VideoCallBridge, CallManager, StreamWrapper, SDKAdapter, or similar middle layers. Use SDK types directly: StreamVideo / StreamVideoUI at app init, CallViewModel as @StateObject in the root call view, callViewModel.call?.state for call state.
The only exception is a thin file-isolation service for combined Chat + Video apps (see pitfalls). Even then it just holds the SDK instances - no methods, no extra logic.
Project ownership
Preserve the app's existing architecture. Do not convert UIKit to SwiftUI, do not replace CocoaPods with SPM, and do not flatten an existing coordinator / DI / navigation setup unless the user asks. If there is no iOS project, tell the user to create it in Xcode first - do not scaffold from the CLI.
Do not change the app's entry point or navigation unless asked. The default Chat integration is channel list → tap → pushed message screen. Do not make the app open one hard-coded channel directly (ChatChannelView as the root) as a shipped behaviour - that is an architecture change the user did not request, and it hides header/navigation bugs because the direct-open path never exercises makeChannelHeaderViewModifier / makeChannelDestination. Any temporary scaffold used to screenshot a screen (e.g. a direct-open root for design verification) is throwaway: DELETE it before delivery (not merely disable it - no verifyMessageScreen-style flag, dead if/else branch, or scaffold-only constant/import may remain in the shipped code) and re-verify on the real navigation path. "Make the channel look like X" is a styling request, not a request to change how the app is entered.
Match the Chat UI layer to the vertical
Stream Chat has two layers; picking the wrong one is an architecture mistake, not a styling one.
- Pre-built UI components (
StreamChatSwiftUI/StreamChatUI, customized via ViewFactory + theming) - for standard-messenger verticals: social / community chat, marketplace, workplace (Slack-like), support, DMs. Their UI already resembles these apps; customize, do not rebuild. - Custom UI on the low-level `StreamChat` client + State Layer - for livestream chat (Twitch / YouTube / Kalshi), live shopping, overlay / ticker chat, and any bespoke surface. The pre-built components fight this kind of UI; do not recommend or wire them here. Build views directly on the State Layer (
LivestreamChatfor high volume, elseChat,ChannelList, etc.) / Controllers, withStreamChat(+ optionalStreamChatCommonUI) linked but notStreamChatSwiftUI. Run `custom-ui.md` (the decision + the build procedure); see `docs-map.md` "Chat - Custom UI" for the docs it routes to.
Default to components, and lean hard that way. The expensive, common mistake is over-choosing custom - rebuilding a worse messenger by hand and throwing away avatars, grouping, reactions, threads, attachments, typing, receipts, and the composer's pickers/voice notes. The litmus test: if theming + a few `ViewFactory` slots + `Styles` could match the design, it is a components job (even strong reskins like WhatsApp). Pick custom only when you'd otherwise be replacing the row, composer, header, AND list all at once - using the SDK purely as a data source. When unsure which applies, build the components version first (faster to confirm-or-reject), or ask before writing code (see `SKILL.md`).
Permissions: match the channel type to the vertical
Stream Chat's built-in channel types ship sensible default permission policies - start from the one that fits the vertical instead of hand-rolling permissions:
| Vertical | Channel type | Default posture |
|---|---|---|
| Social / dating / marketplace DMs | messaging | Membership-gated: members read/write; not public |
| Workplace / Slack-like | team | Membership + roles for broader team spaces |
| Livestream / live-shopping | livestream | Public read/write without membership; supports guest + anonymous viewers |
| In-game chat | gaming | Game-tuned defaults |
| User-to-LLM chat | ai | AI chat defaults |
Then tune only what the vertical needs:
- Livestream viewers: connect read-only viewers as anonymous (
connectAnonymousUser()- read-only, can readlivestreamchannels, no MAU cost) or as guest (connectGuestUser(userInfo:)- limited writes, pre-account). Do not mint a full per-user JWT for every anonymous viewer. Moderate with thechannel_moderatorrole, not client-sideadmin. - Regular chat: keep channels membership-gated; do not make
messagingchannels world-readable to imitate livestream - use thelivestreamtype for that. - Roles: user-level (
user,guest,anonymous,admin) vs channel-level (channel_member,channel_moderator); built-in roles cannot be mixed across levels.
Security (non-negotiable): permission checks apply to client-side calls only - server-side calls (API key + secret) bypass all permissions. Never rely on client permissions to protect sensitive actions, never give the app the secret or an admin role, and never grant elevated permissions to ordinary client users. Customize policies in the Dashboard (Chat > Roles & Permissions) or via the API (UpdateChannelType, CreateRole) - never from app code. Routes: `docs-map.md` "Permissions and roles".
Client lifetime
Initialize Stream clients once at app launch or in an owned service object. Never create a client in a SwiftUI View body, a computed property that re-runs on redraw, or a transient callback with no owner. Store controllers / view models / SDK helpers as owned state (@State, @StateObject, ObservableObject, or stored properties).
On account switch, fully disconnect / log out the current user before connecting the next one - wait for logout completion (offline storage + optimistic updates can corrupt if you connect mid-logout).
UI and concurrency
UI state changes belong on the main actor - hop back to the main actor before mutating SwiftUI or UIKit state from an SDK callback or async task. Match the project's existing concurrency style (async/await vs completion handlers) instead of rewriting unrelated code.
Mindful API usage (best practices)
The SDK talks to a rate-limited, billed backend. Sloppy usage causes throttling, UI jank, and cost. Read the relevant best-practices page (routes in `docs-map.md`) before building a vertical.
- Do not spam `queryChannels` (or any query). Query once with a filter + sort + sensible
pageSize, paginate withloadMore*, then rely onwatch+ WebSocket events for live updates - do not re-query on every change. For a single channel, only one ofwatch/query/createis needed; do not chain redundant calls. - No rendering loops. Never create a client, controller, query, or state object in a SwiftUI
bodyor computed var - each redraw re-fires network calls. Own them as@State/@StateObject/ stored properties (see Client lifetime). A list that re-fetches on every scroll, or a view that reconnects on every appear, is the symptom. - Authenticate once. Connect the user once and reuse the connection. For expiring tokens supply a
tokenProviderand let the SDK refresh - never poll, manually reconnect on expiry, or connect/disconnect in a loop. Wait for logout before connecting the next user. Never ship dev tokens or the API secret. - Respect budgets and limits. There is a per-app query-channels budget plus global rate limits; back off on errors instead of retrying tightly, and paginate instead of requesting huge pages.
Case-specific tuning
Defaults are tuned for standard messaging, not every vertical - configure to fit:
- Livestream / live-shopping chat: use the
livestreamchannel type; disable read events, typing indicators, connect events, file uploads, and custom messages; disable offline/local storage (per-message write overhead bottlenecks under volume); enable slow mode for high traffic; pre-load users via batch upsert (up to 100 per call). The API auto-throttles typing/read events past ~100 watchers and messages past ~5/sec - design for it. Source: the livestream best-practices page. - Marketplace / workplace: read the marketplace best-practices page before scaling channel and user counts.
Docs discipline
The live iOS docs are the source of truth (see `SKILL.md` for the .md convention and `docs-map.md` for routing). Do not answer SDK specifics from training data. If you did not fetch it this conversation, fetch it. Cite the page. Never guess a ViewFactory / ColorPalette / method name - these look guessable but are routinely wrong; fetch the page first.
---
iOS pitfalls (the build-breakers)
These are the mistakes the docs will not warn you about. Honor them every time.
Chat + Video name collisions (most common build break)
When an app uses both SDKs, several names collide and cause "Ambiguous use of 'init'" / "ambiguous" compiler errors:
| Concept | Chat | Video |
|---|---|---|
| User | UserInfo (StreamChat) | User (StreamVideo) |
| Token | Token (StreamChat) | UserToken (StreamVideo) |
| SwiftUI wrapper | StreamChat (StreamChatSwiftUI) | StreamVideoUI (StreamVideoSwiftUI) |
ViewFactory, @Injected, InjectionKey, InjectedValues | StreamChatSwiftUI | StreamVideoSwiftUI (same names) |
Fix: file isolation. Never import both SDKs in one file. Put each SDK's setup in its own service file (ChatService.swift imports only Chat; VideoService.swift imports only Video); the App/AppDelegate imports neither and just calls both services. StreamVideoUIKit depends on StreamVideoSwiftUI, so the same rule applies to UIKit combined apps. Same API key, same JWT, same user id work for both products. Docs: Chat side video-integration, Video side chat-integration.
SwiftUI lifecycle
- Initializing
StreamChat/StreamVideo/StreamVideoUIin anAppstructinit()requires storing them in `@State` (_x = State(wrappedValue:)).Appis a value type SwiftUI can recreate; a plainlet/vargets re-initialized. UseAppDelegateinstead when you needUIApplicationDelegatecallbacks (push, CallKit, background, URL handling). - The SwiftUI wrapper (
StreamChat/StreamVideoUI) must exist before any SDK view renders - rendering first causes afatalError/ crash.
Chat
- Never wrap
ChatChannelListViewinNavigationView/NavigationStack- it includes its own. Set the title via thetitle:initializer parameter, not.navigationTitle(). To use your own nav container, passembedInNavigationView: false. - Controllers are stateful - store as
@Stateor in anObservableObject, never a computed var (a new instance per redraw re-fetches and loses state). import StreamChatandimport StreamChatSwiftUIin any file using either -ChatClient,UserInfo,Token, controllers live inStreamChat; views + theStreamChatwrapper live inStreamChatSwiftUI. Access the client via@Injected(\.chatClient), neverChatClient.shared.- Link the `StreamChat` product, not just `StreamChatSwiftUI`.
StreamChatSwiftUIonly re-exportsStreamChatCommonUI+StreamCore, notStreamChat. If onlyStreamChatSwiftUIis added to the target,import StreamChatfails with "No such module 'StreamChat'". Add theStreamChatproduct to the target too (it ships from the transitively-resolvedstream-chat-swiftpackage - no second package URL needed). Separately, anObservableObjectservice with@Publishedneedsimport Combineonce Stream modules are imported (SwiftUI's re-export is not always enough) - else "init(wrappedValue:) is not available due to missing import of defining module 'Combine'". - Matching a reference design is not a theming task - and there are THREE axes, not two. When the request carries a target screenshot / Figma / "look like <app>", run `design-matching.md` and decompose every region first. Setting the bubble color and wallpaper and stopping is the known failure - the composer button set, the timestamp + read-receipt placement (often inside the bubble), the bubble tail, the header, and the date-separator pill are all structural and need
ViewFactoryslots, notColorPalette. The grouped 1/2/3/4+ photo collage, by contrast, is already the default (MessageMediaAttachmentsContainerView) - do not rebuild it. The three axes: theming (Appearance: colors/fonts/glyphs), `Styles` (factory.styles: insets/padding/corner-radius/chrome), structure (ViewFactory: which views & layout). Routing a problem to the wrong axis is the core failure mode. - Padding / insets / corner-radius are the `Styles` axis - not theming, not a `ViewFactory` slot. "The green frame around the photo collage is too thick / the bubble padding is too big" comes from
factory.styles.makeMessageAttachmentsViewModifier(default insetspacingXsall sides); the message bubble frommakeMessageViewModifier; attachment cells frommakeMessageAttachmentItemViewModifier; composer chrome frommakeComposerViewModifier(container background) /makeComposerInputViewModifier(field background+border) /makeComposerButtonViewModifier. `RegularStyles` is not `open` - you cannot subclass it. Conform a class toStylesdirectly, implement the modifiers you want, and re-supply the non-defaulted members RegularStyles provides (composer background =ComposerBackgroundRegularViewModifier, input =RegularInputViewModifier, button =RegularButtonViewModifier, scroll/suggestions) using those public types - or the composer/field render white/unstyled because theextension Stylesdefaults areEmptyViewModifier. Assign it to your factory'svar styles. Details + checklist in `design-matching.md`. - Liquid Glass is a ready-made `Styles`, not a hand-rolled effect. For glassy/translucent designs (workplace / Slack / Teams / Discord, or any modern iOS-26 chrome) set
factory.styles = LiquidGlassStyles()— the SDK class that gives a floating glass composer + glass buttons/scroll/suggestions in one line. Recommend it whenever the reference shows translucency. It renders only on iOS 26+ and Swift 6.2+ (graceful no-op otherwise — verify on an iOS 26 sim)..floatingchanges composer layout (overlay over the list, no docked bar); if the reference composer is docked, keepcomposerPlacement = .dockedand apply the publicLiquidGlassModifier/LiquidGlassBorderlessModifierselectively instead.LiquidGlassStylesispublicnotopen(can't subclass) — to mix glass with otherStylesoverrides, conform your own class and re-supply the glass modifiers. See `design-matching.md`. - Reactions are configured in the DATA layer — never hard-code a type→emoji map in a custom view. A SwiftUI view that maps reaction types to emoji itself desyncs from the picker, the long-press overlay, and the reaction-detail view (they read the SDK's config, not your view), so a reaction can be added that other views can't render. Configure
Appearance.Images.availableMessagesReactionEmojis(type→emoji, read everywhere byReactionsIconProvider),availableEmojis(picker), andUtils(sortReactions:)(order); then use the SDK'smakeBottomReactionsView. Reaction types stay alphanumeric (haha,argentina); the emoji comes from the config. The long-press reactions overlay is overridable (makeReactionsOverlayView) but is a heavy messenger idiom — only replace it when the user explicitly asks for it; otherwise keep the SDK default. Consult the message-reactions docs; don't fix this at the view layer. See `design-matching.md`. - Overriding a composite slot drops every sub-feature the default rendered.
makeMessageItemView,makeChannelHeaderViewModifier,makeMessageComposerViewType,makeComposerInputTrailingView, andmakeChannelListItemeach draw many things internally. A custom replacement that only handles the case in front of you silently loses the author avatar on incoming messages, message grouping, reactions, replies, delivery status - or, for the composer input trailing, the send / voice-record / confirm-edit / slow-mode button. A near-empty test channel hides the loss. Before overriding one, read the default view'sbodyin the pinned source, enumerate every sub-view, and reproduce each (reuse the SDK sub-views) or tell the user you dropped it. - The channel header modifier is applied to a zero-height divider between the message list and the composer - not the whole screen. So rendering a header inside
makeChannelHeaderViewModifiervia.safeAreaInset(.top)or aVStackputs it at the bottom, above the composer. Customize the header with.toolbarplacements (they reach the nav bar at the top regardless) +.toolbarBackground; only drop to a fully custom container/makeChannelDestinationfor a genuinely taller header - and never fake the header in the app's root view for one channel (it vanishes on the real push path). See `design-matching.md` Step 2.5. - Match dimensions by MEASURING, not eyeballing - then verify and iterate. The reference is a spec: reproduce header height, font/icon sizes, paddings, corner radius, and alignment - not just colors and presence. Do not pick round numbers by eye (the recurring composer failure). Find the screenshot's scale (
sips -g pixelWidth -g pixelHeight; iOS shots are @2x/@3x → divide to points), measure each element in points, and reuse the SDK design tokens (@Injected(\.tokens):spacingXs=8,spacingMd=16,radius3xl=24; composer fielddefaultInputViewHeight=40) rather than magic numbers, so custom views align with the un-overridden parts. A match is unverified until you build, seed data that triggers every region (incoming + outgoing, a run of same-author messages so avatars/grouping show, album, reactions, reply, long text, date separator), open the real message screen, measure your render against the reference at the same scale, compare region-by-region, and iterate until each passes. See `design-matching.md` "How to actually get the dimensions right" + Step 5.
Video
CallViewModelis@StateObjectat the single ownership site,@ObservedObjectin children. Never create a second instance for the same call - state is lost.- Read state via
callViewModel.call?.state, neverCallState.shared(singleton access leaves the UI stale). Leave a call withcallViewModel.hangUp(), notcall.leave(). import StreamVideoin any file declaringUser,UserToken,Call,CallParticipant,Member,RTCVideoTrack, orLogConfig-StreamVideoSwiftUIalone is not enough and you get "cannot find type".- Add
NSCameraUsageDescription+NSMicrophoneUsageDescriptiontoInfo.plistbefore any call (missing keys = silent crash). Audio-onlyaudio_roomstill needs the microphone key. - Always surface
callViewModel.errorwith an.alerton views that calljoinCall/startCall/acceptCall/hangUp.
Push notifications (Chat push, Video VoIP / CallKit)
Full runbook: `push.md`. The traps:
- The APNs auth key (
.p8) goes to Stream via the CLI (UpsertPushProvider), never into the app bundle,Info.plist, or git. Pipe it over stdin; do not paste its contents into chat. - A single token
.p8powers both alert push and VoIP - you do not need a separate VoIP certificate. The providername(s) you create (apn,voip) must match the names referenced in client code (addDevice(providerName:),PushProviderInfo(name:)). apn_developmentmust match the build:true= sandbox (Xcode debug),false= TestFlight / App Store. A mismatch = silent no-delivery.- Real device only - APNs and VoIP never fire on the simulator.
- A device registers only after the user connects (chat) /
StreamVideois initialized with the push config (video). Registering earlier is a no-op. - VoIP/CallKit needs Background Modes (Voice over IP, Remote notifications, Background processing); plain Chat push needs only the Push Notifications capability.
- Add a Notification Service Extension only when rich/decrypted previews are wanted - it is not required for a basic alert or for ringing calls. Decide deliberately; do not add it by reflex.
Feeds
Feeds has no pre-built UI components - the SDK gives you state objects (FeedState, activities, reactions, comments, follows), you build the views. Load the relevant Feeds docs page from `docs-map.md` for the exact state API; do not assume a component exists.
Stream Swift - setup flow (integrate / new app)
Run this once per session for integrate or new-app requests, before feature work. How-to / reference requests skip this entirely and go straight to docs lookup. Obey `RULES.md` throughout.
---
1. Project signals (read-only probe)
Detect the project shape once:
bash -c 'echo "=== XCODE ==="; find . -maxdepth 3 \( -name "*.xcodeproj" -o -name "*.xcworkspace" \) -print 2>/dev/null; echo "=== MANIFESTS ==="; find . -maxdepth 3 \( -name "Package.swift" -o -name "Package.resolved" -o -name "Podfile" \) -print 2>/dev/null; echo "=== EMPTY ==="; test -z "$(ls -A 2>/dev/null)" && echo "EMPTY_CWD" || echo "NON_EMPTY"'Interpret and hold in context:
*.xcodeproj/*.xcworkspace-> existing Xcode app; preserve its UI layer and package manager.Package.swift, no Xcode project -> ask whether it is an app package, a shared module, or support code.Podfile-> keep CocoaPods unless the user wants to migrate.EMPTY_CWD/ no Xcode project -> stop: tell the user to create the iOS app in Xcode first. Do not scaffold from the CLI.
State a one-line status (e.g. SwiftUI app detected - MyApp.xcodeproj - ready for Stream wiring).
---
2. Credentials (ask once, then act)
Collect the API key, a user token, and (Chat only) optional seed channels in one message, then execute without pausing between steps.
Chat:
To wire this with real data I need: (1) should I fetch your API key and generate a token via the Stream CLI, or will you paste them? (2) token expiry (1h,1d, never)? (3) seed a few channels so the app shows data on first launch?
Feeds: same as Chat but replace seed channels with feed groups (defaults user, timeline, notification).
Video: API key + token only (calls are ephemeral - nothing to seed).
If the user says they will paste credentials, take them and skip the CLI steps below.
CLI steps (run in sequence, narrate one line each)
# Onboard ONCE in the project dir: authenticate + select/create org & app + write
# project credentials. REQUIRED first — env/token/api all fail with
# "stream project is not initialized; run `getstream init` first" otherwise.
getstream init
# API key -> Secrets.xcconfig (read via Info.plist / Bundle.main); the secret is never printed
getstream env --target ios
# Token (never-expiring, or add --ttl <duration>)
getstream token <user_id>
getstream token <user_id> --ttl <duration>
# Seed channels (Chat only, if requested): create the users first, then each channel
getstream api UpdateUsers --request '{"users":{"<token_user_id>":{"id":"<token_user_id>","name":"Token User"},"alice":{"id":"alice","name":"Alice"}}}'
getstream api GetOrCreateChannel --type messaging --id <channel-id> --request '{"data":{"created_by_id":"<token_user_id>","members":[{"user_id":"<token_user_id>"},{"user_id":"alice"}]}}'To seed messages, attribute the sender server-side with message.user_id and include original_width/original_height on image attachments (without them the SwiftUI media gallery treats images as landscape and stacks a 2-photo album vertically instead of side-by-side):
getstream api SendMessage --type messaging --id <channel-id> \
--request '{"message":{"user_id":"alice","text":"Hi","attachments":[{"type":"image","image_url":"<url>","thumb_url":"<url>","original_width":1200,"original_height":900}]}}'Pick --type to match the vertical, not always messaging: messaging for social / marketplace / DMs, team for workplace, livestream for livestream / live-shopping (its permissions allow public + anonymous access). See `RULES.md` "Permissions". Use short channel ids (general, random) and a small set of usernames (alice, bob, carol). Make the token user a member of at least one channel so it shows on first launch. Print a one-line summary of what was created.
Never put the API secret in app code - the CLI uses it server-side only. Never fabricate credentials. If a CLI step fails, explain briefly and ask the user to paste the missing value.
---
3. Install the SDKs
Use the project's existing package workflow; install only what the requested products need:
- Xcode app, no `Package.swift` -> guide the user through File -> Add Package Dependencies. For exact package names/URLs, fetch the installation doc from `docs-map.md` (Chat:
basics/integration.md; Video:basics/installation.md; Feeds:installation.md). - Swift package-managed -> edit
Package.swiftdirectly. - CocoaPods -> keep Pods unless the user asks to migrate.
---
4. Wire the client
Initialize the client once at an owned lifecycle entry point (App init, AppDelegate, or a service object) and connect the user. Reference credentials via named constants (Config.apiKey, Config.userToken), never inline.
The exact init + connect code lives in the docs - fetch the relevant page from `docs-map.md` ("Getting started" / "Quickstart" / "Client and authentication") and apply it. Honor the lifecycle and combined-SDK pitfalls in `RULES.md`. If channels were seeded, the app should render them on first launch with no hardcoded ids.
---
5. Verify before stopping
- the right package is on the right target and resolves
- the client is initialized before any Stream view renders
- the requested user connects without leaking the secret
- the feature renders inside the existing navigation structure
- switching users / logout tears down the previous session cleanly
Then return to Docs lookup in `SKILL.md` for each requested screen.
Related skills
How it compares
Use stream-swift for official Stream iOS SDK integration with live docs; generic mobile-ux-optimizer covers web touch UX, not Stream realtime SDK wiring.
FAQ
Which Stream products does stream-swift cover on iOS?
stream-swift covers Stream Chat (SwiftUI and UIKit UI layers plus low-level client), Stream Video (calls, livestream, CallKit), and Stream Activity Feeds. The skill classifies each request, fetches the matching getstream.io .md index, and applies pages to the user’s Xcode project
How does stream-swift keep SDK guidance current?
stream-swift converts any Stream docs page URL to its Markdown twin by appending .md, fetches live content from getstream.io, and limits itself to 3 pages per request. When docs are incomplete, it escalates to pinned SDK source in Package.resolved checkouts.