This tutorial builds a video and audio calling app with ringing, using the Stream Video Flutter SDK. You'll end up with an app that:
- Makes calls that ring on the recipient's device.
- Works on both Android and iOS.
- Supports both audio and video calls.
- Keeps ringing when the app is backgrounded or closed.
This page works for both humans and AI coding agents: every step is a file operation or a shell command, the steps that produce something you can run end in a verification checkpoint, and the few steps that need a human are explicitly marked.
Before you start. Ringing needs at least two users on separate devices, and a physical device to test the closed-app case, since APNs doesn't deliver to the iOS Simulator. Push notifications are what deliver the ring, so the client is wired for them from the start: you'll need a Firebase project for Android and, for iOS, an Apple Developer account to create an APNs key.
The complete code for this tutorial is in the Flutter Video Tutorials repository. If you'd rather skip the tutorial and go straight to the reference, see the official Flutter Ringing Documentation.
Choose your path
This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step. Both paths end with calls that ring on another device - Path A's app is shaped by what you ask for, Path B's by the steps below - so pick the one you prefer instead of working through both.
Path A - Let your AI agent build it (recommended)
Install the Stream CLI once, then add the skills. This step is required for Path A, not optional - the skills are what give Claude Code, Cursor, or Codex the Flutter integration patterns and current SDK APIs, so the agent builds against real docs instead of stale training data.
123456789# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Flutter pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-flutter --universal
Then ask your agent:
1234/stream-flutter Build a Flutter ringing app with a login screen, a user picker, and incoming, outgoing and active call screens that ring over push. Provision credentials with the CLI and mint tokens for three test users. If you can't mint them, ask me to provide the credentials.
Only the first line matters - /stream-flutter Build a Flutter ringing app is enough to get going. The rest just steers the result: ask for a contact list from your own backend instead of hardcoded users, your own incoming-call screen instead of the default one, or a call history. Describe the UI you want rather than the UI the tutorial builds.
If the CLI can't provision the credentials, create the app and tokens via Step 3, Option 2 and provide them when the agent asks.
Where you come in. Push credentials live in consoles no CLI can reach, so the agent hands back to you a few times along the way. It writes the code, provisions your Stream app, and edits AndroidManifest.xml, Info.plist and AppDelegate.swift itself. You take over for the browser sign-in and the org and app choice during getstream init (new accounts get an organization created in that flow), creating a Firebase project and downloading google-services.json, creating an APNs key in the Apple Developer portal, and granting notification, camera and microphone access on each device. Depending on what your agent is allowed to run, it may also ask you to run flutter create, flutterfire configure or flutter run yourself.
Already have your Flutter project? You can get the sign-in out of the way first: run getstream init from inside the project before you prompt the agent, and it picks up the initialized project from there.
Human checkpoint: build the app to two devices and sign in as a different user on each. Ring one from the other - the caller should get an outgoing call screen and the callee a full incoming call screen, and accepting should drop you both into the call. Then close the app on the callee's device and ring it again; it should still ring.
What the agent can't do. Two things here are outside any agent's reach. The Firebase console and the Apple Developer portal are click-through web UIs with no API for creating a project or an APNs key, so those credentials have to come from you. And push delivery can't be verified by an agent at all: it needs release builds installed on two physical devices, notification permissions granted by hand, and somebody watching a lock screen. Agents: provision what the CLI can reach, ask the human for the Firebase and APNs credentials, and hand the final ringing test to them.
Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.
Path B - Build it manually
Choose this path if you'd rather write the code yourself, or want to understand what the SDK is doing. Follow the steps below.
For AI assistants reading this page: append
.mdto any Stream docs URL for a clean Markdown version. A condensed index for the Flutter Video SDK is athttps://getstream.io/video/docs/flutter/llms.txt. Prefer these over parsing HTML.
Important Building Blocks
Five pieces, one mental model:
StreamVideo- the low-level client. Holds the API key, the user, the token, and the websocket connection. For ringing it is created with apushNotificationManagerProvider, which is what registers the device for push. Created once, when a user logs in, and reachable anywhere asStreamVideo.instance.- A ringing call - an ordinary
defaultcall, created withcall.getOrCreate(memberIds: [...], ringing: true, video: true).ringing: trueis the whole difference: it tells the backend to ring every member you listed instead of quietly creating a call. Each of those members has to already exist on Stream. StreamVideoPushNotificationManager- the bridge to the OS. It registers the device token with your Firebase and APNs providers, then turns an incoming push into the native call UI: CallKit on iOS, a full-screen incoming-call notification on Android.- Ringing events - what you listen to so that accepting a call actually opens it.
observeCoreRingingEvents(onCallAccepted:)covers the foreground,observeCoreRingingEventsForBackground()runs inside the Firebase background isolate, andconsumeAndAcceptActiveCall()picks up a call the user accepted while the app was terminated. StreamCallContainer- one widget for all three faces of a ringing call. It switches oncall.stateto render the incoming, outgoing and active screens so you don't build them separately, and you can replace any of them throughincomingCallWidgetBuilder,outgoingCallWidgetBuilderandcallContentWidgetBuilder.
A call can arrive when your app is in the foreground, backgrounded, or terminated. On Android each of those needs its own wiring; on iOS the same ringing listener covers all three, and the only extra work is registering for VoIP pushes natively.
Step 1 - Create a New Flutter Project
To begin developing your ringing app, you need to create a new Flutter project. If you do not have Flutter or an IDE configured to work with it, we highly recommend following the Install and Set up an editor steps from the official documentation.
Please make sure you are using the latest version of Flutter from the stable channel:
12flutter channel stable flutter upgrade
Now create the project. For this tutorial we are calling it 'ringing_tutorial':
12flutter create ringing_tutorial --empty cd ringing_tutorial
Checkpoint: you have a ringing_tutorial directory and flutter run builds the Flutter starter app.
Step 2 - Install the SDK and Declare Permissions
Add Stream Video and the five helper packages the ringing flow needs:
12flutter pub add stream_video_flutter stream_video_push_notification \ firebase_core firebase_messaging uuid flutter_secure_storage:^9.2.4
flutter_secure_storage is pinned on purpose: 11.x requires compileSdk 37 and fails the Android build, so this tutorial stays on the 9.x line. Everything else takes the current release.
You should now have these in your pubspec.yaml:
12345678910dependencies: flutter: sdk: flutter stream_video_flutter: ^1.4.2 stream_video_push_notification: ^1.4.2 firebase_core: ^4.13.0 firebase_messaging: ^16.5.0 flutter_secure_storage: ^9.2.4 uuid: ^4.6.0
What each package is for:
- stream_video_flutter: the SDK, including the pre-built call UI. It re-exports the low-level stream_video client, so you don't list that separately.
- stream_video_push_notification: turns an incoming push into a native incoming-call screen - CallKit on iOS, a full-screen notification on Android.
- firebase_core and firebase_messaging: how ringing is delivered on Android. You configure the Firebase project itself in Step 6.
- flutter_secure_storage: remembers who is logged in, so a call can still ring after the app is closed.
- uuid: a unique id per call. Reusing a call id breaks ringing.
Declare Permissions
In your AndroidManifest.xml file, add these permissions:
12345678910111213141516171819<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-feature android:name="android.hardware.camera"/> <uses-feature android:name="android.hardware.camera.autofocus"/> <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <!-- Bluetooth permissions for audio routing --> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30"/> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/> ... </manifest>
You do not need to declare the notification and call permissions yourself. stream_video_push_notification ships its own manifest with POST_NOTIFICATIONS, USE_FULL_SCREEN_INTENT, MANAGE_OWN_CALLS, WAKE_LOCK and the activities and services that draw the incoming-call screen, and Gradle merges all of it into your app.
Next, set your MainActivity to singleInstance:
1234<activity android:name=".MainActivity" android:launchMode="singleInstance" ...>
Tapping a call notification then brings the existing instance to the foreground instead of launching a second copy of your app, so accepted calls don't stack screens.
Open Info.plist and add the usage descriptions and background modes:
1234567891011121314151617<key>NSCameraUsageDescription</key> <string>$(PRODUCT_NAME) needs access to your camera for video calls.</string> <key>NSMicrophoneUsageDescription</key> <string>$(PRODUCT_NAME) needs access to your microphone for voice and video calls.</string> <key>UIApplicationSupportsIndirectInputEvents</key> <true/> <key>BGTaskSchedulerPermittedIdentifiers</key> <array> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> </array> <key>UIBackgroundModes</key> <array> <string>audio</string> <string>processing</string> <string>remote-notification</string> <string>voip</string> </array>
Then enable the Push Notifications capability, which VoIP ringing needs:
- Open
ios/Runner.xcworkspacein Xcode. - Select your app's target.
- Go to the Signing & Capabilities tab.
- Click + Capability.
- Search for Push Notifications and add it.
Human checkpoint: this one is Xcode-only. Agents: you cannot do this - ask the human to add the capability.
firebase_core and firebase_messaging, added in the same step above, require iOS 15.0, and flutter create scaffolds the project at 13.0, so raise the deployment target or the build fails. Set Minimum Deployments to 15.0 in Xcode, or change all three occurrences of IPHONEOS_DEPLOYMENT_TARGET in ios/Runner.xcodeproj/project.pbxproj to 15.0.
Run the App
Build now rather than at the end - it confirms the native setup compiles before you add any code. An Android emulator is fine while you build, but ringing a closed app has to be tested on physical devices, because APNs never delivers to the iOS Simulator. The first iOS device build also needs a signing team: in Xcode, under Signing & Capabilities, select your Team and change the bundle identifier if the default is taken.
1flutter run
Human checkpoint: the app builds and launches on your device. Agents: run the build, then ask the human to confirm it launched.
Step 3 - Get Your Credentials
The code ahead needs an API key and three user tokens. Three, because ringing needs a caller and a callee who are different users - two clients signed in as the same user count as one participant - and a third lets you test ringing several people at once.
Tokens are normally minted by your server-side API when a user signs in. Here you'll create three by hand so you can sign in as different people on different devices. Either option below works, and both give you your own Stream app - which this tutorial needs, because push providers are tied to an app you control.
Option 1 - The Stream CLI (recommended)
The getstream CLI provisions all of it in one flow. Run these from the ringing_tutorial directory you created in Step 1 - the CLI stores project credentials there.
1. Install the CLI (skip if you did this in Path A):
1curl -fsSL https://getstream.io/cli.sh | bash
2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The sign-in flow creates your organization. Already have an org or an app? It lets you pick them.
1getstream init
Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish signing in before continuing. It's required first - token and api fail with "project credentials missing", and open fails with "stream project is not initialized", until it runs.
3. Grab your API key. This opens the app you just selected in the Stream dashboard, where the API key is shown. Paste it into the code in Step 4.
1getstream open
4. Mint a token for each user (never expiring by default; add a TTL for production-like testing):
123getstream token alice getstream token bob getstream token charlie
Option 2 - The Stream Dashboard and token generator
Prefer a UI, or don't want to install the CLI?
- Create an application in the Stream Dashboard. The API key is shown on the app's page.
- Mint a token for each of your three user ids with the token generation form, supplying your App Secret and the user id.
Your App Secret must never ship in a mobile app - it belongs on your server. Using it in this form is fine because the form runs in your browser and only the resulting tokens go into the app.
Create the three users
Do this whichever option you used. You can only ring a user who already exists on Stream, and a user is only created the first time they connect with their token. So until each of your three has signed in on a device at least once, ringing them fails. Creating them up front avoids the whole problem:
1234getstream api UpdateUsers --request '{"users":{ "alice":{"id":"alice","name":"Alice"}, "bob":{"id":"bob","name":"Bob"}, "charlie":{"id":"charlie","name":"Charlie"}}}'
No CLI? Then sign in once as each of the three users when the app first runs in Step 9, before you try to ring anybody. Server-side creation is the more reliable habit, and the Users API is how you'd do it from your own backend.
Checkpoint: you have an API key and three tokens, all belonging to the same app, and all three user ids exist on Stream. Skip the user creation and the RING button later fails with "The following users are involved in call create operation, but don't exist" - which surfaces only as a brief snackbar, so it reads like the button is doing nothing.
Pasting the API key straight into your source is fine for this tutorial - it's a publishable key, not a secret. The user tokens are the ones to be careful with: in production your backend mints them after sign-in and the app fetches them, rather than shipping hardcoded ones. The Client and Authentication guide covers the production shape.
Step 4 - Store Your Credentials
Create lib/app_keys.dart to hold everything from Step 3. Keeping it in one file means the rest of the app never hardcodes a token, and it's the only file you touch when you switch from the tutorial credentials to your own:
1234567891011121314151617181920212223class AppKeys { // Your Stream API key from the Stream Dashboard static const String streamApiKey = '{REPLACE_WITH_YOUR_STREAM_API_KEY}'; // Push provider names. You create these in Step 7 - leave them for now. static const String iosPushProviderName = 'apn-flutter-sample'; static const String androidPushProviderName = 'flutter-firebase'; // User 1 - the caller you'll sign in as first static const String user1Id = '{REPLACE_WITH_USER_1_ID}'; static const String user1Name = '{REPLACE_WITH_USER_1_NAME}'; static const String user1Token = '{REPLACE_WITH_USER_1_TOKEN}'; // User 2 - the one you'll ring static const String user2Id = '{REPLACE_WITH_USER_2_ID}'; static const String user2Name = '{REPLACE_WITH_USER_2_NAME}'; static const String user2Token = '{REPLACE_WITH_USER_2_TOKEN}'; // User 3 - so you can test ringing more than one person static const String user3Id = '{REPLACE_WITH_USER_3_ID}'; static const String user3Name = '{REPLACE_WITH_USER_3_NAME}'; static const String user3Token = '{REPLACE_WITH_USER_3_TOKEN}'; }
The two push provider names are the one thing here you don't have yet. Keep the defaults for now; Step 7 is where you create the providers, and the names have to match what you register there.
Checkpoint: flutter analyze lib reports no issues, and every {REPLACE_WITH_...} placeholder above has a real value from Step 3.
Step 5 - Add the Users and Build the Login Screen
Two files. TutorialUser turns the credentials from Step 4 into the User objects the SDK expects, and LoginScreen lets you choose which one to sign in as. A real app authenticates against your own backend and fetches a token; three hardcoded users is what lets you sign in as three different people on three devices without building auth first.
Create lib/tutorial_user.dart:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748import 'package:ringing_tutorial/app_keys.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class TutorialUser { const TutorialUser({ required this.user, required this.token, }); final User user; final String? token; factory TutorialUser.user1() => TutorialUser( user: User.regular( userId: AppKeys.user1Id, name: AppKeys.user1Name, image: 'https://images.pexels.com/photos/774909/pexels-photo-774909.jpeg?auto=compress&cs=tinysrgb&w=600', ), token: AppKeys.user1Token, ); factory TutorialUser.user2() => TutorialUser( user: User.regular( userId: AppKeys.user2Id, name: AppKeys.user2Name, image: 'https://images.pexels.com/photos/415829/pexels-photo-415829.jpeg?auto=compress&cs=tinysrgb&w=600', ), token: AppKeys.user2Token, ); factory TutorialUser.user3() => TutorialUser( user: User.regular( userId: AppKeys.user3Id, name: AppKeys.user3Name, image: 'https://images.pexels.com/photos/1681010/pexels-photo-1681010.jpeg?auto=compress&cs=tinysrgb&w=600', ), token: AppKeys.user3Token, ); static List<TutorialUser> get users => [ TutorialUser.user1(), TutorialUser.user2(), TutorialUser.user3(), ]; }
Every factory reads its id, name and token out of AppKeys, so Step 4's file stays the only place a credential appears. User.regular is the SDK's signed-in user type, and image is the avatar the incoming-call screen shows for the caller, so any reachable URL works.
Create lib/login_screen.dart:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475import 'package:flutter/material.dart'; import 'package:ringing_tutorial/app_initializer.dart'; import 'package:ringing_tutorial/home_screen.dart'; import 'package:ringing_tutorial/tutorial_user.dart'; class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); State<LoginScreen> createState() => _LoginScreenState(); } class _LoginScreenState extends State<LoginScreen> { TutorialUser? selectedUser; List<TutorialUser> users = TutorialUser.users; Widget build(BuildContext context) { return MaterialApp( home: Builder( builder: (context) { return Scaffold( body: Center( child: Column( spacing: 16, mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Login as:', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 90), ...users.map((user) { return ElevatedButton( style: ElevatedButton.styleFrom( foregroundColor: selectedUser?.user.id == user.user.id ? Colors.green : null, ), onPressed: () { setState(() { selectedUser = user; }); }, child: Text(user.user.name ?? ''), ); }), const SizedBox(height: 90), TextButton( onPressed: selectedUser != null ? () async { await AppInitializer.storeUser(selectedUser!); await AppInitializer.init(selectedUser!); if (context.mounted) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => HomeScreen(), ), ); } } : null, child: Text( 'Login', )), ], ), ), ); }, ), ); } }
Pressing Login does two things in order. AppInitializer.storeUser writes the chosen user id to secure storage, so the next launch can restore the session without showing this screen again. Then AppInitializer.init creates the StreamVideo client and connects it. Only after that does the app navigate to the home screen, because everything on that screen reads StreamVideo.instance.
AppInitializer and HomeScreen do not exist yet. They arrive in Steps 8 and 9, so the analyzer flags both imports until then.

Step 6 - Set Up Firebase
Ringing is delivered by a push notification, and on Android that means Firebase Cloud Messaging. iOS rings over APNs, but this tutorial's client calls Firebase.initializeApp on both platforms, so you configure Firebase once and both platforms are covered.
Commit before you start. This step rewrites your Gradle files and your Xcode project, and a clean starting point is how you see what changed:
123git init git add . git commit -m "Ringing tutorial: project, credentials, login screen"
Pick your bundle identifier first
Do this before you configure Firebase. The tooling registers Firebase apps against whatever identifiers it finds, so changing them afterwards means registering again. flutter create scaffolds com.example.ringing_tutorial on Android and com.example.ringingTutorial on iOS - the two differ, so check both. Change them to something you own.
In android/app/build.gradle.kts, change applicationId:
123456android { defaultConfig { applicationId = "io.getstream.flutter.sample.ringing" ... } }
This has to match the package_name inside the google-services.json you get in a moment, or Firebase Messaging fails at runtime.
Open ios/Runner.xcodeproj in Xcode, select the Runner target, go to Signing & Capabilities and set the Bundle Identifier. Or change all occurrences of PRODUCT_BUNDLE_IDENTIFIER in ios/Runner.xcodeproj/project.pbxproj.
Write this identifier down. Step 7 asks for it again when you register the APNs push provider.
Create the Firebase project and configure the app
flutterfire configure needs both the Firebase CLI and the FlutterFire CLI:
123npm install -g firebase-tools dart pub global activate flutterfire_cli firebase login
Then, from the project root:
1flutterfire configure --platforms=android,ios
It asks you to select or create a Firebase project, registers an Android and an iOS app for the identifiers you just set, and writes four files:
lib/firebase_options.dart, which the app imports in Step 8.android/app/google-services.json.ios/Runner/GoogleService-Info.plist.firebase.json, recording which Firebase project and apps this directory is wired to.
It also adds the Google Services Gradle plugin, which is what makes the Android build read google-services.json. Without it the app still compiles and then throws at runtime when it registers for push, so confirm the plugin actually landed in both files:
1grep -rn google-services android/settings.gradle.kts android/app/build.gradle.kts
You should see it in each, wrapped in // START: FlutterFire Configuration markers. If the CLI could not match a customized Gradle file and skipped one, add id("com.google.gms.google-services") version "4.5.0" apply false to the plugins block in android/settings.gradle.kts and id("com.google.gms.google-services") to the one in android/app/build.gradle.kts.
Human checkpoint: flutterfire configure signs in with a Google account and, for a new project, walks you through the Firebase console. Agents: you cannot do this one. Ask the human to run flutterfire configure, then verify the four files exist. When it is done, lib/firebase_options.dart exists and names your Firebase project, and android/app/google-services.json exists with a package_name matching the applicationId you set above. A mismatch there is the single most common Firebase setup error, and it fails at runtime rather than at build time. Nothing runs end to end yet; the app first starts in Step 9.
Step 7 - Configure Push Providers
Stream is what sends the ring, so it needs credentials for the two delivery services: a Firebase service account for Android and an APNs key for iOS. You register these once per Stream app as push providers, and each one gets a name your app has to use.
The two platforms are independent. Shipping Android only? Set up the Firebase provider and skip the rest. iOS only? The reverse.
Set both up in the Stream Dashboard using these guides:
- Android - creating a Firebase provider, which needs a service account key from the Firebase console.
- iOS - creating an APNs provider, which needs a
.p8authentication key from the Apple Developer portal, plus the Key ID, your Team ID, and the bundle identifier you set in Step 6.
Name them flutter-firebase and apn-flutter-sample to match the defaults already sitting in lib/app_keys.dart.
The credentials themselves come from the Firebase console and the Apple Developer portal, but the registration does not have to happen in the dashboard - the CLI can do it once you have the files:
1234# firebase_credentials is the whole service account JSON, as a string getstream api UpsertPushProvider --request '{"push_provider":{ "type":"firebase","name":"flutter-firebase", "firebase_credentials":"<contents of service-account.json>"}}'
The APNs provider works the same way with "type":"apn" and the apn_* fields - run getstream api UpsertPushProvider --schema for the full list. The response echoes the credential back in full, so avoid pasting it anywhere shared.
Once they exist, list what your app has:
1getstream api ListPushProviders
Human checkpoint: the credentials behind both providers come from web consoles no CLI can reach, so this step needs a human. Agents: read the two guides above, tell the human which credential each one needs, then confirm the result with ListPushProviders. You should get one entry for each platform you set up, and the name on each has to match androidPushProviderName and iosPushProviderName in lib/app_keys.dart. A name mismatch is the nastiest failure on this page: every call succeeds, and no device ever rings.
Step 8 - Set Up the Video Client
This is where the app becomes a Stream app. AppInitializer has three jobs: remember who is signed in across launches, initialize Firebase, and create the StreamVideo client with a push notification manager attached.
Create lib/app_initializer.dart:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768import 'package:firebase_core/firebase_core.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:ringing_tutorial/app_keys.dart'; import 'package:ringing_tutorial/firebase_options.dart'; import 'package:ringing_tutorial/tutorial_user.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; import 'package:stream_video_push_notification/stream_video_push_notification.dart'; class AppInitializer { static const storedUserKey = 'loggedInUserId'; static Future<TutorialUser?> getStoredUser() async { final storage = FlutterSecureStorage(); final userId = await storage.read(key: storedUserKey); if (userId == null) { return null; } if (TutorialUser.users.where((user) => user.user.id == userId).isEmpty) { await clearStoredUser(); return null; } return TutorialUser.users.firstWhere( (user) => user.user.id == userId, ); } static Future<void> storeUser(TutorialUser tutorialUser) async { final storage = FlutterSecureStorage(); await storage.write(key: storedUserKey, value: tutorialUser.user.id); } static Future<void> clearStoredUser() async { final storage = FlutterSecureStorage(); await storage.delete(key: storedUserKey); } static Future<StreamVideo> init(TutorialUser tutorialUser) async { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); return StreamVideo( AppKeys.streamApiKey, user: tutorialUser.user, userToken: tutorialUser.token, options: StreamVideoOptions( keepConnectionsAliveWhenInBackground: true, logPriority: Priority.debug, ), pushNotificationManagerProvider: StreamVideoPushNotificationManager.create( iosPushProvider: const StreamVideoPushProvider.apn( name: AppKeys.iosPushProviderName, ), androidPushProvider: const StreamVideoPushProvider.firebase( name: AppKeys.androidPushProviderName, ), pushConfiguration: const StreamVideoPushConfiguration( ios: IOSPushConfiguration(iconName: 'IconMask'), ), registerApnDeviceToken: true, ), )..connect(); } }
Five things in there are worth understanding.
pushNotificationManagerProvider is the ringing switch. It is the difference between a video SDK and a video SDK that rings. The two provider names have to match what you registered in Step 7, and registerApnDeviceToken: true asks iOS for a regular APNs token alongside the VoIP one.
connect() does two jobs. It opens the websocket for the signed-in user and registers this device with your push providers, which is what makes the device reachable by a ring. The ..connect() cascade returns the client without waiting for the connection to finish, which is what you want when a push wakes the app - but it also means a failed connection goes unobserved. logPriority is what makes that visible: the SDK logs nothing at all by default, so a push setup that is wrong in any way - a provider that does not exist, a name that does not match, a device that never gets a token - fails without a word in your console.
StreamVideo is created once, here, and read everywhere else as StreamVideo.instance. Nothing later in the tutorial passes the client around.
getStoredUser guards its lookup. If the stored id is no longer in TutorialUser.users, it clears the stored user and returns null.
Firebase.initializeApp runs before the client is built, and it is not optional. Step 6 has to be finished for this to work: firebase_options.dart does not exist until flutterfire configure generates it, and there is no useful way to carry on without it. The SDK reaches for FirebaseMessaging.instance again inside device registration, and the home screen in the next step reaches for it a third time, so a missing Firebase app surfaces as a red error screen rather than as a degraded app.
Wiring it into main.dart
main.dart decides which screen opens: the login screen for a fresh install, the home screen if a user is already stored. Replace the contents of lib/main.dart:
1234567891011121314151617181920212223242526272829303132333435363738import 'package:flutter/material.dart'; import 'package:ringing_tutorial/app_initializer.dart'; import 'package:ringing_tutorial/home_screen.dart'; import 'package:ringing_tutorial/login_screen.dart'; import 'package:ringing_tutorial/tutorial_user.dart'; Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); final storedUser = await AppInitializer.getStoredUser(); if (storedUser != null) { await AppInitializer.init(storedUser); } runApp(MainApp(storedUser: storedUser)); } class MainApp extends StatefulWidget { final TutorialUser? storedUser; const MainApp({ this.storedUser, super.key, }); State<MainApp> createState() => _MainAppState(); } class _MainAppState extends State<MainApp> { Widget build(BuildContext context) { return MaterialApp( home: widget.storedUser == null ? LoginScreen() : HomeScreen(), ); } }
Restoring the session here rather than on the home screen matters for ringing. When a push wakes the app from a terminated state, main runs before any screen does, and the client has to exist before the incoming call can be handled.
Step 9 - Build the Home Screen
The home screen is where a call starts: it shows who you are, lets you pick who to ring and whether the call carries video, and hands off to the call screen. It is also the first screen that reads StreamVideo.instance, which is why it only opens after AppInitializer.init has run.
Create lib/home_screen.dart:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143import 'package:flutter/material.dart'; import 'package:ringing_tutorial/app_initializer.dart'; import 'package:ringing_tutorial/login_screen.dart'; import 'package:ringing_tutorial/tutorial_user.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); State<HomeScreen> createState() => _HomeScreenState(); } class _HomeScreenState extends State<HomeScreen> { final Subscriptions subscriptions = Subscriptions(); final List<String> selectedUserIds = []; bool videoCall = true; Future<void> _createRingingCall() async { // Step 10 fills this in. } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Ringing Tutorial'), centerTitle: true, automaticallyImplyLeading: false, actions: [ IconButton( icon: const Icon(Icons.logout), onPressed: () async { await StreamVideo.instance.disconnect(); await StreamVideo.reset(); await AppInitializer.clearStoredUser(); subscriptions.cancelAll(); if (context.mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => LoginScreen(), ), ); } }, ), ], ), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Hello ${StreamVideo.instance.currentUser.name}!', style: Theme.of(context).textTheme.headlineLarge, ), const SizedBox(height: 90), Text( 'Select who would you like to ring?', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), Row( spacing: 16, mainAxisSize: MainAxisSize.min, children: [ ...TutorialUser.users .where( (u) => u.user.id != StreamVideo.instance.currentUser.id) .map((user) { return ElevatedButton( onPressed: () async { if (selectedUserIds.contains(user.user.id)) { selectedUserIds.remove(user.user.id); } else { selectedUserIds.add(user.user.id); } setState(() {}); }, style: ElevatedButton.styleFrom( foregroundColor: selectedUserIds.contains(user.user.id) ? Colors.green : null, ), child: Text(user.user.name ?? ''), ); }), ], ), const SizedBox(height: 16), Text( 'Should it be a video or audio call?', style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), Row( spacing: 16, mainAxisSize: MainAxisSize.min, children: [ ElevatedButton( onPressed: () async { setState(() { videoCall = true; }); }, style: ElevatedButton.styleFrom( foregroundColor: videoCall ? Colors.green : null, ), child: Text('Video'), ), ElevatedButton( onPressed: () async { setState(() { videoCall = false; }); }, style: ElevatedButton.styleFrom( foregroundColor: !videoCall ? Colors.green : null, ), child: Text('Audio'), ), ], ), const SizedBox(height: 90), ElevatedButton( onPressed: selectedUserIds.isEmpty ? null : _createRingingCall, child: const Text('RING'), ), ], ), ), ); } void dispose() { subscriptions.cancelAll(); super.dispose(); } }

Three things on this screen are worth pointing out.
The user picker excludes you. TutorialUser.users.where((u) => u.user.id != StreamVideo.instance.currentUser.id) filters out whoever is signed in. Ringing yourself does nothing useful: two clients signed in as the same user count as one participant.
selectedUserIds is a list, not a single id. Ringing several people at once is the same call with more members, which is why Step 3 had you create three users.
Logging out tears down the client. disconnect() closes the websocket, StreamVideo.reset() clears the singleton so a different user can sign in cleanly, and subscriptions.cancelAll() releases the ringing listeners you add in Step 11.
Subscriptions is a small keyed helper from the SDK: you add a stream subscription under an int key and cancel them all at once.
Checkpoint: run the app, pick a user, and press Login. You land on the home screen greeting you by name, with the other two users as buttons, a video/audio toggle, and a RING button that stays greyed out until you select someone. RING does nothing yet - Step 10 wires it up.
Step 10 - Build the Call Screen and Start a Ringing Call
Two pieces: the screen that hosts a call, and the method that creates one. StreamCallContainer is the whole reason this step is short - it renders the incoming, outgoing and active states off call.state, so you do not build three screens.
Create lib/call_screen.dart:
12345678910111213141516171819202122232425import 'package:flutter/material.dart'; import 'package:stream_video_flutter/stream_video_flutter.dart'; class CallScreen extends StatefulWidget { final Call call; const CallScreen({ super.key, required this.call, }); State<CallScreen> createState() => _CallScreenState(); } class _CallScreenState extends State<CallScreen> { Widget build(BuildContext context) { return Scaffold( body: StreamCallContainer( call: widget.call, ), ); } }
One widget covers all three faces of a ringing call. It switches on call.state to show the outgoing screen to the caller, the incoming screen to the callee, and the active call to both once someone accepts. Each is replaceable through outgoingCallWidgetBuilder, incomingCallWidgetBuilder and callContentWidgetBuilder when you want your own UI.
This is a merge into the _HomeScreenState you wrote in Step 9, not a whole-file replacement. Add two imports:
12import 'package:ringing_tutorial/call_screen.dart'; import 'package:uuid/uuid.dart';
Then replace the _createRingingCall stub with the real thing:
123456789101112131415161718192021222324252627282930313233Future<void> _createRingingCall() async { final call = StreamVideo.instance.makeCall( callType: StreamCallType.defaultType(), id: Uuid().v4(), ); final result = await call.getOrCreate( memberIds: selectedUserIds, video: videoCall, ringing: true, ); result.fold( success: (success) { if (mounted) { Navigator.of(context).push( MaterialPageRoute( builder: (context) => CallScreen( call: call, ), ), ); } }, failure: (failure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(failure.error.message), ), ); }, ); }
makeCall builds a call object locally; nothing reaches the backend until getOrCreate. The id is a fresh UUID every time on purpose - reusing a call id means joining the old call instead of ringing a new one.
ringing: true is the entire difference between this and an ordinary call. It tells the backend to push a ring to every member in memberIds rather than quietly creating a call and waiting for people to join.
The failure branch matters more than it looks. Call creation is where "the following users are involved in call create operation, but don't exist" surfaces if you skipped creating your users in Step 3, and a SnackBar auto-dismisses, so watch for it.

Checkpoint: select a user and press RING. You get the outgoing call screen with the callee's avatar, "Calling...", mic and camera toggles and a hang-up button. That means getOrCreate succeeded and the call exists on Stream. Nothing rings on the other device yet - that needs the push handling in Steps 11 and 12. If a snackbar flashes instead, read it: "...but don't exist" means you skipped UpdateUsers in Step 3.
Step 11 - Handle Incoming Calls on Android
Everything so far has been about placing a call. This step is about receiving one, and it is the longest on the page because the app can be in three different states when a call arrives, each needing its own wiring:
- Foreground - the app is open. A Firebase message arrives on a stream you listen to.
- Background - the app is alive but not visible. A separate isolate handles the message, so it has to build its own client.
- Terminated - the app is not running. Android shows the incoming call, and your app has to pick the call up when it launches.
All three merge into home_screen.dart. Start by adding the import the first two sections need:
1import 'package:firebase_messaging/firebase_messaging.dart';
Ringing while the app is open
Add these members to _HomeScreenState. This is a merge into the class you already have, not a replacement:
12345678910111213141516171819202122232425262728293031323334353637383940414243static const int _fcmSubscription = 1; static const int _callKitSubscription = 2; void initState() { super.initState(); FirebaseMessaging.instance.requestPermission(); _observeFcmMessages(); _observeRingingEvents(); } Future<bool> _handleRemoteMessage(RemoteMessage message) async { return StreamVideo.instance.handleRingingFlowNotifications(message.data); } void _observeFcmMessages() { subscriptions.add( _fcmSubscription, FirebaseMessaging.onMessage.listen(_handleRemoteMessage), ); } void _observeRingingEvents() { final streamVideo = StreamVideo.instance; subscriptions.add( _callKitSubscription, streamVideo.observeCoreRingingEvents( onCallAccepted: (callToJoin) { Navigator.push( context, MaterialPageRoute( builder: (context) => CallScreen( call: callToJoin, ), ), ); }, ), ); }
Two listeners, doing two different jobs.
_observeFcmMessages catches the raw push. handleRingingFlowNotifications takes the message payload and turns it into the native incoming-call screen, and it also handles missed-call notifications. This is the only path that shows an incoming call: the websocket alone will not do it, which is why nothing rings until Steps 6 and 7 are finished.
_observeRingingEvents catches what the user does with that screen. Without it, Accept and Decline are inert - the notification dismisses and nothing opens. onCallAccepted is where you navigate to the CallScreen, and it fires on iOS too, so this method is not Android-only.
requestPermission asks for the notification permission Android 13+ requires. It also needs a live Firebase app, so it is the third place in this tutorial that depends on Step 6 being finished.

Ringing while the app is backgrounded
A backgrounded app gets its message in a separate isolate, which shares no memory with your running app. StreamVideo.instance does not exist there, so the handler builds its own client.
Add the remaining imports:
1234import 'package:firebase_core/firebase_core.dart'; import 'package:ringing_tutorial/app_keys.dart'; import 'package:ringing_tutorial/firebase_options.dart'; import 'package:stream_video_push_notification/stream_video_push_notification.dart';
Then add this as a top-level function in home_screen.dart, outside any class:
123456789101112131415161718192021222324252627282930313233343536373839404142('vm:entry-point') Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); try { final tutorialUser = await AppInitializer.getStoredUser(); if (tutorialUser == null) return; final streamVideo = StreamVideo.create( AppKeys.streamApiKey, user: tutorialUser.user, userToken: tutorialUser.token, options: StreamVideoOptions( keepConnectionsAliveWhenInBackground: true, logPriority: Priority.debug, ), pushNotificationManagerProvider: StreamVideoPushNotificationManager.create( iosPushProvider: const StreamVideoPushProvider.apn( name: AppKeys.iosPushProviderName, ), androidPushProvider: const StreamVideoPushProvider.firebase( name: AppKeys.androidPushProviderName, ), pushConfiguration: const StreamVideoPushConfiguration( ios: IOSPushConfiguration(iconName: 'IconMask'), ), registerApnDeviceToken: true, ), )..connect(); final subscription = streamVideo.observeCoreRingingEventsForBackground(); streamVideo.disposeAfterResolvingRinging( disposingCallback: subscription.cancel, ); await streamVideo.handleRingingFlowNotifications(message.data); } catch (e, stk) { debugPrint('Error handling remote message: $e'); debugPrint(stk.toString()); } }
Three details make this work:
@pragma('vm:entry-point')keeps the function reachable when it is called from a separate isolate, which nothing in your code appears to do.StreamVideo.create, not theStreamVideoconstructor.createbuilds a throwaway instance without touching theStreamVideo.instancesingleton, so the isolate cannot fight with the app over it. It reads the signed-in user back out of secure storage, which is why Step 8 stored it.disposeAfterResolvingRingingtears that instance down once the call is accepted, declined, or times out, so the isolate does not sit on a connection.
Now register it, by merging one line into the _observeFcmMessages you wrote above:
12345678void _observeFcmMessages() { FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); subscriptions.add( _fcmSubscription, FirebaseMessaging.onMessage.listen(_handleRemoteMessage), ); }
Ringing while the app is terminated
A terminated app still shows the incoming call, because the notification is drawn natively. What it cannot do is act on your answer: accepting launches the app and drops you on the home screen instead of into the call. consumeAndAcceptActiveCall picks up the call the user explicitly accepted and opens it.
Add this to _HomeScreenState:
12345678910111213141516171819void _tryConsumingIncomingCallFromTerminatedState() { // This is only relevant for Android. if (CurrentPlatform.isIos) return; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { StreamVideo.instance.consumeAndAcceptActiveCall( onCallAccepted: (callToJoin) { Navigator.push( context, MaterialPageRoute( builder: (context) => CallScreen( call: callToJoin, ), ), ); }, ); }); }
It runs in addPostFrameCallback because it navigates, so the first frame has to exist first. iOS returns early: CallKit hands the accepted call back through the ringing events you are already listening to.
Call it from initState, before the two observers:
1234567891011void initState() { super.initState(); FirebaseMessaging.instance.requestPermission(); _tryConsumingIncomingCallFromTerminatedState(); _observeFcmMessages(); _observeRingingEvents(); }
Test this one in release mode. In debug the connection to the debugger keeps the process alive, so you are not really testing a terminated app:
1flutter run --release
Release builds shrink with R8, which strips code the plugins reach reflectively and fails outright on classes Flutter references but this app does not ship. Create android/app/proguard-rules.pro:
1234567891011121314# Flutter core classes -keep class io.flutter.plugin.** { *; } -keep class io.flutter.util.** { *; } -keep class io.flutter.view.** { *; } -keep class io.flutter.plugins.** { *; } -keep class io.flutter.embedding.** { *; } -keep class io.flutter.app.** { *; } -keep class io.getstream.video.flutter.stream_video_push_notification.** { *; } # Suppress warnings for common missing classes -dontwarn org.conscrypt.** -dontwarn org.w3c.dom.bootstrap.DOMImplementationRegistry -dontwarn com.google.android.play.core.**
Writing the file is not enough - nothing reads it until you register it. Add proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") to the release block in android/app/build.gradle.kts.
Human checkpoint: install the app on two Android devices, sign in as a different user on each, and ring one from the other. The callee should get a full-screen incoming call, and accepting should open the call on both. Then repeat it twice more: once with the callee's app backgrounded, and once with it swiped away in release mode. Agents: you cannot verify this one - it needs two physical devices, notification permissions granted by hand, and someone watching a screen. Build and install, then hand it over. Nothing arriving at all is almost always Step 7: check getstream api ListPushProviders returns a firebase provider whose name matches AppKeys.androidPushProviderName.
This is the payoff: a call placed on one device rings on another, and keeps ringing when the app is closed.
Step 12 - Handle Incoming Calls on iOS
To handle incoming calls on iOS, our backend sends VoIP push notifications via APNs. These need to be managed by the CallKit framework on the native side.
Skipped Step 11? Go back and add
_observeRingingEvents()and its call ininitState. It is not Android-only; without it, accepting a call on iOS does nothing.
Open ios/Runner/AppDelegate.swift and add the import and the registration call:
12345678910111213141516171819import Flutter import UIKit import stream_video_push_notification @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { StreamVideoPKDelegateManager.shared.registerForPushNotifications() return super.application(application, didFinishLaunchingWithOptions: launchOptions) } func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) } }
registerForPushNotifications() is the line that matters. It registers the app with PushKit for VoIP pushes and wires up the CallKit delegate, so an incoming call shows the native screen even when the app is closed.

Human checkpoint: ring the iOS device from another device. You get the CallKit incoming-call screen, and accepting opens the call. Agents: not verifiable by you - APNs never delivers to the Simulator, so this needs a physical device, a signing team, and a human. Three things have to be true or the push is silently dropped: a physical device, an apn provider in getstream api ListPushProviders whose name matches AppKeys.iosPushProviderName, and a bundle identifier matching the one your APNs key was issued for.
Verify the whole build
Build to two devices, signing in as a different user on each, and run the full loop: ring from one, check the outgoing screen on the caller and the full-screen incoming call on the callee, accept, and confirm both video tiles render. Hang up and both should land back on the home screen. Then close the app on the callee's device and ring again - it should still ring, and accepting should open the call.
Troubleshooting
project credentials missing- the CLI has not been initialized here. Rungetstream initfrom the project directory first (Step 3).stream project is not initialized- same cause, reported bygetstream open. Rungetstream initfirst (Step 3).- RING appears to do nothing - read the snackbar before it dismisses.
The following users are involved in call create operation, but don't existmeans the person you rang has never existed on Stream. Create all three up front withgetstream api UpdateUsers(Step 3). Failed to find target with hash string 'android-37'-flutter_secure_storage11.x requirescompileSdk 37. Pinflutter_secure_storage: ^9.2.4as Step 2 does; installing the platform does not clear it.[core/no-app] No Firebase App '[DEFAULT]' has been created-flutterfire configurehas not run, solib/firebase_options.dartdoes not exist yet. Finish Step 6; the app cannot start without it.cannot load such file -- xcodeproj (LoadError)-flutterfire configureuses Ruby'sxcodeprojgem for the iOS half. Rungem install xcodeproj, then re-run it.[firebase_messaging/unknown] ... Please set a valid API key- thrown at runtime out of device registration, not at build time. Eitherandroid/app/google-services.jsonis missing, or itspackage_namedoes not match yourapplicationId, or the Google Services Gradle plugin was not applied (Step 6).- Nothing rings on an Android emulator - Firebase Cloud Messaging needs Google Play Services, which plain AOSP emulator images do not have. Use an emulator image that includes the Play Store, or a physical device.
- The call is created but no device ever rings - the provider names are the usual cause, and they fail silently. Check
getstream api ListPushProvidersreturns one entry per platform and that eachnamematchesiosPushProviderNameandandroidPushProviderNameinlib/app_keys.dart(Step 7). - Nothing rings on iOS - APNs does not deliver to the Simulator, so use a physical device. You also need the Push Notifications capability, a signing team, and a bundle identifier matching the one your APNs key was issued for (Steps 2, 6 and 12).
The package product 'firebase-core' requires minimum platform version 15.0- your deployment target is below 15.0. Set it to 15.0 in all three places (Step 2).Missing class com.google.android.play.core.tasks.*, orminifyReleaseWithR8fails - the release build needs the proguard rules from Step 11 and theproguardFilesentry that activates them. Aproguard-rules.prothat is not referenced frombuild.gradle.ktsdoes nothing.- Ringing works in debug but not once the app is killed - test with
flutter run --release; the debugger keeps a debug build alive. Step 11's proguard rules are required for the release build to compile at all. - Accepting a notification opens the app but not the call -
consumeAndAcceptActiveCallis missing frominitState, orobserveCoreRingingEventsis not being observed (Step 11).
Recap
Congratulations! You've successfully built a fully functional ringing experience using Stream Video in a Flutter app.
To recap what we've covered:
- Setting up Stream Video and initializing the client.
- Building UI for login, home, and call screens.
- Handled authentication and user data storage.
- Configured FCM and APNs providers in the Stream Dashboard.
- Creating and handling calls (video, and audio).
- Implementing push notifications for incoming calls.
- Handling background and terminated states on Android and iOS.
At this point, your app should be able to send and receive calls with ringing notifications across devices, even when in the background or terminated.
We hope you've enjoyed this tutorial, and please do feel free to reach out if you have any suggestions or questions.
Next steps
To further improve your app, check out these helpful links:
- Stream Video Docs: Deep dive into Stream Video's incoming call documentation
- Troubleshooting Common Issues: Learn how to troubleshoot common issues
- Working Ringing Sample App: Sample app build based on this tutorial
- Dogfooding App: Our internal Dogfooding with most of the SDK features implemented
- Video calling tutorial, livestreaming and audio rooms: the other Flutter video experiences
- Chat Flutter SDK: add messaging to the same app
Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.
Final Thoughts
This is one of our video app tutorials built on the Flutter SDK component library.
Both the video SDK for Flutter and the API have plenty more features available to support more advanced use-cases.
Machine-readable resources
For AI agents and coding assistants working with this SDK:
- CLI + skills:
curl -fsSL https://getstream.io/cli.sh | bash, thengetstream skillsandgetstream skills stream-flutterfor the Flutter pack. (Alternative:npx skills add GetStream/agent-skills -s stream.) - Provisioning:
getstream init(auth + create/select org & app) ->getstream open(opens the dashboard, where the API key is shown) ->getstream token <user-id>(mint a token per user) - Data & config from the CLI:
getstream api <Endpoint> --request '{...}'for users, calls, and push providers -UpdateUserscreates the users a ringing call needs,ListPushProvidersconfirms push is configured - Flutter integration skill: invoke
/stream-flutterin your agent for Flutter setup patterns;/stream-docssearches live SDK docs with citations - Docs index for LLMs:
https://getstream.io/video/docs/flutter/llms.txt - Markdown endpoints: append
.mdto any docs URL for a clean, token-efficient version - Not agent-verifiable: the Firebase console, the Apple Developer portal, and push notification delivery. Delivery needs release builds on two physical devices with notification permissions granted by hand. Hand these to a human.
- Source of truth for APIs: the SDK repository - check the installed version's source rather than assuming APIs from training data

