Add video calling to FlutterFlow using Agora.io's Flutter SDK as a Custom Widget — Agora handles WebRTC signaling, TURN servers, and codec negotiation. Use Firestore subcollections for real-time chat messages. Generate Agora channel tokens server-side in a Cloud Function — never on the client, as this exposes your App Certificate. Display a split-view layout with video at the top and chat at the bottom using a Column widget.
Video Calling with Agora and Chat with Firestore
WebRTC video calling requires peer-to-peer connection negotiation (signaling), relay servers (TURN) for NAT traversal, and media encoding — none of which FlutterFlow's visual builder handles natively. Agora.io solves all of this with a managed SDK that takes a channel name and token, handles the rest, and renders video directly in a Flutter widget. Alongside video, real-time messaging uses Firestore's real-time capabilities — messages are written to a subcollection and immediately delivered to all participants via a Backend Query listener. This tutorial builds a working video call screen with in-call chat.
Prerequisites
- A FlutterFlow Pro account with code export enabled
- An Agora.io account with an App ID and App Certificate created
- A Firebase project with Firestore, Authentication, and Cloud Functions configured
- Basic familiarity with FlutterFlow Custom Widgets and Custom Actions
Step-by-step guide
Set up Agora project and add the SDK after code export
Set up Agora project and add the SDK after code export
Sign up at agora.io and create a project. Note your App ID (shown in the project dashboard) and App Certificate (in Project Settings → Basic Info — keep this secret). Export your FlutterFlow project to a local Flutter codebase. In pubspec.yaml, add agora_rtc_engine (version ^6.3.2) and permission_handler (version ^11.3.1). Run flutter pub get. For iOS, open ios/Runner/Info.plist and add usage descriptions for NSCameraUsageDescription and NSMicrophoneUsageDescription. For Android, add CAMERA, RECORD_AUDIO, INTERNET, and MODIFY_AUDIO_SETTINGS permissions to AndroidManifest.xml. Store your Agora App ID in your app's constants file — never hardcode the App Certificate client-side.
1# pubspec.yaml additions2dependencies:3 agora_rtc_engine: ^6.3.24 permission_handler: ^11.3.156# ios/Runner/Info.plist additions:7# <key>NSCameraUsageDescription</key>8# <string>Video calling requires camera access</string>9# <key>NSMicrophoneUsageDescription</key>10# <string>Video calling requires microphone access</string>1112# android/app/src/main/AndroidManifest.xml additions:13# <uses-permission android:name="android.permission.CAMERA" />14# <uses-permission android:name="android.permission.RECORD_AUDIO" />15# <uses-permission android:name="android.permission.INTERNET" />16# <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />Expected result: flutter pub get completes. Build the app on a real device — the permissions dialogs appear when Agora requests camera/mic access for the first time.
Deploy a Cloud Function to generate Agora channel tokens
Deploy a Cloud Function to generate Agora channel tokens
Agora tokens are time-limited credentials that authorize a user to join a specific channel. They are generated using your App Certificate combined with the user's UID and channel name. The App Certificate must never leave the server — if exposed, anyone can join any channel in your Agora project. Create a Cloud Function called 'getAgoraToken' that accepts channel_name and user_uid as parameters, generates a token with a 1-hour expiry using the agora-access-token npm package, and returns the token string. In FlutterFlow, add this as an API Call pointing to the Cloud Function. Call it before joining any video channel.
1// functions/getAgoraToken.js2const functions = require('firebase-functions');3const { RtcTokenBuilder, RtcRole } = require('agora-access-token');45exports.getAgoraToken = functions.https.onCall(async (data, context) => {6 if (!context.auth) {7 throw new functions.https.HttpsError('unauthenticated', 'Must be signed in');8 }9 const { channel_name } = data;10 if (!channel_name) {11 throw new functions.https.HttpsError('invalid-argument', 'channel_name required');12 }13 const appId = functions.config().agora.app_id;14 const appCertificate = functions.config().agora.app_certificate;15 const uid = 0; // 0 = auto-assign UID16 const role = RtcRole.PUBLISHER;17 const expireTimeSeconds = 3600; // 1 hour18 const currentTime = Math.floor(Date.now() / 1000);19 const privilegeExpireTime = currentTime + expireTimeSeconds;20 const token = RtcTokenBuilder.buildTokenWithUid(21 appId, appCertificate, channel_name, uid, role, privilegeExpireTime22 );23 return { token, channel_name, app_id: appId };24});Expected result: Calling the Cloud Function with a channel name returns a token string. Test from the Firebase console — the token should be a ~200-character alphanumeric string.
Create the Agora Video Call Custom Widget
Create the Agora Video Call Custom Widget
Create a Custom Widget called 'AgoraVideoCallWidget'. It accepts four parameters: appId (String), token (String), channelName (String), and uid (Integer). In the widget's build method, initialize the Agora RtcEngine with the appId, enable video, set a video encoder configuration (640x360 at 15fps for a balanced quality-bandwidth tradeoff), and call joinChannel with the token and channelName. Use a Stack to layer the remote user's video (AgoraVideoView with a RemoteVideoViewController) on the full background, and the local user's camera preview (AgoraVideoView with a LocalVideoViewController) as a small draggable picture-in-picture in the top-right corner. Add mute and camera toggle buttons at the bottom of the Stack, and a Leave Call button that calls engine.leaveChannel() and triggers a callback parameter to navigate back.
1// lib/custom_widgets/agora_video_call_widget.dart (partial)2import 'package:agora_rtc_engine/agora_rtc_engine.dart';3import 'package:flutter/material.dart';4import 'package:permission_handler/permission_handler.dart';56class AgoraVideoCallWidget extends StatefulWidget {7 final String appId;8 final String token;9 final String channelName;10 final VoidCallback onCallEnded;1112 const AgoraVideoCallWidget({13 super.key, required this.appId, required this.token,14 required this.channelName, required this.onCallEnded,15 });1617 @override State<AgoraVideoCallWidget> createState() => _AgoraVideoCallWidgetState();18}1920class _AgoraVideoCallWidgetState extends State<AgoraVideoCallWidget> {21 late RtcEngine _engine;22 int? _remoteUid;23 bool _muted = false;24 bool _cameraOff = false;2526 @override void initState() {27 super.initState();28 _initAgora();29 }3031 Future<void> _initAgora() async {32 await [Permission.camera, Permission.microphone].request();33 _engine = createAgoraRtcEngine();34 await _engine.initialize(RtcEngineContext(appId: widget.appId,35 channelProfile: ChannelProfileType.channelProfileCommunication));36 _engine.registerEventHandler(RtcEngineEventHandler(37 onUserJoined: (conn, remoteUid, elapsed) =>38 setState(() => _remoteUid = remoteUid),39 onUserOffline: (conn, remoteUid, reason) =>40 setState(() => _remoteUid = null),41 ));42 await _engine.enableVideo();43 await _engine.startPreview();44 await _engine.joinChannel(45 token: widget.token, channelId: widget.channelName,46 uid: 0, options: const ChannelMediaOptions());47 }4849 @override void dispose() {50 _engine.leaveChannel();51 _engine.release();52 super.dispose();53 }5455 @override Widget build(BuildContext context) => _buildCallUI();5657 Widget _buildCallUI() => Stack(children: [58 // Remote video fills the background59 _remoteUid != null60 ? AgoraVideoView(controller: VideoViewController.remote(61 rtcEngine: _engine, canvas: VideoCanvas(uid: _remoteUid!),62 connection: RtcConnection(channelId: widget.channelName)))63 : const Center(child: Text('Waiting for other participant...',64 style: TextStyle(color: Colors.white))),65 // Local preview in top-right corner66 Positioned(top: 20, right: 20, width: 100, height: 140,67 child: AgoraVideoView(controller: VideoViewController(68 rtcEngine: _engine, canvas: const VideoCanvas(uid: 0)))),69 // Call controls70 Positioned(bottom: 40, left: 0, right: 0,71 child: Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [72 IconButton(icon: Icon(_muted ? Icons.mic_off : Icons.mic, color: Colors.white),73 onPressed: () { setState(() => _muted = !_muted); _engine.muteLocalAudioStream(_muted); }),74 FloatingActionButton(backgroundColor: Colors.red,75 onPressed: () async { await _engine.leaveChannel(); widget.onCallEnded(); },76 child: const Icon(Icons.call_end)),77 IconButton(icon: Icon(_cameraOff ? Icons.videocam_off : Icons.videocam, color: Colors.white),78 onPressed: () { setState(() => _cameraOff = !_cameraOff); _engine.muteLocalVideoStream(_cameraOff); }),79 ])),80 ]);81}Expected result: The Custom Widget renders video from the local camera. When a second device joins the same channel with the same token, both remote and local video streams appear.
Build the Firestore chat subcollection for in-call messaging
Build the Firestore chat subcollection for in-call messaging
In Firestore, create a 'calls' collection. Each document represents an active call with fields: channel_name, participants (Array of UIDs), created_at. Under each call document, create a subcollection called 'messages' with fields: sender_uid, sender_name, text, sent_at (Timestamp), and read (Boolean). In FlutterFlow, create a chat message Component with the sender name, message text, and timestamp. On your call page, add a Backend Query on calls/{channel_name}/messages ordered by sent_at ascending with real-time enabled. Bind the messages to a ListView. Add a TextField and Send button at the bottom — the Send action creates a new document in the messages subcollection with the current user's UID and the typed text.
Expected result: Both call participants can send and receive text messages that appear in real time during the call. Messages persist after the call ends for review.
Design the split-view call and chat layout
Design the split-view call and chat layout
Create a 'VideoCallPage' in FlutterFlow with a dark background. The page layout is a Column with three sections: First, a Flexible widget (flex: 3) containing the AgoraVideoCallWidget Custom Widget — this takes 3/5 of the screen height. Second, a thin horizontal divider. Third, a Flexible widget (flex: 2) containing the chat panel — a Column with an Expanded ListView of message bubbles at the top and a fixed-height Row with a TextField and send button at the bottom. This flex-based split keeps the video prominent while making chat accessible without leaving the call. Add a toggle icon button that collapses the chat section to give video more space for users who want full-screen video.
Expected result: The call screen shows video in the top portion and scrollable chat below. The video does not compress when the keyboard opens. Participants can type messages while watching video.
Complete working example
1// callManagement.js — Cloud Functions for Agora token generation2// and call session lifecycle management34const functions = require('firebase-functions');5const admin = require('firebase-admin');6const { RtcTokenBuilder, RtcRole } = require('agora-access-token');78if (!admin.apps.length) admin.initializeApp();9const db = admin.firestore();1011// Generate Agora token for joining a video channel12exports.getAgoraToken = functions.https.onCall(async (data, context) => {13 if (!context.auth) {14 throw new functions.https.HttpsError('unauthenticated', 'Sign in required');15 }16 const { channel_name } = data;17 if (!channel_name) {18 throw new functions.https.HttpsError('invalid-argument', 'channel_name is required');19 }20 const appId = functions.config().agora?.app_id;21 const appCertificate = functions.config().agora?.app_certificate;22 if (!appId || !appCertificate) {23 throw new functions.https.HttpsError('failed-precondition', 'Agora config missing');24 }25 const expireTime = Math.floor(Date.now() / 1000) + 3600;26 const token = RtcTokenBuilder.buildTokenWithUid(27 appId, appCertificate, channel_name, 0,28 RtcRole.PUBLISHER, expireTime29 );30 // Log the call session in Firestore31 await db.collection('calls').doc(channel_name).set({32 channel_name,33 initiator_uid: context.auth.uid,34 participants: admin.firestore.FieldValue.arrayUnion(context.auth.uid),35 status: 'active',36 created_at: admin.firestore.FieldValue.serverTimestamp(),37 }, { merge: true });38 return { token, app_id: appId, channel_name };39});4041// End a call session — clean up and record duration42exports.endCall = functions.https.onCall(async (data, context) => {43 if (!context.auth) {44 throw new functions.https.HttpsError('unauthenticated', 'Sign in required');45 }46 const { channel_name } = data;47 if (!channel_name) return { success: false };48 const callRef = db.collection('calls').doc(channel_name);49 const callSnap = await callRef.get();50 if (!callSnap.exists) return { success: false };51 const createdAt = callSnap.data().created_at?.toMillis() || Date.now();52 const durationSeconds = Math.round((Date.now() - createdAt) / 1000);53 await callRef.update({54 status: 'ended',55 ended_at: admin.firestore.FieldValue.serverTimestamp(),56 duration_seconds: durationSeconds,57 });58 return { success: true, duration_seconds: durationSeconds };59});6061// Scheduled cleanup: mark calls as ended if inactive for > 2 hours62exports.cleanupStaleCalls = functions.pubsub63 .schedule('every 30 minutes').onRun(async () => {64 const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);65 const snap = await db.collection('calls')66 .where('status', '==', 'active')67 .where('created_at', '<', twoHoursAgo)68 .get();69 const batch = db.batch();70 snap.docs.forEach(d => batch.update(d.ref, { status: 'abandoned' }));71 await batch.commit();72 console.log(`Cleaned up ${snap.size} stale calls`);73 return null;74 });Common mistakes when implementing a Video Call and Messaging System in FlutterFlow
Why it's a problem: Generating Agora channel tokens on the client side using the App Certificate
How to avoid: Generate tokens exclusively in a Firebase Cloud Function that reads the App Certificate from Firebase Functions config (server-side environment variables). The client calls the function and receives only the time-limited token.
Why it's a problem: Not requesting camera and microphone permissions before joining the Agora channel
How to avoid: Use the permission_handler package to explicitly request both permissions before any Agora initialization. Check the PermissionStatus and show an explanation dialog if the user denies — telling them the feature requires camera and microphone access.
Why it's a problem: Building the chat message list with a new Backend Query inside each message Component
How to avoid: Pass the message document data as a Component Parameter from the parent ListView, which is already bound to the single Backend Query result. The message Component only displays what is passed to it — no internal queries needed.
Best practices
- Always generate Agora tokens server-side — never embed the App Certificate in client code.
- Set a token expiry appropriate for your use case: 1 hour for typical calls, with logic to request a new token from the Cloud Function if the call is still active near expiry.
- Use Agora's channel quality callback (onRtcStats) to display a connection quality indicator in the UI so users know if bandwidth is degraded.
- Store call session metadata (duration, participant UIDs, start time) in Firestore for billing, analytics, and support purposes.
- Implement a call waiting/ringing state using Firestore document status field: 'ringing' → 'active' → 'ended'. Update this from both clients so both see the correct state.
- Test video calls on a real device over cellular — emulators and desktop WiFi don't represent real-world audio/video quality issues.
- Handle the case where a participant's app is killed mid-call: listen to Firestore call document status changes so the remaining participant sees 'Call ended' instead of waiting indefinitely.
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I'm integrating Agora.io video calling into a Flutter app. Write a StatefulWidget called AgoraCallScreen that: initializes the RtcEngine with an App ID, requests camera and microphone permissions using permission_handler, joins a channel using a token and channelName passed as constructor parameters, displays remote video in full-screen and local video as a picture-in-picture overlay, and handles the onUserJoined and onUserOffline Agora callbacks to update the UI. Include a mute button, camera off button, and leave call button.
I'm building a video call feature in FlutterFlow. I have a Custom Widget called AgoraVideoCallWidget that takes appId, token, and channelName as parameters. I also have a Cloud Function API Call named 'getAgoraToken' that returns a token when given a channel_name. Walk me through building the action flow on my 'Start Call' button that: calls the getAgoraToken API, stores the token in a Page State variable, and then shows the AgoraVideoCallWidget on screen.
Frequently asked questions
Does Agora work for group video calls, not just 1-on-1?
Yes. Agora supports up to 1,000 video streams in a single channel. For group calls, display multiple remote UIDs using a GridView of AgoraVideoView widgets, each with a different remoteUid. Manage the list of active participants using the onUserJoined and onUserOffline callbacks.
How much does Agora cost?
Agora offers 10,000 free minutes per month on the free tier, which is generous enough for testing and small apps. Paid usage is approximately $0.0099 per minute per user for standard quality — a 30-minute 1-on-1 call costs about $0.60. Pricing is lower for audio-only calls.
Can I record video calls?
Yes. Agora's Cloud Recording API lets you record calls to cloud storage. Add a 'Start Recording' Cloud Function that calls Agora's recording API. Recordings are stored in Agora's storage (or you can configure an AWS S3/Azure bucket) and accessible via a URL you store in the call's Firestore document.
Will this work in FlutterFlow's web preview?
The Agora Flutter SDK supports web builds, but the Custom Widget requires code export to test. The Custom Widget won't render in FlutterFlow's Run Mode. Test video calling by running flutter run from your exported project targeting Chrome (flutter run -d chrome).
How do I handle a poor network connection gracefully?
Agora's SDK fires the onNetworkQuality callback with quality ratings 0-5. Listen to this callback and display a connection quality indicator (e.g., WiFi signal bars) in the call UI. When quality drops below 2, show a 'Poor connection' banner. Agora's Adaptive Bitrate (ABR) automatically reduces video resolution to maintain the call.
Can I add screen sharing to the video call?
Yes on Android (flutter screen capture is supported). On iOS, screen sharing from within the app requires a Broadcast Upload Extension — a separate app target in Xcode. Agora's documentation covers this setup. This is advanced functionality requiring significant post-export customization.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation