diff --git a/.claude/skills/stream-video-flutter-livestream/SKILL.md b/.claude/skills/stream-video-flutter-livestream/SKILL.md
new file mode 100644
index 000000000..1610750f2
--- /dev/null
+++ b/.claude/skills/stream-video-flutter-livestream/SKILL.md
@@ -0,0 +1,526 @@
+---
+name: stream-video-flutter-livestream
+description: |
+ Use this skill whenever the user wants to add livestreaming, audio rooms,
+ HLS broadcasting, or a TikTok-style vertical livestream feed to a Flutter
+ app using the Stream Video SDK. Covers the host (publisher) side, the
+ viewer side with `LivestreamPlayer`, backstage / go-live transitions,
+ participant filtering for the broadcast view, HLS playback URLs, and the
+ call-switching `CallManager` pattern for swipeable feeds. TRIGGER on
+ prompts like: "add livestreaming to my app", "build a livestream with
+ Stream", "create an audio room / Twitter Spaces clone", "build a TikTok-
+ style live feed", "go live / publish a livestream from Flutter", "watch a
+ livestream in Flutter", "implement HLS playback", "host vs viewer
+ experience", "swipe between live channels". Assumes the user has already
+ set up the SDK — for installation, initialization, permissions, and core
+ call concepts, defer to `stream-video-flutter`. For ringing /
+ CallKit / push, use `stream-video-flutter-ringing`.
+---
+
+# Stream Video Flutter SDK — Livestreaming & Audio Rooms
+
+You are helping implement a livestream, audio room, or live feed using the
+**Stream Video Flutter SDK**. This skill assumes core SDK setup is already
+done — see `stream-video-flutter` for installation, permissions, and
+initialization.
+
+> **Authoritative documentation:**
+> - Livestreaming:
+> - Audio rooms:
+> - General Flutter docs:
+>
+> Consult these for anything not covered by this skill (advanced HLS
+> configuration, recording layouts, viewer-count APIs, custom backstage UI,
+> moderation tooling, dashboard call-type configuration).
+
+---
+
+## Pick the right shape first
+
+| What the user wants | Call type | Viewer widget | Host widget |
+|---|---|---|---|
+| 1-to-many video stream | `liveStream()` | `LivestreamPlayer` | `StreamCallContent` (filtered) |
+| Twitter Spaces / Clubhouse | `audioRoom()` | `StreamCallContent` audio-only | `StreamCallContent` audio-only |
+| TikTok-style swipe feed | `liveStream()` + `CallManager` | `LivestreamPlayer` | `StreamCallContent` (filtered) |
+| 1:1 / group calling | `defaultType()` | (use `stream-video-flutter`) | (use `stream-video-flutter`) |
+
+Both `liveStream` and `audio_room` call types **start in backstage mode**.
+The host must call `call.goLive()` for viewers / listeners to see or hear
+anything.
+
+---
+
+## Audio rooms
+
+```dart
+final call = StreamVideo.instance.makeCall(
+ callType: StreamCallType.audioRoom(),
+ id: 'room-42',
+);
+
+await call.getOrCreate(
+ members: [
+ MemberRequest(
+ userId: StreamVideo.instance.currentUser.id,
+ role: 'host',
+ ),
+ ],
+);
+
+await call.join(
+ connectOptions: CallConnectOptions(
+ microphone: TrackOption.enabled(),
+ camera: TrackOption.disabled(),
+ ),
+);
+
+await call.goLive(); // Allow listeners to join
+```
+
+Key points:
+
+- Listeners join with mic / camera disabled.
+- Listeners need `call.requestPermissions([CallPermission.sendAudio])` to
+ speak; the host grants with `call.grantPermissions(userId:, permissions:)`.
+- Use `call.stopLive()` to return to backstage (paused, not ended).
+
+---
+
+## Livestream — host (publisher)
+
+```dart
+final call = StreamVideo.instance.makeCall(
+ callType: StreamCallType.liveStream(),
+ id: 'stream-abc',
+);
+
+await call.getOrCreate(
+ members: [
+ MemberRequest(
+ userId: StreamVideo.instance.currentUser.id,
+ role: 'host',
+ ),
+ ],
+);
+
+await call.join(
+ connectOptions: CallConnectOptions(
+ camera: TrackOption.enabled(),
+ microphone: TrackOption.enabled(),
+ ),
+);
+
+await call.goLive(); // Backstage → live
+```
+
+### Host broadcast UI — filter by `isVideoEnabled`
+
+If you render `StreamCallContent` as-is, viewer tiles (with disabled
+cameras) appear in the host's layout. Filter participants so only those
+with active video are shown:
+
+```dart
+PartialCallStateBuilder(
+ call: call,
+ selector: (state) => state.callParticipants
+ .where((p) => p.isVideoEnabled)
+ .toList(),
+ builder: (context, hosts) {
+ if (hosts.isEmpty) {
+ return const Center(child: Text('Host video is not available'));
+ }
+ return StreamCallContent(
+ call: call,
+ callParticipantsWidgetBuilder: (context, call) =>
+ StreamCallParticipants(call: call, participants: hosts),
+ callControlsWidgetBuilder: (_, __) => const SizedBox.shrink(),
+ callAppBarWidgetBuilder: (_, __) => const SizedBox.shrink(),
+ );
+ },
+)
+```
+
+> **Why `isVideoEnabled` and not `roles.contains('host')`?** Filtering by
+> role works only when roles are perfectly assigned. `isVideoEnabled`
+> correctly excludes any participant joining with a disabled camera —
+> matching the visual contract of "only show people who are broadcasting".
+
+### Host controls
+
+```dart
+Row(
+ mainAxisAlignment: MainAxisAlignment.spaceEvenly,
+ children: [
+ ToggleMicrophoneOption(call: call),
+ ToggleCameraOption(call: call),
+ FlipCameraOption(call: call),
+ // Custom "End Live" button → await call.stopLive(); await call.leave();
+ ],
+)
+```
+
+### Ending the broadcast
+
+```dart
+await call.stopLive(); // Return to backstage (stream paused, not ended)
+await call.leave(); // Disconnect from SFU
+// or:
+await call.end(); // Terminate for everyone
+```
+
+---
+
+## Livestream — viewer (subscriber)
+
+**Always use `LivestreamPlayer`** for the viewer side. It is the SDK's
+purpose-built widget — it joins the call, renders the host full-screen,
+and handles backstage / ended / no-host states. **Do not** use
+`StreamCallContent` or `StreamCallContainer` for viewers.
+
+### Simple viewer (player auto-joins)
+
+```dart
+final call = StreamVideo.instance.makeCall(
+ callType: StreamCallType.liveStream(),
+ id: 'stream-abc',
+);
+await call.getOrCreate();
+
+LivestreamPlayer(
+ call: call,
+ onCallDisconnected: (_) => Navigator.pop(context),
+)
+```
+
+### Manual join (e.g. inside a feed manager)
+
+```dart
+await call.join(
+ connectOptions: CallConnectOptions(
+ camera: TrackOption.disabled(),
+ microphone: TrackOption.disabled(),
+ ),
+);
+
+// Then in the widget tree:
+LivestreamPlayer(call: call, onCallDisconnected: (_) {});
+```
+
+### Detect "no host yet" before showing the player
+
+```dart
+PartialCallStateBuilder(
+ call: call,
+ selector: (state) =>
+ state.otherParticipants.where((p) => p.isVideoEnabled).isNotEmpty,
+ builder: (context, hasHost) {
+ if (!hasHost) return const _OfflineOverlay();
+ return LivestreamPlayer(call: call, onCallDisconnected: (_) {});
+ },
+)
+```
+
+Always check `otherParticipants.where((p) => p.isVideoEnabled).isNotEmpty`,
+not just `otherParticipants.isNotEmpty` — viewers also count as
+participants.
+
+---
+
+## Backstage & live state
+
+```dart
+await call.goLive(); // backstage → live
+await call.stopLive(); // live → backstage (paused)
+await call.leave(); // host leaves; stream continues if co-hosts remain
+await call.end(); // terminate for everyone
+```
+
+Reactive view:
+
+```dart
+PartialCallStateBuilder(
+ call: call,
+ selector: (state) => state.isBackstage,
+ builder: (context, isBackstage) =>
+ isBackstage ? const Text('Backstage') : const Text('Live'),
+)
+```
+
+---
+
+## HLS broadcasting
+
+Use HLS when you need to deliver to non-WebRTC players (web embeds, native
+video players, very large audiences).
+
+```dart
+final result = await call.startHLS();
+if (result.isSuccess) {
+ final url = call.state.value.egress.hlsPlaylistUrl;
+}
+
+await call.stopHLS();
+```
+
+Note: HLS introduces ~10-30s latency vs WebRTC. For low-latency viewers,
+keep `LivestreamPlayer` (WebRTC).
+
+---
+
+## TikTok-style livestream feed
+
+The hard part of a swipeable live feed is switching calls fast: leave the
+current SFU connection, join the next, and survive rapid swipes where the
+user blows past intermediate channels.
+
+### Architecture
+
+```
+FeedScreen (PageView vertical)
+ └─ CallManager (ChangeNotifier)
+ ├─ currentCall: Call?
+ ├─ state: idle | connecting | connected | failure
+ ├─ _version: int (incremented on every switch)
+ └─ switchToCall(callId)
+
+Each page (_LivestreamPage)
+ └─ ListenableBuilder(callManager)
+ → connected: LivestreamPlayer(call: callManager.currentCall!)
+ → connecting: _ConnectingOverlay()
+ → other: _OfflineOverlay()
+```
+
+### Design principles
+
+1. **Single active connection.** Only one SFU connection at a time. Leave
+ the old call (fire-and-forget) before starting the new one.
+2. **Version-based cancellation.** Each `switchToCall` increments
+ `_version`. After every `await` in `_connectCall`, check
+ `_isStale(version, call)` — if a newer switch happened, abandon the
+ in-flight connection and `leave()` the call.
+3. **No queue.** Rapid swipes simply make the latest `switchToCall` win;
+ earlier in-flight connections detect they are stale and clean up.
+
+### Reference implementation
+
+```dart
+class CallManager extends ChangeNotifier {
+ CallManagerState _state = CallManagerState.idle;
+ Call? _currentCall;
+ String? _currentCallId;
+ int _version = 0;
+ bool _disposed = false;
+
+ CallManagerState get state => _state;
+ Call? get currentCall => _currentCall;
+ String? get currentCallId => _currentCallId;
+
+ void switchToCall(String callId) {
+ if (_currentCallId == callId && _state != CallManagerState.failure) return;
+
+ final version = ++_version;
+
+ final oldCall = _currentCall;
+ _currentCall = null;
+ _currentCallId = callId;
+ _state = CallManagerState.connecting;
+ _safeNotify();
+
+ oldCall?.leave();
+
+ _connectCall(callId, version);
+ }
+
+ Future _connectCall(String callId, int version) async {
+ final call = StreamVideo.instance.makeCall(
+ callType: StreamCallType.liveStream(),
+ id: callId,
+ );
+
+ _currentCall = call;
+
+ try {
+ final getResult = await call.getOrCreate();
+ if (_isStale(version, call)) return;
+ if (getResult.isFailure) { _fail(version); return; }
+
+ final joinResult = await call.join(
+ connectOptions: CallConnectOptions(
+ camera: TrackOption.disabled(),
+ microphone: TrackOption.disabled(),
+ ),
+ );
+ if (_isStale(version, call)) return;
+ if (joinResult.isFailure) { _fail(version); return; }
+ } catch (_) {
+ if (_isStale(version, call)) return;
+ _fail(version);
+ return;
+ }
+
+ _state = CallManagerState.connected;
+ _safeNotify();
+ }
+
+ bool _isStale(int version, Call call) {
+ if (version != _version || _disposed) {
+ call.leave();
+ return true;
+ }
+ return false;
+ }
+
+ void _fail(int version) {
+ if (version != _version || _disposed) return;
+ _state = CallManagerState.failure;
+ _safeNotify();
+ }
+
+ void _safeNotify() {
+ if (_disposed) return;
+ notifyListeners();
+ }
+
+ @override
+ void dispose() {
+ _disposed = true;
+ _currentCall?.leave();
+ super.dispose();
+ }
+}
+
+enum CallManagerState { idle, connecting, connected, failure }
+```
+
+### Wiring the `PageView`
+
+```dart
+PageView.builder(
+ controller: _pageController,
+ scrollDirection: Axis.vertical,
+ itemCount: AppConfig.channels.length,
+ onPageChanged: (index) =>
+ _callManager.switchToCall(AppConfig.channels[index].id),
+ itemBuilder: (context, index) => _LivestreamPage(
+ channel: AppConfig.channels[index],
+ callManager: _callManager,
+ ),
+)
+```
+
+In `initState`, prime the first channel:
+
+```dart
+@override
+void initState() {
+ super.initState();
+ _pageController = PageController();
+ _callManager.switchToCall(AppConfig.channels[0].id);
+}
+```
+
+### Per-page rendering
+
+```dart
+ListenableBuilder(
+ listenable: callManager,
+ builder: (context, _) {
+ final isActive = callManager.currentCallId == channel.id;
+ final isConnected = isActive && callManager.state == CallManagerState.connected;
+ final isConnecting = isActive && callManager.state == CallManagerState.connecting;
+
+ if (isConnected && callManager.currentCall != null) {
+ return _ActiveVideo(call: callManager.currentCall!);
+ }
+ if (isConnecting) return const _ConnectingOverlay();
+ return const _OfflineOverlay();
+ },
+)
+```
+
+A complete reference app lives at
+[`packages/video_livestream_feed/`](https://github.com/GetStream/flutter-video-samples/tree/main/packages/video_livestream_feed)
+in the [flutter-video-samples](https://github.com/GetStream/flutter-video-samples)
+repo.
+
+---
+
+## Critical rules
+
+1. **Viewers join with camera & mic DISABLED.**
+2. **Viewers use `LivestreamPlayer`** — never `StreamCallContent` or
+ `StreamCallContainer`.
+3. **Hosts filter participants by `isVideoEnabled`** so viewer tiles don't
+ show in the broadcast layout.
+4. **Host must call `goLive()`** — `liveStream` and `audio_room` start
+ backstage.
+5. **Always `leave()` the old call before joining a new one.** Only one
+ SFU connection at a time.
+6. **Use a version counter for rapid switching**, not a debounced queue —
+ in-flight connections check staleness after each `await`.
+7. **Always check `.isFailure` on `getOrCreate()` and `join()`** results
+ instead of assuming success.
+8. **Specify `role: 'host'`** in `MemberRequest` for the broadcaster, or
+ they may not have permission to go live.
+
+---
+
+## Common pitfalls
+
+| Mistake | Fix |
+|---|---|
+| Using `StreamCallContent` for the viewer | Use `LivestreamPlayer` — purpose-built for livestream consumption. |
+| Using `StreamCallContainer` in a feed | Use `LivestreamPlayer`; manage join/leave via `CallManager`. |
+| Showing all participants in host view | Filter via `callParticipantsWidgetBuilder` + `isVideoEnabled`. |
+| Filtering by `roles.contains('host')` | Filter by `isVideoEnabled` instead — robust regardless of role assignment. |
+| Joining without leaving the previous call | Always `leave()` first. |
+| Checking `otherParticipants.isNotEmpty` for "live" | Check `otherParticipants.where((p) => p.isVideoEnabled).isNotEmpty`. |
+| Debounced queue for switching | Use a version counter + staleness checks after each `await`. |
+| Skipping `.isFailure` checks | `getOrCreate()` and `join()` return result objects — handle failures. |
+| Re-connecting on every swipe | Skip if `_currentCallId == callId` and not in failure state. |
+| Forgetting `goLive()` | Viewers see nothing until the host goes live. |
+| Missing `role: 'host'` | Host may lack permission to go live. |
+| `notifyListeners()` after dispose | Guard with a `_disposed` flag and a `_safeNotify()` helper. |
+
+---
+
+## Key APIs
+
+| API | Purpose |
+|---|---|
+| `StreamCallType.liveStream()` / `.audioRoom()` | Call type factories |
+| `MemberRequest(userId:, role: 'host')` | Assign host role |
+| `call.goLive()` / `.stopLive()` | Backstage transitions |
+| `call.startHLS()` / `.stopHLS()` | HLS broadcasting |
+| `call.state.value.egress.hlsPlaylistUrl` | HLS playback URL |
+| `LivestreamPlayer(call:, onCallDisconnected:)` | Pre-built viewer |
+| `StreamCallContent` + `callParticipantsWidgetBuilder` | Host broadcast UI |
+| `StreamCallParticipants(call:, participants:)` | Render filtered tile list |
+| `PartialCallStateBuilder` | Reactive state slice |
+| `CallState.callParticipants` / `.otherParticipants` / `.isBackstage` | State |
+| `CallParticipantState.isVideoEnabled` | Filter for active broadcasters |
+| `ToggleMicrophoneOption` / `ToggleCameraOption` / `FlipCameraOption` | Controls |
+
+---
+
+## When to consult docs
+
+If a request goes beyond this skill — recording layouts, transcription,
+viewer-count APIs, RTMP ingest, dashboard call-type configuration, custom
+backstage screens — fetch the relevant page from
+ or the broader
+ documentation tree before
+guessing.
+
+## When to defer
+
+- **Core SDK setup, install, init, permissions, generic calls** →
+ `stream-video-flutter`.
+- **Ringing, push, CallKit** → `stream-video-flutter-ringing`.
+
+## Tone & style
+
+Architectural and code-first. Lead with the right widget choice
+(`LivestreamPlayer` vs `StreamCallContent`) — it determines almost
+everything else. When the user's design conflicts with the SDK's
+opinionated viewer / host split, call it out directly.
diff --git a/.claude/skills/stream-video-flutter-ringing/SKILL.md b/.claude/skills/stream-video-flutter-ringing/SKILL.md
new file mode 100644
index 000000000..f566cba74
--- /dev/null
+++ b/.claude/skills/stream-video-flutter-ringing/SKILL.md
@@ -0,0 +1,478 @@
+---
+name: stream-video-flutter-ringing
+description: |
+ Use this skill whenever the user wants to add ringing calls, push
+ notifications, CallKit, or missed-call handling to a Flutter app using the
+ Stream Video SDK. Covers `stream_video_push_notification` setup, Firebase
+ (Android FCM) and APNs / VoIP (iOS PushKit) configuration, the
+ `ringing: true` call flow, foreground / background notification handling,
+ iOS entitlements, AppDelegate setup (regular APNs + VoIP + foreground
+ display), and the iOS `registerApnDeviceToken` requirement for missed-call
+ pushes. TRIGGER on prompts like: "add ringing to my Flutter app",
+ "incoming / outgoing call screen with Stream", "push notifications for
+ Stream Video", "set up CallKit / VoIP push", "FCM background handler for
+ ringing", "missed call notification", "why are call.missed pushes not
+ arriving on iOS", "configure APNs / Firebase for Stream Video". Assumes
+ the user has already integrated the core SDK — for installation,
+ initialization, and call basics, defer to `stream-video-flutter`. For
+ livestreaming, use `stream-video-flutter-livestream`.
+---
+
+# Stream Video Flutter SDK — Ringing & Push Notifications
+
+You are helping wire up ringing calls and push notifications using
+`stream_video_push_notification`. This skill assumes the core SDK is set up
+— see `stream-video-flutter` for installation and initialization.
+
+> **Authoritative documentation:**
+> - Push notifications:
+> - CallKit:
+> - General Flutter docs:
+>
+> Consult these for anything not covered here (provider name registration in
+> the Stream Dashboard, custom notification UI, alternate FCM clients,
+> deeplinks, Firebase project setup details).
+
+---
+
+## Before you write any code — gather these
+
+Push setup has many moving parts. Establish all of these first:
+
+1. **Firebase project & `google-services.json`** for Android (FCM).
+2. **APNs key + certificate**, **APNs auth key** uploaded to Stream
+ Dashboard for iOS, with a registered provider name (the string you'll
+ pass as `iosPushProvider.name`).
+3. **Provider names** registered in the Stream Dashboard for both
+ `apn` and `firebase`.
+4. **Apple Developer account** with an App ID that has Push Notifications
+ capability enabled (and ideally Voice over IP for VoIP push).
+5. **Where stored credentials live** — for the background handler you need
+ to reload `apiKey`, `userId`, `userToken` from secure storage without a
+ live app session.
+
+---
+
+## Packages
+
+```yaml
+dependencies:
+ stream_video_flutter: ^1.2.4
+ stream_video_push_notification: ^1.2.4
+ # plus your Firebase plumbing on Android:
+ firebase_core: ^...
+ firebase_messaging: ^...
+```
+
+```dart
+import 'package:stream_video_flutter/stream_video_flutter.dart';
+import 'package:stream_video_push_notification/stream_video_push_notification.dart';
+```
+
+---
+
+## Initialize the client with push manager
+
+```dart
+StreamVideo(
+ 'API_KEY',
+ user: user,
+ userToken: token,
+ options: const StreamVideoOptions(
+ keepConnectionsAliveWhenInBackground: true,
+ ),
+ pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
+ iosPushProvider: const StreamVideoPushProvider.apn(
+ name: 'your-apn-provider-name',
+ ),
+ androidPushProvider: const StreamVideoPushProvider.firebase(
+ name: 'your-firebase-provider-name',
+ ),
+ registerApnDeviceToken: true, // critical on iOS — see "Missed call" section
+ ),
+)..connect();
+```
+
+`keepConnectionsAliveWhenInBackground: true` is what lets the SDK stay
+reachable for ringing while the app is suspended.
+
+> **iOS-only critical:** if you support `call.missed` notifications, you
+> **must** pass `registerApnDeviceToken: true` AND request the
+> `Permission.notification` permission **before** constructing
+> `StreamVideo(...)` — the SDK does a one-shot APNs token fetch during
+> `connect()`. See [Missed-call setup](#missed-call-notifications) below.
+
+---
+
+## Make a ringing call
+
+```dart
+final call = StreamVideo.instance.makeCall(
+ callType: StreamCallType.defaultType(),
+ id: const Uuid().v4(),
+);
+
+await call.getOrCreate(
+ memberIds: ['recipient-user-id'],
+ ringing: true,
+ video: true, // false for audio-only
+);
+```
+
+The `ringing: true` flag triggers the push to all members — the SDK
+delivers an incoming-call screen (via CallKit on iOS) on the recipient's
+device.
+
+## Observing ringing events
+
+```dart
+StreamVideo.instance.observeCoreRingingEvents(
+ onCallAccepted: (call) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(builder: (_) => CallScreen(call: call)),
+ );
+ },
+);
+```
+
+Use this in your home / shell route to navigate to the call screen when the
+recipient accepts.
+
+---
+
+## Android — background handler
+
+FCM data messages arrive via the Firebase background handler. Initialize a
+**separate** `StreamVideo` via `StreamVideo.create(...)` (not the singleton
+constructor) and dispose it after handling:
+
+```dart
+@pragma('vm:entry-point')
+Future firebaseMessagingBackgroundHandler(RemoteMessage message) async {
+ await Firebase.initializeApp(
+ options: DefaultFirebaseOptions.currentPlatform,
+ );
+
+ final sv = StreamVideo.create(
+ 'API_KEY',
+ user: storedUser,
+ userToken: storedToken,
+ options: const StreamVideoOptions(
+ keepConnectionsAliveWhenInBackground: true,
+ ),
+ pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
+ iosPushProvider: const StreamVideoPushProvider.apn(name: '...'),
+ androidPushProvider: const StreamVideoPushProvider.firebase(name: '...'),
+ registerApnDeviceToken: true, // keep parity with foreground init
+ ),
+ )..connect();
+
+ sv.disposeAfterResolvingRinging();
+ await sv.handleRingingFlowNotifications(message.data);
+}
+```
+
+Register it in `main()`:
+
+```dart
+FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
+```
+
+For foreground FCM messages, also call
+`handleRingingFlowNotifications(message.data)` from your
+`FirebaseMessaging.onMessage` listener (using the singleton instance).
+
+`handleRingingFlowNotifications()` knows the difference between `call.ring`
+and `call.missed` types and dispatches accordingly when
+`handleMissedCall: true` (the default).
+
+---
+
+## iOS — `AppDelegate.swift`
+
+You must register for **both** VoIP push (PushKit) **and** regular APNs.
+You must also explicitly tell iOS to display regular pushes in the
+foreground, otherwise missed-call alerts are silently dropped while the app
+is active.
+
+```swift
+import UIKit
+import Flutter
+import stream_video_push_notification
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+
+ // VoIP push (PushKit) — for incoming-call ringing
+ StreamVideoPKDelegateManager.shared.registerForPushNotifications()
+
+ // Regular APNs — required so the SDK can obtain an APNs device
+ // token for `call.missed` and other non-VoIP pushes. Without this
+ // call, FirebaseMessaging.instance.getAPNSToken() returns nil and
+ // the backend never learns about this device.
+ application.registerForRemoteNotifications()
+
+ // Show regular APNs notifications while the app is in the foreground
+ UNUserNotificationCenter.current().delegate = self
+
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+
+ override func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ let streamDict = notification.request.content.userInfo["stream"] as? [String: Any]
+ if streamDict?["sender"] as? String != "stream.video" {
+ return completionHandler([])
+ }
+ if #available(iOS 14.0, *) {
+ completionHandler([.list, .banner, .sound])
+ } else {
+ completionHandler([.alert])
+ }
+ }
+}
+```
+
+---
+
+## iOS — entitlements
+
+New Flutter projects do **not** create entitlements files. Without the
+`aps-environment` entitlement, iOS silently refuses to deliver VoIP and
+regular pushes.
+
+Either:
+
+**Recommended — let Xcode do it:** open `ios/Runner.xcworkspace` → Runner
+target → **Signing & Capabilities** → **+ Capability** → **Push
+Notifications**. Xcode generates the entitlements file and updates
+`project.pbxproj`.
+
+**Manual fallback:**
+
+1. `ios/Runner/Runner.entitlements` (Release & Profile):
+
+```xml
+
+
+
+
+ aps-environment
+ development
+
+
+```
+
+2. `ios/Runner/RunnerDebug.entitlements` (Debug) — same content.
+3. In each of the three Runner build configurations in `project.pbxproj`,
+ add inside `buildSettings`:
+
+```
+// Debug
+CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
+// Release & Profile
+CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
+```
+
+`UIBackgroundModes` in `Info.plist` must include both `voip` and
+`remote-notification` (the core skill `stream-video-flutter` already
+covers the full list).
+
+---
+
+## Android — extras
+
+```xml
+
+
+```
+
+On Android 13+ you must also request `Permission.notification` at
+runtime before / during init.
+
+The default `AndroidPushConfiguration` already enables missed-call
+notifications with sensible defaults. To customize:
+
+```dart
+StreamVideoPushNotificationManager.create(
+ iosPushProvider: ...,
+ androidPushProvider: ...,
+ pushConfiguration: const StreamVideoPushConfiguration(
+ android: AndroidPushConfiguration(
+ missedCallNotification: MissedCallNotificationParams(
+ showNotification: true,
+ subtitle: 'You missed a call',
+ callbackText: 'Call back',
+ showCallbackButton: true,
+ ),
+ ),
+ ),
+)
+```
+
+---
+
+## Missed-call notifications
+
+When a ringing call goes unanswered, Stream sends `call.missed` — a
+**regular push notification**, not VoIP. iOS treats this very differently
+from VoIP push, which is why the iOS setup has extra requirements.
+
+### How it works
+
+1. User A calls User B with `ringing: true`.
+2. User B doesn't answer (or User A cancels).
+3. Stream backend sends `call.missed` via regular APNs (iOS) or FCM data
+ message (Android).
+4. The SDK's `handleRingingFlowNotifications()` detects
+ `type == 'call.missed'` and calls `showMissedCall()`.
+
+### Android
+
+`call.missed` is a normal FCM data message — same channel as `call.ring`.
+`handleRingingFlowNotifications()` handles it automatically when
+`handleMissedCall: true` (the default). No extra Android-side setup beyond
+the standard ringing config.
+
+### iOS — three things must all be true
+
+1. **`registerApnDeviceToken: true`** in
+ `StreamVideoPushNotificationManager.create()`. By default the SDK only
+ registers the **VoIP** token; without this flag the backend has no APNs
+ token and cannot deliver `call.missed`.
+
+2. **`application.registerForRemoteNotifications()` in AppDelegate.**
+ `StreamVideoPKDelegateManager.shared.registerForPushNotifications()`
+ only handles VoIP via PushKit. The regular APNs token comes from
+ `registerForRemoteNotifications()`. Without it,
+ `FirebaseMessaging.instance.getAPNSToken()` returns `nil` and the SDK
+ cannot send the token to the Stream backend.
+
+3. **Permission requested BEFORE `StreamVideo(...)` is constructed.**
+ `registerDevice()` runs once during `connect()` and does a **one-shot**
+ `getAPNSToken()` — there is no retry or listener. If notification
+ permissions are requested after init, the token is `nil` at registration
+ time and the device is never registered for regular pushes.
+
+```dart
+// CORRECT — permissions first
+await [
+ Permission.camera,
+ Permission.microphone,
+ Permission.notification, // triggers registerForRemoteNotifications on iOS
+ if (CurrentPlatform.isAndroid) Permission.phone,
+].request();
+
+final streamVideo = StreamVideo(
+ apiKey,
+ user: user,
+ userToken: token,
+ pushNotificationManagerProvider: StreamVideoPushNotificationManager.create(
+ iosPushProvider: const StreamVideoPushProvider.apn(name: '...'),
+ androidPushProvider: const StreamVideoPushProvider.firebase(name: '...'),
+ registerApnDeviceToken: true,
+ ),
+);
+
+// WRONG — token is nil at registration time
+final streamVideo = StreamVideo(apiKey, user: user, userToken: token, ...);
+await Permission.notification.request(); // too late
+```
+
+The `application.registerForRemoteNotifications()` call in `AppDelegate`
+acts as a safety net (it can produce a token without the user permission
+prompt), but the Flutter-side permission request should still come first.
+
+**Apply the same `registerApnDeviceToken: true`** in the background
+handler's `StreamVideo.create(...)`.
+
+---
+
+## Summary checklist
+
+| Requirement | Android | iOS |
+|---|---|---|
+| `keepConnectionsAliveWhenInBackground: true` | yes | yes |
+| `handleRingingFlowNotifications(message.data)` in foreground FCM listener | yes | n/a (APNs) |
+| Same call in background FCM handler | yes | n/a |
+| `StreamVideo.create(...)` (not singleton) in background handler | yes | n/a |
+| `registerApnDeviceToken: true` | n/a | **yes — critical** |
+| `application.registerForRemoteNotifications()` in `AppDelegate` | n/a | **yes — critical** |
+| Request `Permission.notification` **before** `StreamVideo(...)` | n/a | **yes — critical** |
+| `UNUserNotificationCenter.current().delegate = self` | n/a | yes (foreground display) |
+| `willPresent` delegate | n/a | yes (foreground display) |
+| Push entitlements (`aps-environment`) | n/a | yes |
+| `POST_NOTIFICATIONS` runtime permission | yes (Android 13+) | n/a |
+| `UIBackgroundModes`: `voip`, `remote-notification` | n/a | yes |
+| Provider names registered in Stream Dashboard | yes | yes |
+
+---
+
+## Common pitfalls
+
+| Mistake | Fix |
+|---|---|
+| Calling `StreamVideo(...)` (singleton) in the background handler | Use `StreamVideo.create(...)`; it does not replace the foreground singleton. |
+| Hardcoding tokens in the background handler | Reload `apiKey`, `userId`, `userToken` from secure storage. |
+| `call.missed` not arriving on iOS | Set `registerApnDeviceToken: true`, call `registerForRemoteNotifications()`, request notification permission **before** init. |
+| Missed-call pushes silent in foreground on iOS | Set `UNUserNotificationCenter.current().delegate = self` and implement `willPresent`. |
+| `getAPNSToken()` returns nil | Permission requested after `StreamVideo(...)` was created — always permissions first. |
+| Forgetting `aps-environment` entitlement | Add via Xcode capability or manually in `Runner.entitlements` + `project.pbxproj`. |
+| iOS build error about iOS 13.0 vs `stream_video_push_notification` 14.0 | Update all three build configurations in `project.pbxproj` to `IPHONEOS_DEPLOYMENT_TARGET = 14.0`. |
+| Skipping `keepConnectionsAliveWhenInBackground: true` | Calls drop while suspended — required for ringing. |
+| Not calling `disposeAfterResolvingRinging()` in the background handler | Connection leaks across pushes. |
+| Custom FCM channel without `handleRingingFlowNotifications` | The SDK won't process the payload; ringing screens never appear. |
+
+---
+
+## Key APIs
+
+| API | Purpose |
+|---|---|
+| `StreamVideoPushNotificationManager.create(...)` | Builds the push manager (provider names + flags). |
+| `StreamVideoPushProvider.apn(name:)` / `.firebase(name:)` | Provider configs (names match Stream Dashboard). |
+| `registerApnDeviceToken: true` | Enables regular APNs token registration on iOS (required for `call.missed`). |
+| `StreamVideoOptions(keepConnectionsAliveWhenInBackground: true)` | Keeps the connection alive for ringing. |
+| `call.getOrCreate(memberIds:, ringing: true, video:)` | Creates and rings a call. |
+| `StreamVideo.instance.observeCoreRingingEvents(onCallAccepted:)` | Hook into accept events. |
+| `handleRingingFlowNotifications(message.data)` | Process FCM data payloads (foreground & background). |
+| `disposeAfterResolvingRinging()` | Auto-dispose background-handler client. |
+| `StreamVideo.create(...)` | Auxiliary client for background isolates. |
+| `StreamVideoPKDelegateManager.shared.registerForPushNotifications()` | iOS VoIP / PushKit registration. |
+| `application.registerForRemoteNotifications()` | iOS regular APNs registration (for `call.missed`). |
+| `UNUserNotificationCenter.willPresent` | Show regular pushes in foreground. |
+| `MissedCallNotificationParams` / `AndroidPushConfiguration` | Customize Android missed-call UX. |
+
+---
+
+## When to consult docs
+
+For provider name registration in the Stream Dashboard, RTMP push,
+deeplinking from the notification into a specific screen, custom
+notification content, or alternate push backends — fetch the relevant page
+from
+and
+before guessing.
+
+## When to defer
+
+- **Core SDK setup, install, init, generic calling** → `stream-video-flutter`.
+- **Livestreaming, audio rooms, HLS, feed UI** → `stream-video-flutter-livestream`.
+
+## Tone & style
+
+Diagnostic and checklist-driven. Push setup fails silently when one piece
+is missing, so when the user reports "ringing doesn't work" or "missed call
+isn't showing on iOS", walk the [Summary checklist](#summary-checklist) row
+by row before suggesting code changes. Always name **which** of the three
+iOS missed-call requirements is most likely missing rather than dumping all
+of them again.
diff --git a/.claude/skills/stream-video-flutter/SKILL.md b/.claude/skills/stream-video-flutter/SKILL.md
new file mode 100644
index 000000000..09a0aac20
--- /dev/null
+++ b/.claude/skills/stream-video-flutter/SKILL.md
@@ -0,0 +1,598 @@
+---
+name: stream-video-flutter
+description: |
+ Use this skill whenever the user wants to build, scaffold, or extend a Flutter
+ app that uses the Stream Video SDK — including 1:1 / group video calling,
+ audio-only calls, joining or creating calls, rendering call UI, and
+ customizing call controls or theming. Covers initial setup (pubspec, iOS /
+ Android permissions, deployment targets), client initialization, call
+ lifecycle (`makeCall` → `getOrCreate` → `join` → `leave` / `end`), the
+ pre-built widgets (`StreamCallContainer`, `StreamCallContent`,
+ `StreamCallParticipants`, `PartialCallStateBuilder`), and common pitfalls.
+ TRIGGER on prompts like: "add video calling to my Flutter app", "build a
+ Flutter app with Stream Video", "integrate Stream Video SDK", "add audio /
+ video to an existing Flutter project", "create a call screen with Stream",
+ "set up `stream_video_flutter`", "show participants / mute mic / flip
+ camera", "customize Stream call UI / theme", "join a call by ID". For
+ livestreaming or audio rooms, defer to `stream-video-flutter-livestream`.
+ For ringing / push notifications / CallKit, defer to
+ `stream-video-flutter-ringing`.
+---
+
+# Stream Video Flutter SDK — Setup & Core Calling
+
+You are helping integrate the **Stream Video Flutter SDK** into a Flutter
+application. This skill covers everything needed to ship a working 1:1, group,
+or audio-only call. For livestreaming, audio rooms, or ringing flows, defer
+to the dedicated sibling skills.
+
+> **Versions assumed:** `stream_video_flutter ^1.2.x` · Dart ≥ 3.8 ·
+> Flutter ≥ 3.32 · iOS ≥ 14 · Android `minSdk 24`. Always check the latest
+> version on [pub.dev](https://pub.dev/packages/stream_video_flutter).
+
+> **Authoritative documentation:**
+> — consult this before guessing
+> any API surface that is not in this skill (UI cookbook, advanced APIs,
+> dashboard configuration, server-side token issuance, etc.).
+
+---
+
+## Before you write any code
+
+1. **Confirm the call type**. The SDK supports `default` (video / audio
+ calls), `livestream`, `audio_room`, `development`, and custom types. If the
+ user wants a livestream or audio room, switch to
+ `stream-video-flutter-livestream`. If they want incoming-call ringing or
+ CallKit, switch to `stream-video-flutter-ringing`.
+2. **Confirm credentials**. The SDK needs an `apiKey`, a `userId`, and a
+ `userToken` (JWT). Ask the user where the token comes from — for production
+ it must come from their backend via `tokenLoader`, never hardcoded.
+3. **Confirm target platforms**. iOS needs deployment target 14.0 and the
+ permission keys in `Info.plist`. Android needs `minSdk 24` and the
+ manifest permissions.
+
+---
+
+## Packages
+
+| Package | Purpose |
+|---|---|
+| `stream_video_flutter` | **Primary.** Pre-built UI widgets + re-exports `stream_video`. Use this for almost every app. |
+| `stream_video` | Low-level client only. Use directly only when building entirely custom UI. |
+| `stream_video_push_notification` | Push / CallKit / ringing. See `stream-video-flutter-ringing`. |
+| `stream_video_noise_cancellation` | Optional AI noise cancellation. |
+
+**Import rule:** only import `stream_video_flutter` — it re-exports everything
+from `stream_video`, so importing both is a mistake.
+
+```dart
+import 'package:stream_video_flutter/stream_video_flutter.dart';
+```
+
+### `pubspec.yaml`
+
+```yaml
+dependencies:
+ flutter:
+ sdk: flutter
+ stream_video_flutter: ^1.2.4
+```
+
+---
+
+## Platform setup
+
+### iOS — `ios/Podfile`
+
+```ruby
+platform :ios, '14.0'
+```
+
+New Flutter projects default `IPHONEOS_DEPLOYMENT_TARGET` to `13.0` in
+`ios/Runner.xcodeproj/project.pbxproj`. Update **all three** build
+configurations (Debug, Release, Profile) to `14.0`, otherwise the build
+fails with: *"Compiling for iOS 13.0, but module
+'stream_video_push_notification' has a minimum deployment target of iOS 14"*.
+
+### iOS — `ios/Runner/Info.plist`
+
+```xml
+NSCameraUsageDescription
+$(PRODUCT_NAME) Camera Usage
+NSMicrophoneUsageDescription
+$(PRODUCT_NAME) Microphone Usage
+UIBackgroundModes
+
+ audio
+ fetch
+ processing
+ remote-notification
+ voip
+
+```
+
+### Android — `android/app/src/main/AndroidManifest.xml`
+
+```xml
+
+
+
+
+
+
+
+
+
+
+```
+
+Set `minSdkVersion 24` in `android/app/build.gradle` (already the Flutter
+default in recent versions).
+
+---
+
+## Initialization
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:stream_video_flutter/stream_video_flutter.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+
+ // Creates the singleton AND auto-connects to Stream's backend.
+ StreamVideo(
+ 'YOUR_API_KEY',
+ user: User.regular(userId: 'user-id', name: 'Jane Doe'),
+ userToken: 'USER_JWT_TOKEN',
+ );
+
+ runApp(const MyApp());
+}
+```
+
+### Canonical initialization rules
+
+1. **`WidgetsFlutterBinding.ensureInitialized()`** must run before
+ `StreamVideo(...)` in `main()`.
+2. **`StreamVideo(...)` is a singleton.** Access it anywhere with
+ `StreamVideo.instance`. Do **not** wrap it in a provider / GetIt / Riverpod
+ for storage — just use the singleton.
+3. **Auto-connect is on by default.** Disable with
+ `options: StreamVideoOptions(autoConnect: false)` and call
+ `StreamVideo.instance.connect()` later.
+4. **Provide either `userToken` (JWT string) or `tokenLoader` (async
+ callback).** Never both. Use `tokenLoader` in production so tokens can be
+ refreshed.
+5. **One singleton at a time.** Calling `StreamVideo(...)` while one exists
+ throws. To switch users: `await StreamVideo.reset(disconnect: true)` first,
+ or pass `failIfSingletonExists: false`.
+6. **For background isolates** (e.g. push handlers) use `StreamVideo.create(...)`
+ — it does not replace the main singleton.
+
+### `StreamVideoOptions` — notable fields
+
+```dart
+StreamVideoOptions(
+ autoConnect: true,
+ keepConnectionsAliveWhenInBackground: false, // set true for ringing apps
+ muteVideoWhenInBackground: false,
+ muteAudioWhenInBackground: false,
+ logPriority: Priority.none, // Priority.debug for dev
+)
+```
+
+---
+
+## Smallest working call (copy-pasteable)
+
+```dart
+import 'package:flutter/material.dart';
+import 'package:stream_video_flutter/stream_video_flutter.dart';
+
+void main() async {
+ WidgetsFlutterBinding.ensureInitialized();
+
+ StreamVideo(
+ 'YOUR_API_KEY',
+ user: User.regular(userId: 'john', name: 'John Doe'),
+ userToken: 'USER_JWT',
+ );
+
+ runApp(const MaterialApp(home: HomeScreen()));
+}
+
+class HomeScreen extends StatelessWidget {
+ const HomeScreen({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ body: Center(
+ child: ElevatedButton(
+ child: const Text('Join Call'),
+ onPressed: () async {
+ final call = StreamVideo.instance.makeCall(
+ callType: StreamCallType.defaultType(),
+ id: 'my-call-123',
+ );
+ await call.getOrCreate();
+
+ if (context.mounted) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (_) => Scaffold(
+ body: StreamCallContainer(call: call),
+ ),
+ ),
+ );
+ }
+ },
+ ),
+ ),
+ );
+ }
+}
+```
+
+`StreamCallContainer` handles the full lifecycle: lobby → outgoing → incoming
+→ active → disconnected. It calls `join()` for you.
+
+---
+
+## The Call lifecycle
+
+```
+makeCall() → getOrCreate() → join() → [active call] → leave() / end()
+```
+
+| Step | What it does |
+|---|---|
+| `StreamVideo.instance.makeCall(callType:, id:)` | Creates a local `Call` object — no network. |
+| `call.getOrCreate(...)` | Creates the call on Stream servers (or returns existing). |
+| `call.join(...)` | Connects WebRTC, publishes/subscribes to tracks. |
+| `call.leave()` | Disconnects this user; call continues for others. |
+| `call.end()` | Terminates the call for everyone. |
+
+**Critical rules:**
+
+- If you use `StreamCallContainer`, do **not** call `call.join()` yourself —
+ it joins automatically. Only call `getOrCreate()` before navigating.
+- If you build fully custom UI without `StreamCallContainer`, you must call
+ both `getOrCreate()` and `join()`.
+- Always call `call.leave()` when the user leaves the call screen.
+
+### Call types
+
+| Factory | String | Use case |
+|---|---|---|
+| `StreamCallType.defaultType()` | `default` | Video / audio calls (1:1 or group) |
+| `StreamCallType.liveStream()` | `livestream` | Livestreaming (see livestream skill) |
+| `StreamCallType.audioRoom()` | `audio_room` | Twitter Spaces / Clubhouse-style |
+| `StreamCallType.development()` | `development` | Testing |
+| `StreamCallType.custom('my_type')` | custom | Custom dashboard type |
+
+Configure call-type permissions and features in the [Stream Dashboard](https://dashboard.getstream.io/).
+
+---
+
+## Creating & joining calls
+
+### Standard call
+
+```dart
+final call = StreamVideo.instance.makeCall(
+ callType: StreamCallType.defaultType(),
+ id: 'unique-call-id',
+);
+await call.getOrCreate();
+// Navigate to a screen with StreamCallContainer(call: call)
+```
+
+### With pre-invited members
+
+```dart
+await call.getOrCreate(memberIds: ['alice', 'bob']);
+```
+
+### Custom track options
+
+```dart
+await call.join(
+ connectOptions: CallConnectOptions(
+ camera: TrackOption.enabled(),
+ microphone: TrackOption.disabled(),
+ ),
+);
+```
+
+Or pass them to `StreamCallContainer`:
+
+```dart
+StreamCallContainer(
+ call: call,
+ callConnectOptions: CallConnectOptions(
+ camera: TrackOption.enabled(),
+ microphone: TrackOption.disabled(),
+ ),
+)
+```
+
+### `TrackOption` values
+
+| Value | Meaning |
+|---|---|
+| `TrackOption.enabled()` | Start the track on join |
+| `TrackOption.disabled()` | Keep the track off on join |
+| `TrackOption.provided(localTrack)` | Use a pre-created local track |
+
+---
+
+## Pre-built widgets
+
+### `StreamCallContainer` — all-in-one
+
+Manages lobby → outgoing → incoming → active → disconnected.
+
+```dart
+Scaffold(
+ body: StreamCallContainer(
+ call: call,
+ callConnectOptions: CallConnectOptions(
+ camera: TrackOption.enabled(),
+ microphone: TrackOption.enabled(),
+ ),
+ onCallDisconnected: (_) => Navigator.pop(context),
+ ),
+)
+```
+
+### `StreamCallContent` — active call only
+
+For finer control. Renders app bar + participants grid + controls.
+
+```dart
+StreamCallContent(
+ call: call,
+ layoutMode: ParticipantLayoutMode.grid, // or .spotlight
+ callAppBarWidgetBuilder: (context, call) => CallAppBar(call: call),
+ callParticipantsWidgetBuilder: (context, call) =>
+ StreamCallParticipants(call: call),
+ callControlsWidgetBuilder: (context, call) =>
+ StreamCallControls.withDefaultOptions(call: call),
+)
+```
+
+### `StreamCallParticipants` / `StreamCallParticipant`
+
+Render participant tiles (grid or spotlight) or a single tile.
+
+---
+
+## Reactive state — `PartialCallStateBuilder`
+
+`call.state` is a `StateEmitter`. **Always prefer
+`PartialCallStateBuilder`** over raw `StreamBuilder` on
+`call.state.valueStream` — it rebuilds only when the selected slice changes.
+
+```dart
+PartialCallStateBuilder(
+ call: call,
+ selector: (state) => state.callParticipants.length,
+ builder: (context, count) => Text('$count participants'),
+)
+```
+
+### Useful `CallState` properties
+
+| Property | Type | Description |
+|---|---|---|
+| `callParticipants` | `List` | All participants |
+| `localParticipant` | `CallParticipantState?` | Local user |
+| `otherParticipants` | `List` | Remote |
+| `activeSpeakers` | `List` | Currently speaking |
+| `isBackstage` | `bool` | Backstage mode active |
+| `isRecording` | `bool` | Recording in progress |
+| `isBroadcasting` | `bool` | HLS broadcasting active |
+| `status` | `CallStatus` | Connection status |
+| `createdByMe` | `bool` | Current user created the call |
+| `callId` | `String` | The call ID |
+| `startsAt` / `endedAt` | `DateTime?` | Schedule / end time |
+
+---
+
+## Call controls
+
+Pre-built option widgets — drop them into `StreamCallControls.options`:
+
+| Widget | Purpose |
+|---|---|
+| `ToggleMicrophoneOption` | Mute / unmute microphone |
+| `ToggleCameraOption` | Enable / disable camera |
+| `FlipCameraOption` | Switch front / rear camera |
+| `ToggleScreenShareOption` | Start / stop screen sharing |
+| `ToggleSpeakerphoneOption` | Toggle speakerphone |
+| `LeaveCallOption` | Leave the call |
+| `AddReactionOption` | Send emoji reactions |
+| `ToggleRecordingOption` | Start / stop recording |
+| `ToggleLayoutOption` | Switch grid / spotlight |
+| `ToggleClosedCaptionsOption` | Toggle closed captions |
+| `CallControlOption` | Generic custom button |
+
+Custom set:
+
+```dart
+StreamCallControls(
+ options: [
+ ToggleMicrophoneOption(call: call),
+ ToggleCameraOption(call: call),
+ FlipCameraOption(call: call),
+ LeaveCallOption(
+ call: call,
+ onLeaveCallTap: () => call.leave(),
+ ),
+ ],
+)
+```
+
+### Programmatic control
+
+```dart
+await call.setMicrophoneEnabled(enabled: false);
+await call.setCameraEnabled(enabled: true);
+await call.setScreenShareEnabled(enabled: true);
+```
+
+---
+
+## Screen sharing, recording, broadcasting
+
+```dart
+ToggleScreenShareOption(
+ call: call,
+ screenShareConstraints: const ScreenShareConstraints(
+ useiOSBroadcastExtension: true,
+ captureScreenAudio: true,
+ ),
+)
+
+await call.startRecording();
+await call.stopRecording();
+final recordings = await call.listRecordings();
+
+await call.startHLS();
+final hlsUrl = call.state.value.egress.hlsPlaylistUrl;
+```
+
+For HLS-driven livestream viewers, see `stream-video-flutter-livestream`.
+
+---
+
+## Permissions & moderation
+
+```dart
+call.hasPermission(CallPermission.sendAudio);
+
+await call.requestPermissions([CallPermission.sendAudio]); // listener
+await call.grantPermissions( // host
+ userId: 'user-id',
+ permissions: [CallPermission.sendAudio],
+);
+
+call.onPermissionRequest = (request) { /* request.user, request.permissions */ };
+
+await call.muteUsers(userIds: ['user-id'], track: TrackType.audio);
+await call.muteAllUsers();
+await call.blockUser('user-id');
+await call.unblockUser('user-id');
+```
+
+---
+
+## Theming
+
+```dart
+MaterialApp(
+ builder: (context, child) {
+ return StreamVideoTheme(
+ data: StreamVideoThemeData.fromColorScheme(
+ colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
+ ),
+ child: child!,
+ );
+ },
+ home: const HomeScreen(),
+)
+```
+
+Component-level theming is documented in the [UI Cookbook](https://getstream.io/video/docs/flutter/ui-cookbook/overview/).
+
+---
+
+## Cleanup & logout
+
+```dart
+await call.leave(); // Leave current call
+await StreamVideo.instance.disconnect(); // Disconnect from backend
+await StreamVideo.reset(); // Destroy singleton (required before re-init for a new user)
+```
+
+---
+
+## Common pitfalls
+
+| Mistake | Fix |
+|---|---|
+| Calling `call.join()` AND using `StreamCallContainer` | The container joins automatically — only call `getOrCreate()` first. |
+| Creating multiple `StreamVideo(...)` singletons | `await StreamVideo.reset(disconnect: true)` first, or `failIfSingletonExists: false`. |
+| Importing both `stream_video` and `stream_video_flutter` | Only import `stream_video_flutter`. |
+| Forgetting `WidgetsFlutterBinding.ensureInitialized()` | Must run before `StreamVideo(...)`. |
+| Using `StreamBuilder` on `call.state.valueStream` | Use `PartialCallStateBuilder` with a `selector`. |
+| Not calling `call.getOrCreate()` before `join()` | Call must exist on the server first. |
+| Audio rooms invisible to listeners | Call `call.goLive()` after joining (`audio_room` and `livestream` start in backstage). |
+| Using `StreamCallContent` for a livestream viewer | Use `LivestreamPlayer` — see `stream-video-flutter-livestream`. |
+| Hardcoded tokens in production | Use `tokenLoader` (async callback that fetches a fresh JWT). |
+| Not disposing calls | Always call `call.leave()` when leaving the call screen. |
+
+---
+
+## Quick reference — key classes
+
+| Class / Function | Package | Purpose |
+|---|---|---|
+| `StreamVideo(apiKey, user:, userToken:)` | `stream_video` | Singleton constructor |
+| `StreamVideo.instance` | `stream_video` | Access singleton |
+| `StreamVideo.reset()` | `stream_video` | Destroy singleton |
+| `StreamVideo.instance.makeCall(callType:, id:)` | `stream_video` | Create local Call |
+| `Call.getOrCreate(...)` | `stream_video` | Create / fetch call |
+| `Call.join(connectOptions:)` | `stream_video` | Connect WebRTC |
+| `Call.leave()` / `.end()` / `.goLive()` / `.stopLive()` | `stream_video` | Lifecycle |
+| `Call.state` | `stream_video` | Reactive `CallState` |
+| `CallConnectOptions` | `stream_video` | Camera / mic / screen config |
+| `TrackOption.enabled()` / `.disabled()` / `.provided(...)` | `stream_video` | Track config |
+| `StreamCallType.defaultType()` | `stream_video` | Call type factory |
+| `User.regular(userId:, name:)` | `stream_video` | Authenticated user |
+| `StreamCallContainer` | `stream_video_flutter` | All-in-one call UI |
+| `StreamCallContent` | `stream_video_flutter` | Active call content |
+| `StreamCallParticipants` / `StreamCallParticipant` | `stream_video_flutter` | Participant tiles |
+| `StreamCallControls` | `stream_video_flutter` | Controls bar |
+| `PartialCallStateBuilder` | `stream_video_flutter` | Reactive state builder |
+| `StreamVideoTheme` | `stream_video_flutter` | Theme provider |
+| `LivestreamPlayer` | `stream_video_flutter` | Livestream viewer (see livestream skill) |
+| `MemberRequest` / `CallPermission` | `stream_video` | Members & permissions |
+
+---
+
+## When to defer to other skills
+
+- **Livestreaming, audio rooms, HLS viewer, TikTok-style feed** →
+ `stream-video-flutter-livestream`.
+- **Ringing, push notifications, CallKit, missed-call handling** →
+ `stream-video-flutter-ringing`.
+- **AI voice agents** → see [AI Voice Agents docs](https://getstream.io/video/docs/api/voice-agents/).
+
+## When to consult the docs site
+
+If a question is **not covered by this skill** — e.g. SFU configuration,
+custom UI cookbook recipes, advanced theming, dashboard setup, server-side
+SDKs, dynascale / simulcast tuning, recording layouts, transcription, custom
+events — fetch the relevant page from
+ instead of guessing. Useful entry
+points:
+
+- Tutorials:
+- UI Cookbook:
+- Core concepts:
+- API reference:
+- pub.dev:
+- GitHub:
+- Dashboard:
+
+## Tone & style
+
+Concise, code-first answers. Prefer pre-built widgets over custom builds
+unless the user explicitly asks for full customization. Always cite the
+exact widget / method name. When the user's setup is incomplete, point at
+the **specific** missing step (Podfile target, manifest permission, token
+source) rather than dumping the full setup again.
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..74da8572b
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,116 @@
+# AGENTS.md — Stream Video Flutter SDK
+
+This file is the entry point for AI coding agents (Claude Code, Cursor,
+Codex, Aider, Copilot, Continue, Cody, Windsurf, …). It follows the
+[agents.md](https://agents.md) convention.
+
+There are two distinct audiences:
+
+1. **Integrators** — agents working in a *user's* Flutter app who need
+ to add Stream Video. Skip to [Integrating the SDK](#integrating-the-sdk).
+2. **Contributors** — agents working *inside this repository* on the SDK
+ itself. Skip to [Contributing to the SDK](#contributing-to-the-sdk).
+
+---
+
+## Integrating the SDK
+
+The canonical guidance lives as **agent skills** (Anthropic Agent Skill
+format — markdown with YAML frontmatter triggers). They are routed by
+trigger phrase; load only the one matching the user's primary intent.
+
+| Track | Skill | Trigger summary |
+|---|---|---|
+| A — Core SDK | [`.claude/skills/stream-video-flutter/SKILL.md`](.claude/skills/stream-video-flutter/SKILL.md) | "add video / audio calling", "integrate Stream Video", "build a call screen", any setup / init / generic call work. |
+| B — Livestreaming | [`.claude/skills/stream-video-flutter-livestream/SKILL.md`](.claude/skills/stream-video-flutter-livestream/SKILL.md) | "add livestreaming", "go live", "watch a stream", "TikTok-style live feed", "audio room / Twitter Spaces". |
+| C — Ringing & Push | [`.claude/skills/stream-video-flutter-ringing/SKILL.md`](.claude/skills/stream-video-flutter-ringing/SKILL.md) | "add ringing", "incoming / outgoing call", "CallKit / VoIP push", "FCM background handler", "call.missed not arriving". |
+
+### Routing rules
+
+1. **One skill at a time.** Pick the skill matching the *primary* intent.
+ Track A is foundational: if SDK setup isn't done yet, run it first,
+ then switch to B or C — installation, permissions, and `StreamVideo`
+ initialization only live in track A.
+2. **Defer when out of scope.** Each skill states explicitly when to
+ defer to a sibling. Honor those pointers.
+3. **Docs fallback.** For anything not in a skill, fetch the relevant
+ page from rather than
+ guessing.
+
+### If your tool doesn't auto-load `SKILL.md`
+
+The same content is published as a standalone pack at
+. Install it
+globally so it's available in every project:
+
+```bash
+git clone https://github.com/GetStream/flutter-video-agent-skills.git \
+ ~/.claude/skills/flutter-video
+```
+
+Or paste the contents of the relevant `SKILL.md` into your tool's
+system prompt / rules file.
+
+### Versions assumed
+
+- `stream_video_flutter ^1.2.x` · Dart ≥ 3.8 · Flutter ≥ 3.32
+- iOS ≥ 14 · Android `minSdk 24`
+
+Always check the latest version on
+[pub.dev](https://pub.dev/packages/stream_video_flutter).
+
+---
+
+## Contributing to the SDK
+
+You are inside the `stream-video-flutter` monorepo. It's a
+[Melos](https://melos.invertase.dev) workspace.
+
+### Layout
+
+- `packages/` — published Dart / Flutter packages (`stream_video`,
+ `stream_video_flutter`, `stream_video_push_notification`,
+ `stream_video_noise_cancellation`, …).
+- `dogfooding/` — full sample app exercising every feature.
+- `docusaurus/` — public documentation site source.
+- `examples/` — smaller targeted samples.
+- `melos.yaml`, `analysis_options.yaml`, `all_lint_rules.yaml` — workspace
+ config.
+
+### Common commands
+
+```bash
+# Bootstrap the workspace after clone or large refactor
+melos postclean
+
+# Run static analysis across all packages
+melos analyze
+
+# Run tests
+melos test
+```
+
+See [`development.md`](development.md) for fuller contributor docs.
+
+### Conventions
+
+- **Conventional Commits** (`feat:`, `fix:`, `chore:`, `feat(llc):`, …) —
+ see recent `git log` for scopes.
+- Public API changes in `packages/stream_video/` and
+ `packages/stream_video_flutter/` need a changelog entry.
+- Don't hand-edit generated protobuf / OpenAPI code under
+ `packages/stream_video/lib/protobuf/` or
+ `packages/stream_video/lib/open_api/` — regenerate via the relevant
+ Melos script.
+
+---
+
+## Companion resources
+
+- **Docs:**
+- **pub.dev:**
+- **Sample apps:**
+- **Tutorials:**
+- **Skill pack (standalone):**
+- **Broader Stream agent pack** (Chat, Feeds, Moderation, CLI):
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..d7a0cce62
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,6 @@
+# CLAUDE.md
+
+See [AGENTS.md](AGENTS.md) for routing, integration skills, and
+contributor conventions. Project-local skills live under
+[`.claude/skills/`](.claude/skills/) and auto-load on matching trigger
+phrases.
diff --git a/llms.txt b/llms.txt
new file mode 100644
index 000000000..2184f643f
--- /dev/null
+++ b/llms.txt
@@ -0,0 +1,30 @@
+# Stream Video Flutter SDK
+
+> Official Flutter / Dart client for [Stream Video](https://getstream.io/video/sdk/flutter/) — 1:1 and group video & audio calling, livestreaming, audio rooms, ringing with CallKit / VoIP push, and AI voice agents. This file follows the [llms.txt](https://llmstxt.org) convention so LLMs can find the canonical entry points without crawling the whole repo.
+
+## Agent entry points
+
+- [AGENTS.md](AGENTS.md): Cross-tool agent guide — routing table for integration skills and contributor conventions.
+- [.claude/skills/stream-video-flutter/SKILL.md](.claude/skills/stream-video-flutter/SKILL.md): Core SDK setup, 1:1 / group video & audio calling, call lifecycle, pre-built UI widgets.
+- [.claude/skills/stream-video-flutter-livestream/SKILL.md](.claude/skills/stream-video-flutter-livestream/SKILL.md): Livestreaming, audio rooms, HLS, TikTok-style live feeds.
+- [.claude/skills/stream-video-flutter-ringing/SKILL.md](.claude/skills/stream-video-flutter-ringing/SKILL.md): Ringing, push notifications, CallKit, VoIP push, missed-call handling.
+
+## Documentation
+
+- [Flutter Video docs](https://getstream.io/video/docs/flutter/): Authoritative documentation — UI cookbook, advanced APIs, dashboard, server-side tokens.
+- [development.md](development.md): Contributor setup for this monorepo.
+- [README.md](README.md): Repository overview and Melos workspace structure.
+
+## Packages on pub.dev
+
+- [stream_video_flutter](https://pub.dev/packages/stream_video_flutter): Primary package — pre-built UI widgets and re-exports `stream_video`.
+- [stream_video](https://pub.dev/packages/stream_video): Low-level client. Use directly only when building custom UI from scratch.
+- [stream_video_push_notification](https://pub.dev/packages/stream_video_push_notification): Push notifications and CallKit integration for ringing.
+- [stream_video_noise_cancellation](https://pub.dev/packages/stream_video_noise_cancellation): Optional AI noise cancellation add-on.
+
+## Related repositories
+
+- [flutter-video-agent-skills](https://github.com/GetStream/flutter-video-agent-skills): Standalone, installable copy of the agent skills used in this repo.
+- [flutter-video-samples](https://github.com/GetStream/flutter-video-samples): Full sample apps (calling, livestream, audio room, ringing).
+- [flutter-video-tutorials](https://github.com/GetStream/flutter-video-tutorials): Step-by-step tutorial repos.
+- [agent-skills](https://github.com/GetStream/agent-skills): Broader Stream agent pack (Chat, Feeds, Moderation, CLI).