How to use: paste this entire document into your AI coding agent (Copilot, Cursor,
Claude Code, etc.) inside the Flutter app you want to integrate. The agent will inspect
your app, install and configure embed_flutter, ask you the few decisions only you can
make, then validate the integration end-to-end and report.
Every rule below is derived from the SDK's source (v0.2.0). Where this prompt and a
generic Flutter tutorial disagree, this prompt is correct for this SDK. Flutter is
materially simpler to integrate than the React Native SDK: there is no native
bootstrap code (no MainApplication/AppDelegate edits, no registerGlobals, no
Babel/Metro/worklets/Gesture-Handler setup) — the livekit_client plugin registers its
own native side.
You are an expert Flutter integration agent. Take this application from
zero → working → validated RevRag SDK integration with minimal manual work.
Prime directives
Inspect first, modify second. Never assume Flutter/Dart version, navigation
library, state management, or native setup. Read the project.
Preserve host behavior. Merge into existing config; never blindly overwrite
AndroidManifest.xml, Info.plist, Podfile, main.dart, or the root widget.
Never claim success without running the validation in §16–§21 and observing the
log lines named there.
Ask the developer for the decisions in §13 — do not guess them.
Never hide a crash or error. The SDK ships its own crash-guard that suppresses
only LiveKit/WebRTC async errors (§21); everything else must surface. Report
SDK-side issues you cannot fix.
Do not add dependencies, permissions, or config the SDK does not require.
a build constant, --dart-define, CI-stamped value, or package_info — the SDK does NOT auto-derive it (§10)
Platform targets
android/app/build.gradleminSdkVersion; ios/Podfileplatform :ios; is web/desktop a target? (unsupported, §2)
VALIDATE: you can state, in one sentence each, the navigation type, the runApp
location, whether a login gate exists, and where the app version comes from.
Supported: Flutter ≥ 3.0 (Dart-3 sound null-safety, Dart 3.0–3.x), Android
minSdkVersion ≥ 21 and compileSdk 34+ (floors from flutter_webrtc/livekit_client,
not the SDK's Dart), iOS deployment target 13.0. Web and desktop are not supported
(LiveKit mobile stack) — Android + iOS only.
Target
Floor
Notes
Flutter
≥ 3.0
Dart-3 baseline.
Dart
3.0 – 3.x
The SDK is Dart-3 only.
Android
minSdk 21, compileSdk 34+
Non-negotiable WebRTC floor.
iOS
13.0
platform :ios, '13.0' in the Podfile.
Web / desktop
unsupported
Android + iOS only.
If any target is below floor: explain, raise it, re-validate. Do not upgrade/downgrade
unrelated dependencies.
VALIDATE:flutter pub get resolves with no version-solve error. If it fails on
livekit_client / flutter_webrtc / permission_handler / lottie, an existing host
pin is conflicting — reconcile the range (§4); do not fork the plugin.
There is nothing to configure.embed_flutter pulls livekit_client
(→ flutter_webrtc), permission_handler, shared_preferences, path_provider, http,
and lottie transitively; pub's solver shares one copy of each. Unlike React Native,
there is no Babel plugin, no Metro config, no worklets, no Gesture-Handler root view,
and no registerGlobals.
Verify a single resolved copy of the native stack:
Expected: exactly one resolved version of each. A second copy of livekit_client
pulled in by the host is a version-solve failure, not a silent double-runtime — reconcile
the range.
Permissions. The SDK ships no package-level AndroidManifest.xml — the host
declares the permissions (some, like RECORD_AUDIO/MODIFY_AUDIO_SETTINGS, also merge
from flutter_webrtc; declaring them explicitly is safe). Add to
android/app/src/main/AndroidManifest.xml inside <manifest>, above <application>:
No MainApplication / MainActivity code is required. The plugin auto-registers;
MainActivity stays a plain FlutterActivity. (There is no
audioRecordSamplesDispatcher-style manual-setup trap as in RN.)
android/app/build.gradle: minSdkVersion 21 (or higher), compileSdkVersion at
the Flutter default (34+). The mic runtime prompt is done by the SDK during
startCall via permission_handler.
Build: flutter build apk --debug. On failure decide SDK-related vs pre-existing;
fix; rebuild. Never suppress errors.
VALIDATE: APK builds; the merged manifest
(build/app/intermediates/merged_manifests/.../AndroidManifest.xml) contains
RECORD_AUDIO.
ios/Runner/Info.plist — NSMicrophoneUsageDescription is mandatory (iOS
kills the app on a mic access without it):
<key>NSMicrophoneUsageDescription</key><string>This app needs microphone access for voice calls.</string>
ios/Podfile — set the platform and add the permission_handler macro. The SDK
requests the mic through permission_handler (Permission.microphone.request()), so
PERMISSION_MICROPHONE=1 is required — without it the dialog never appears and the
permission is always reported denied:
platform :ios, '13.0'post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) target.build_configurations.each do |config| config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ '$(inherited)', 'PERMISSION_MICROPHONE=1', ] end endend
Then cd ios && pod install.
No AppDelegate.swift edits are required — there is no LivekitReactNative.setup
analog.
The bundled examples/BajajFinance/ios/Podfile omits both the macro and the
platform :ios, '13.0' line — do not copy it verbatim, or iOS mic requests are
auto-denied and the WebRTC pods may fail to build below iOS 13.
flutter pub getdart analyze # host app must stay at its own baselineflutter build apk --debugflutter build ios --no-codesign
VALIDATE: both platform builds compile. A red analyzer error introduced by the
integration (e.g. a wrong embedInitialize signature) must be fixed here, not deferred.
Call embedInitializeonce, before runApp. All parameters are named; apiKey
and appVersion are required:
import 'package:embed_flutter/embed_flutter.dart';void main() { embedInitialize( apiKey: API_KEY, // REQUIRED (named), from the app's config/env appVersion: APP_VERSION, // REQUIRED (named) — see §10 embedUrl: EMBED_URL, // optional; RevRag-provided host, defaults to prod onResult: (bool success, String? error) { if (!success) debugPrint('Embed init failed: $error'); }, ); runApp(const MyApp());}
embedInitialize is the only place credentials and app version enter the SDK.
There is no EmbedProvider widget and no positional apiKey —
embedInitialize('key', ...) will not compile.
It is synchronous and fail-open: it never throws at your call site. Do NOT block the
app on init; render the app — the widget simply won't appear until config arrives.
It installs the host-crash safety net (EmbedCrashGuard, §21) before any LiveKit work.
Then wrap the tree — EmbedWidget must be the outermost widget (above MaterialApp,
above any portal/overlay/theme wrapper, or the FAB and highlights end up underneath):
VALIDATE: the call compiles with named apiKey: / appVersion:; onResult fires.
On the first run with a valid key you see [ApiService] ← GET /initialize status=200 and
[Embed][ui_config] … full payload: {…}.
The key is sent as the X-Revrag-Embedded-Key header on GET /embedded-agent/initialize. A wrong or missing key still compiles and runs — the fetch
returns non-200, so the SDK logs [ApiService] ← GET /initialize status=<code> then
[ApiService] ⚠️ /initialize returned non-200 (<code>) — UI config is NULL, widget will use SDK defaults; onResult fires (false, "Failed to fetch widget configuration and no cached configuration was available.") (when nothing is cached), and the agent button
never appears.
Do not hard-code secrets in source if an env system exists. If missing → stop,
report "API key missing", ask the developer.
VALIDATE: with the real key you see [ApiService] ← GET /initialize status=200 and
onResult(success=true). A non-200 (401 / 403) means the key or header is wrong — do
not proceed to §11 until fixed.
appVersion is required in embedInitialize and is the only intake — there is
no auto-derivation from package_info and no post-init setter. It is sent as
?app_version=<value> on /initialize and attached to every outbound event in the
sdk block as app_version.
Derive it from a single source of truth (a build constant, a --dart-define, or the
value your CI stamps). Never pass "" or a stale literal.
VALIDATE: the /initialize URL query shows ?app_version=<your value>, and outbound
event payloads carry the same value in the sdk block.
The agent stays hidden and no call can connect until the SDK knows the user. After
login, send:
import 'package:embed_flutter/embed_flutter.dart';embedEvent( EventKeys.USER_DATA, UserEventPayload( app_user_id: userId, // required — your stable user id data: {'name': 'Priya', 'plan': 'gold'}, // optional context ),);
Facts the integration must respect:
Place it immediately after auth succeeds. For a no-auth/demo app, send it in the first
screen's initState.
Without it, a call's token fetch is skipped — the SDK logs
[ApiService] token request skipped: no app_user_id yet. Send a USER_DATA event … after login, before the agent is expected to connect. — so the call cannot connect.
The SDK queues events if config hasn't loaded yet and flushes automatically (§18).
EmbedWidget.clearUserSession() / embedClearStorageCache() clears identity — re-send
USER_DATA for the next user.
embedEvent returns a Future<bool> and never throws.
Required order: embedInitialize → init success → USER_DATA → widget config → FAB shown
on an enabled route → interactions → call.
The FAB renders only after/initialize returns a widget_config (position, colors,
avatar, collapsed view, inactivity nudge). It is dashboard-driven — there are no
host props for position or colors. The SDK merges it and type-coerces every field, so a
malformed value falls back to a default (it never crashes a build).
Case
Expected
Observe
A. config exists
FAB appears on allowed routes; agent_visible fires
[Embed][ui_config] … full payload: {…}
B. config null / {}
default styling (black button, #B391F3 gradient), no crash
onResult(false, …); [ApiService] ⚠️ /initialize returned non-200
D. delayed config
app must not assume config is ready; FAB appears when it lands
on a cold start the FAB may need the next route change to mount
Also confirm the native cache store: on-device UI-graph writes log under the [EmbedCache]
tag; [EmbedCache] store unavailable, memory-only: … means the file store failed and the
cache is in memory only.
After inspecting, list the actual route names you found (the names the navigator
reports — e.g. home, checkout; not display titles), then ask:
Which routes should the RevRag widget appear on? (Offer: all-except via
showOnAllRoutes: true + disabledRoutes, or an explicit enabledRoutes list.)
Which routes must never show the agent / must end a call on entry (login, OTP,
payment, regulated data)? → disabledRoutes (entering one ends a live call).
Should a live call survive navigation between agent routes? →
continuity: EmbedButtonContinuity.keepVisible vs reset (default) (§15).
Do different route groups need different show/delay behavior? →
buttonVisibility groups (EmbedButtonVisibilityConfig, §15).
App version source if it can't be inferred; API key location.
Any WebView-only, native-picker, or custom-navigation flows (see §22).
Wait for answers before finalizing screen integration.
Register the observer; EmbedWidget wraps MaterialApp. Detection rules by navigator:
Named routes / onGenerateRoute — register EmbedNavigatorObserver() on
MaterialApp.navigatorObservers. Always pass settings: settings in onGenerateRoute
so routes have names.
GoRouter — put EmbedNavigatorObserver() in GoRouter(observers: [...]) and use
each route's name: (not path:) in enabledRoutes.
Tabs / ShellRoute / BottomNavigationBar / IndexedStack — tab bodies live in a
nested navigator the top observer can't see. Wrap each tab body in
EmbedRouteListener(routeName: 'home', child: …) whose routeName matches
enabledRoutes.
Anonymous pushes — a MaterialPageRoute with no settings reports a null name
and is invisible. Give it settings: RouteSettings(name: '…'), or supply a custom
EmbedNavigatorObserver(nameExtractor: (route) => …).
Matching — routeMatchMode is exact (default), startsWith, or contains
(RouteMatchMode). Leading slashes are normalized ('home' == '/home'). This is the
analog of RN's screen-matching tiers.
VALIDATE:EmbedWidget(debug: true) turns on the SDK's verbose logging (EmbedLog),
which narrates config load, route changes, and FAB-visibility decisions; navigate to an
enabled route and confirm the FAB appears (after USER_DATA).
Visibility groups — {defaultDelayMs, groups}, each group {id, screens, continuity, delayMs?, delayPolicy?}; first match wins (see INTEGRATION.md §7.2).
Behavior to verify:
Under showOnAllRoutes: true, a call is continuous across screens — it is not
disconnected on each navigation. Add a route to disabledRoutes to force a disconnect
there (RN's endCallWhenHiddenByVisibility is automatic in Flutter: leaving the
allow-list ends the call).
A group's screens join the allow-list — a route listed in a buttonVisibility
group shows the FAB even if it is not in enabledRoutes.
VALIDATE: navigate across two enabled routes with continuity: keepVisible during a
live call — the call persists; navigate to a disabledRoutes route — the call ends
(agent_conversation_ended).
Confirm this order in the logs on a healthy first session, and that nothing fires out of
order:
[ApiService] ← GET /initialize status=200 → [Embed][ui_config] … full payload
USER_DATA sent (after login) → token fetch unlocked
agent_visible on an enabled route
on FAB tap / startCall: [CallTiming] t=0ms startCall() invoked → [CallTiming] t=<ms>ms … → ROOM CONNECTED — call is live → agent_conversation_started (carries a
flat call_id)
on end: agent_conversation_ended (same call_id)
Invalid patterns to catch: a call before USER_DATA ([ApiService] token request skipped: no app_user_id yet …), agent_visible never firing (re-check §11 and §14), a second
EmbedWidget in the tree.
For the agent to act (tap/scroll/type), it reads your Flutter Semantics tree — the
same data screen readers use. Mission frames arrive on the LiveKit data channel
([EmbedWidget] 📩 LiveKit DataReceived); element highlights log [Embed HIGHLIGHT] pulse stableId=…; the durable on-device UI-graph writes under [EmbedCache]. Deep guidance
lives in HOST_INTEGRATION.md; the essentials:
Icon-only buttons need Semantics(label: …, button: true, …).
Chips/toggles must surface selection (Semantics(selected: …) or use
ChoiceChip/FilterChip).
Critical CTAs (login, pay, submit) should carry
Semantics(identifier: 'checkout.pay') — the SDK matches identifier first.
Secure fields (TextField(obscureText: true) → SemanticsFlag.isObscured) are
redacted at capture and stay redacted (sticky) — typed secrets never leave the
device in the UI snapshot.
Snapshot/cache capture is gated by action_config.flags.dataCapture (dashboard-side).
Host query: EmbedWidget.isWidgetTreeSnapshotEnabled.
What the tree cannot see: anything with no Semantics (raw CustomPaint, some
third-party UI kits), platform views / WebViews, and native pickers.
VALIDATE: turn on TalkBack/VoiceOver and tab through a key screen — anything the reader
can't name is invisible to the agent. Missions failing with target_not_found point here.
An over-budget screen logs [EmbedCache] screen "…" is <N> bytes, over the ….
Automatic capture. The SDK hit-tests pointer events to detect taps on buttons and
watches TextField controllers for text changes — all gated by the dashboard
clickTracking flag (ActionGates.clickTrackingEnabled) and only while the widget is
active (EmbedWidget.isActive). Rapid taps (3 within 1 s) emit a rage_click analytics
event automatically. This is the Flutter analog of RN's touchable patching, done through
the gesture/hit-test layer rather than by wrapping widgets.
The agent's targeting id (stableId) is separate from analytics: it resolves
Semantics(identifier:) → text → role. Put a stable identifier on every meaningful
control:
Observe agent events with embedOnAgent(...) / SdkEventName (see INTEGRATION.md §9).
VALIDATE: events flush and return 2xx only once the FAB is live on an enabled route
(EmbedWidget.isActive && EmbedWidget.isLive) andUSER_DATA is in place; before that
they sit in a pending queue and are dropped if older than 60 s (verified: age > 60).
An event sent on a disabled route waits until you reach an enabled one.
Preconditions the SDK enforces: config loaded (FAB present), mic permission granted (the
SDK prompts via permission_handler; denied → EmbedWidget(onPermissionStatusChanged:)
gets false + microphone_permission_denied), and USER_DATA stored (else the token
fetch is skipped, §11). Call start is single-flight (startCall no-ops while already
active/connecting).
Programmatic API (see INTEGRATION.md §10):
await EmbedWidget.startCall(); // co-pilot (default)await EmbedWidget.startCall(agentTriggerMode: AgentTriggerMode.WORKFLOW);await EmbedWidget.endCall();EmbedWidget.isCallActive; // boolEmbedWidget.collapseWidget(); EmbedWidget.expandWidget(); // bar <-> FABawait EmbedWidget.clearUserSession(); // on logout
startCall() does not auto-expand the bar; pair with expandWidget() if you start
from your own UI.
AgentTriggerMode: CO_PILOT (default — agent drives your screens, panels
dropped) vs WORKFLOW (Revrag-owned panel/avatar surface).
Not present in the Flutter SDK (unlike RN): pauseAgent / resumeAgent /
isAgentPaused / isWidgetExpanded. Do not reference them.
VALIDATE (real device):startCall() → [CallTiming] t=0ms startCall() invoked →
ROOM CONNECTED — call is live → agent_conversation_started → isCallActive true →
endCall() → agent_conversation_ended. Then a second call starts cleanly.
The SDK is engineered so nothing it does can crash the host (INTEGRATION.md §12):
public API returns safe defaults, every timer/listener/post-frame is guarded, config is
type-coerced, and a chained PlatformDispatcher.onError (installed by embedInitialize)
swallows onlylivekit_client/flutter_webrtc async errors (e.g. a renegotiation
NegotiationError), delegating everything else to the host's handler untouched.
Grep your crash reporting (Sentry / Crashlytics / logs) for these real SDK lines:
[EmbedSDK] suppressed voice-stack async error — the guard working as intended
(informational; a LiveKit/WebRTC error was kept from crashing the host).
[ApiService] ⚠️ /initialize returned non-200 / [ApiService] ⚠️ /initialize fetch FAILED — config load failed; FAB won't show (key/URL/network).
[ApiService] token request skipped: no app_user_id yet — USER_DATA not sent (§11).
Embed init failed (from your onResult) — surfaces config-load failures.
Native (platform) failures to classify — not SDK crashes: attempted to access privacy-sensitive data without a usage description (iOS NSMicrophoneUsageDescription
missing), a mic prompt that never appears (iOS PERMISSION_MICROPHONE=1 missing, §6),
uses-sdk:minSdkVersion … cannot be smaller than version 21 (Android floor, §5). Never
swallow a native crash; classify app-side vs SDK-side.
Work through every row. "Ask" = a developer decision.
UI / layout
EmbedWidget not outermost → a global overlay/portal/theme wrapper above it can
occlude the FAB and highlights; keep EmbedWidget above MaterialApp; verify on a
device.
Bottom sheets / dialogs / overlays drawn above the FAB → the agent's highlight and
the FAB can end up under them; verify z-order; surface the sheet with
Semantics(container: true) so the agent sees it as its own subtree.
SafeArea / keyboard insets → the FAB position and highlight bounds can drift near
the bottom while the keyboard is open; verify.
Components / design system (Semantics)
Icon-only buttons without Semantics(label:) → invisible/positional ids that shift;
label them and set button: true.
Custom GestureDetector tappables with no semantics → not tappable by the agent;
wrap in Semantics(button: true, onTap: …) or use a Material button.
Selection shown by style only (custom radios/chips/tabs) → verification needs
Semantics(selected:) / Semantics(checked:); add it (or use ChoiceChip/Switch).
CustomPaint / canvas UIs / third-party kits with no Semantics → invisible to the
agent (§17); wrap the meaningful bits.
Inputs / forms
Uncontrolled TextField (no controller) → harder to verify typed text and to detect
changes for auto-capture; prefer a TextEditingController.
Password / OTP fields → mark them obscureText: true so SemanticsFlag.isObscured
redacts them at capture (sticky). Redaction is per node — mark each secret field,
not the wrapper.
Read-only field used as a picker trigger → the agent may try to type into it; use a
tappable + Text instead.
Lists / performance
Lazy lists (ListView.builder, Sliver…) → off-screen rows aren't in the tree
(not captured) until scrolled into view; the find ladder scrolls-and-recaptures, but
give rows a stable Semantics(identifier:) so they're targetable when visible.
Very heavy screens → capture measures the tree; large trees can hit the cache byte
budget ([EmbedCache] screen "…" is <N> bytes, over the …) and get trimmed.
Navigation-adjacent
Anonymous routes — a MaterialPageRoute without settings ⇒ no route name ⇒
invisible to the observer; give every route a name.
GoRouter observer omitted — must be on GoRouter(observers: […]), not only on an
inner MaterialApp; use route name: not path:.
Tabs via IndexedStack without EmbedRouteListener — the SDK can't tell which tab
is active; wrap each tab body.
Route names differing between enabledRoutes and EmbedRouteListener — they must be
identical strings.
disabledRoutes intent — a disabled route ends an active call; confirm that's
desired (e.g. a payment WebView screen).
Native / platform
Backgrounded calls (iOS) → the verified setup covers foreground calls. If a call
must keep running while the app is backgrounded, iOS additionally needs audio under
UIBackgroundModes in Info.plist; the SDK and example do not set it — confirm the
requirement and add it deliberately (don't assume it's handled). Ask.
Other audio-session users (audio players, video, VoIP) → the SDK drives the audio
session per call; a host that also owns it may conflict. Ask.
onPermissionStatusChanged not wired → an EmbedWidget callback prop; if the host
doesn't pass it, a mic denial is silent to the host UI (the SDK still doesn't crash and
still emits microphone_permission_denied).
Build / tooling
Transitive-dependency version clash — the SDK pins livekit_client,
flutter_webrtc, permission_handler, shared_preferences, path_provider, http,
and lottie. A conflicting host major (especially lottie / permission_handler)
fails the pub version solve; reconcile the range, don't fork.
Existing livekit_client / flutter_webrtc at a conflicting version → reconcile the
pub range; do not vendor a second copy.
Web/desktop build target → unsupported (§2); guard the widget out of those flavors.
Runtime / lifecycle
Host overriding PlatformDispatcher.onErrorafterembedInitialize → the host's
handler wins; make sure it re-chains, or the LiveKit crash-guard (§21) is lost.
Sentry/Crashlytics console capture → the guard's informational
[EmbedSDK] suppressed voice-stack async error line is expected, not a fault; don't
page on it.
Security / privacy
disabledRoutes ≠ no capture of other screens — the durable UI-graph cache is
written per screen while capture is enabled (dataCapture flag). Mark sensitive fields
(obscureText for passwords/OTP — a hard guarantee) and ask the developer about
regulated screens.
Store-listing disclosure — the app requests microphone (and Bluetooth) permissions;
disclose in the listing.