Browse In-App Agent

Embed Flutter SDK

Step-by-step guide to integrate the RevRag Flutter embed SDK.

Packageembed_flutter
Version0.4.0
RequiresFlutter ≥ 3.29.0 / Dart ≥ 3.7.0
CoversVoice agent, EmbedWidget, navigation-aware visibility, events, call control, and native platform setup

iOS microphone setup is required. Skipping it is the most common source of "the mic dialog never appears" and silent voice failures. See iOS Setup.

Android needs nothing from you: from 0.4.0 the SDK declares and requests its own permissions. See Android Setup.

Follow this guide in order the first time you integrate:

StepWhat you doDetails
1. InstallAdd embed_flutter to your pubspec.yaml§1
2. Native setupiOS microphone setup — required. Android: nothing to add§1.2 · §1.1
3. InitializeCall embedInitialize() before runApp()§3
4. Wrap your appMake EmbedWidget the outermost widget§4
5. Connect navigationTell the SDK when the route changes, so it can show or hide the agent button§5
6. IdentifySend USER_DATA once you have a logged-in user ID — the only required event§6.1
7. Log outCall EmbedWidget.clearUserSession() when your user logs out — the user is otherwise kept across app restarts§6.1

Table of Contents

  1. Installation
  2. How the SDK Works (Mental Model)
  3. Initialization
  4. Wrapping Your App with EmbedWidget
  5. Navigator Integration
  6. Sending Events
  7. EmbedWidget Parameters Reference
  8. Advanced: Widget Visibility and Positioning
  9. Advanced: Listening to SDK Events
  10. Utility Functions
  11. Complete End-to-End Example
  12. Safety — the SDK cannot crash your app
  13. Troubleshooting

1. Installation

Add the package to your pubspec.yaml:

dependencies:
  embed_flutter: ^0.4.0

Then fetch:

flutter pub get

embed_flutter 0.4.0 needs Flutter 3.29 or newer (Dart 3.7+). Check with flutter --version. If your app can't move to Flutter 3.29 yet, pin embed_flutter: 0.2.0 (exactly, without ^). See the release notes.

1.1 Android Setup

Nothing to add. The SDK's own manifest declares every permission a call needs, and Android merges it into your app when it builds:

PermissionWhy
INTERNET, ACCESS_NETWORK_STATEConfig, token and the voice call
RECORD_AUDIO, MODIFY_AUDIO_SETTINGSThe microphone and the audio route
WAKE_LOCKKeeps call audio going while the screen turns off
BLUETOOTH_CONNECT (Android 12+), BLUETOOTH (Android 11 and below)Call audio through a Bluetooth headset

The SDK also asks for the runtime ones itself: the microphone when a call starts, and Nearby devices (BLUETOOTH_CONNECT, Android 12+) only when a Bluetooth headset is connected. If the user declines Bluetooth, the call still goes ahead on the speaker.

Upgrading from 0.3.0 or earlier? You can keep the permissions already in your manifest. Declaring them twice is harmless.

1.2 iOS Setup

Step 1 — Info.plist

Open ios/Runner/Info.plist and add:

<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for voice calls.</string>

Step 2 — Podfile

Open ios/Podfile and add the PERMISSION_MICROPHONE=1 macro inside the post_install block:

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
  end
end

Without PERMISSION_MICROPHONE=1 the system permission dialog will never appear and the permission will always be reported as denied.

After editing the Podfile, run:

cd ios && pod install && cd ..

2. How the SDK Works (Mental Model)

Understanding these three concepts up front makes integration straightforward:

ConceptDescription
EmbedWidgetA full-screen overlay wrapper placed at the very top of your widget tree. It renders the floating AI agent button and manages the live voice session.
EmbedNavigatorObserver / EmbedRouteListenerTells the SDK which screen the user is on so it knows whether to show the agent button.
enabledRoutesA list of route names where the agent button should appear. Only routes listed here will show the agent.

Order of operations every time:

main()
  └─ embedInitialize(apiKey: …, appVersion: …)     ← before runApp
  └─ runApp(
       EmbedWidget(                                ← outermost widget
         enabledRoutes: ['home', 'product', 'cart'],
         child: MaterialApp / Router(
           navigatorObservers: [EmbedNavigatorObserver()],  ← required
           ...
         ),
       ),
     )

After authentication → embedEvent(USER_DATA, ...)  ← activates the agent

3. Initialization

Call embedInitialize() before runApp().

import 'package:flutter/material.dart';
import 'package:embed_flutter/embed_flutter.dart';

void main() {
  embedInitialize(
    apiKey: 'your-api-key',
    embedUrl: 'https://embed.revrag.ai',     // optional; defaults to revrag.ai
    appVersion: '2.4.1',                     // REQUIRED — the only app-version intake
    onResult: (bool success, String? error) {
      // Called after the SDK prefetches the UI config from the server.
      if (!success) {
        print('Embed init failed: $error');
      }
    },
  );

  runApp(const MyApp());
}

App version is required and passed to embedInitialize(appVersion:) — that is its only intake (no auto-derivation, no post-init setter). It is sent with every event.


4. Wrapping Your App with EmbedWidget

EmbedWidget must be the outermost widget — it wraps your MaterialApp / MaterialApp.router / CupertinoApp.

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      // List the routes where the agent button should appear
      enabledRoutes: const ['home', 'product_list', 'product_detail', 'cart'],
      child: MaterialApp(
        navigatorObservers: [EmbedNavigatorObserver()],
        // ... rest of your app
      ),
    );
  }
}

The agent button appears only on routes listed in enabledRoutes. On all other routes (e.g. splash, login, settings) it is hidden automatically.

Alternatively, to show the agent on every screen except a few:

EmbedWidget(
  showEmbedWidget: true,
  showOnAllRoutes: true,
  disabledRoutes: const ['splash', 'login', 'otp'],
  child: MaterialApp(
    navigatorObservers: [EmbedNavigatorObserver()],
  ),
)

disabledRoutes is the only list that keeps the agent from looking at a screen. enabledRoutes, showEmbedWidget, the platform allowlist and the dashboard data-capture setting control where the button shows and what telemetry is sent. They do not stop an action the agent is already running from reading the screen. Put every screen the agent must never see (login, OTP, payment, regulated data) in disabledRoutes. See Sensitive screens and dialogs.


5. Navigator Integration

This is the most important integration step. The SDK needs to know when the route changes so it can show or hide the agent button. The approach differs slightly depending on which navigator you use.

5.1 MaterialApp with Named Routes

The simplest case — named routes declared directly in MaterialApp.routes.

import 'package:flutter/material.dart';
import 'package:embed_flutter/embed_flutter.dart';

void main() {
  embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0');
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      enabledRoutes: const ['product_list', 'product_detail', 'cart'],
      child: MaterialApp(
        // ✅ Required — tracks route changes
        navigatorObservers: [EmbedNavigatorObserver()],
        initialRoute: '/',
        routes: {
          '/':              (ctx) => const SplashScreen(),
          'home':           (ctx) => const HomeScreen(),
          'product_list':   (ctx) => const ProductListScreen(),
          'product_detail': (ctx) => const ProductDetailScreen(),
          'cart':           (ctx) => const CartScreen(),
          'checkout':       (ctx) => const CheckoutScreen(),
        },
      ),
    );
  }
}

Navigating between routes:

// Push
Navigator.pushNamed(context, 'product_list');

// Push with arguments
Navigator.pushNamed(context, 'product_detail', arguments: {'id': 42});

// Replace (useful for tabs — see section 5.5)
Navigator.pushReplacementNamed(context, 'cart');

EmbedNavigatorObserver intercepts every push, pop, and replace and shows or hides the agent button based on whether the new route is in enabledRoutes.


5.2 MaterialApp with onGenerateRoute

When routes carry parameters you typically use onGenerateRoute. Everything else is identical.

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      enabledRoutes: const ['welcome', 'personal_info', 'address', 'review'],
      child: MaterialApp(
        navigatorObservers: [EmbedNavigatorObserver()], // ✅ required
        home: const SplashScreen(),
        onGenerateRoute: (settings) {
          switch (settings.name) {
            case 'welcome':
              return MaterialPageRoute(
                settings: settings,   // ← always pass settings so the observer reads the name
                builder: (_) => const WelcomeScreen(),
              );
            case 'personal_info':
              final args = settings.arguments as Map<String, dynamic>?;
              return MaterialPageRoute(
                settings: settings,
                builder: (_) => PersonalInfoScreen(data: args),
              );
            case 'address':
              return MaterialPageRoute(
                settings: settings,
                builder: (_) => const AddressScreen(),
              );
            case 'review':
              return MaterialPageRoute(
                settings: settings,
                builder: (_) => const ReviewScreen(),
              );
            default:
              return null;
          }
        },
      ),
    );
  }
}

Always pass settings: settings to MaterialPageRoute. Without it the route name is null and the SDK cannot determine whether to show the agent.


5.3 GoRouter

GoRouter uses the name field of each GoRoute for matching. Pass the observer in GoRouter.observers.

import 'package:go_router/go_router.dart';
import 'package:embed_flutter/embed_flutter.dart';

// ── Router definition ─────────────────────────────────────────────────────────
final _router = GoRouter(
  initialLocation: '/splash',
  // ✅ Add observer here
  observers: [EmbedNavigatorObserver()],
  routes: [
    GoRoute(
      path: '/splash',
      name: 'splash',
      builder: (context, state) => const SplashScreen(),
    ),
    GoRoute(
      path: '/home',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
    ),
    GoRoute(
      path: '/product/:id',
      name: 'product_detail',
      builder: (context, state) {
        final id = state.pathParameters['id']!;
        return ProductDetailScreen(id: id);
      },
    ),
    GoRoute(
      path: '/cart',
      name: 'cart',
      builder: (context, state) => const CartScreen(),
    ),
    GoRoute(
      path: '/payment',
      name: 'payment',
      builder: (context, state) => const PaymentScreen(),
    ),
  ],
);

// ── main ──────────────────────────────────────────────────────────────────────
void main() {
  embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0');
  runApp(const MyApp());
}

// ── App widget ────────────────────────────────────────────────────────────────
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      // Use the `name:` field from GoRoute — not the path
      enabledRoutes: const ['home', 'product_detail', 'cart', 'payment'],
      child: MaterialApp.router(
        routerConfig: _router,
      ),
    );
  }
}

Navigating with GoRouter:

// By name
context.goNamed('cart');

// By path
context.go('/cart');

// Push (adds to stack)
context.pushNamed('product_detail', pathParameters: {'id': '42'});

GoRouter with nameExtractor (path-based matching)

If you prefer to match on URL paths instead of route names, supply a nameExtractor to the observer:

EmbedNavigatorObserver(
  nameExtractor: (route) {
    final name = route.settings.name ?? '';
    if (name.startsWith('/product/')) return 'product_detail';
    if (name == '/cart') return 'cart';
    return null; // null = SDK ignores this push
  },
)

Then use those extracted names in enabledRoutes as usual.


5.4 GoRouter with ShellRoute (Bottom Tab Navigator)

ShellRoute keeps a persistent shell (e.g. a bottom navigation bar) while swapping child routes. The challenge is that ShellRoute children run inside a nested navigator — the top-level observer does not fire for them.

Solution: Wrap each tab's screen with EmbedRouteListener. This widget notifies the SDK of the current screen whenever the tab is displayed.

import 'package:go_router/go_router.dart';
import 'package:embed_flutter/embed_flutter.dart';

// ── Router ────────────────────────────────────────────────────────────────────
final _router = GoRouter(
  initialLocation: '/home',
  observers: [EmbedNavigatorObserver()], // catches pushes outside the shell
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(
          path: '/home',
          name: 'home',
          builder: (context, state) => const HomeTab(),
        ),
        GoRoute(
          path: '/offers',
          name: 'offers',
          builder: (context, state) => const OffersTab(),
        ),
        GoRoute(
          path: '/profile',
          name: 'profile',
          builder: (context, state) => const ProfileTab(),
        ),
        // Sub-route inside a tab (pushed onto the nested navigator)
        GoRoute(
          path: '/offers/detail/:id',
          name: 'offer_detail',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return OfferDetailScreen(id: id);
          },
        ),
      ],
    ),
  ],
);

// ── Shell scaffold ────────────────────────────────────────────────────────────
class AppShell extends StatelessWidget {
  final Widget child;
  const AppShell({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();
    int currentIndex = 0;
    if (location.startsWith('/offers')) currentIndex = 1;
    if (location.startsWith('/profile')) currentIndex = 2;

    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentIndex,
        onTap: (i) {
          switch (i) {
            case 0: context.go('/home');    break;
            case 1: context.go('/offers');  break;
            case 2: context.go('/profile'); break;
          }
        },
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home),        label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.local_offer), label: 'Offers'),
          BottomNavigationBarItem(icon: Icon(Icons.person),      label: 'Profile'),
        ],
      ),
    );
  }
}

// ── Tab screens ───────────────────────────────────────────────────────────────
// Wrap each tab with EmbedRouteListener so the SDK knows which tab is active.

class HomeTab extends StatelessWidget {
  const HomeTab({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedRouteListener(
      routeName: 'home', // ✅ must match the name in enabledRoutes
      child: Scaffold(
        appBar: AppBar(title: const Text('Home')),
        body: const Center(child: Text('Home content')),
      ),
    );
  }
}

class OffersTab extends StatelessWidget {
  const OffersTab({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedRouteListener(
      routeName: 'offers', // ✅ agent visible — listed in enabledRoutes
      child: Scaffold(
        appBar: AppBar(title: const Text('Offers')),
        body: const Center(child: Text('Browse offers')),
      ),
    );
  }
}

class ProfileTab extends StatelessWidget {
  const ProfileTab({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedRouteListener(
      routeName: 'profile', // agent hidden — not listed in enabledRoutes
      child: Scaffold(
        appBar: AppBar(title: const Text('Profile')),
        body: const Center(child: Text('Your profile')),
      ),
    );
  }
}

// ── main ──────────────────────────────────────────────────────────────────────
void main() {
  embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0');
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      // 'profile' is intentionally excluded — agent hidden there
      enabledRoutes: const ['home', 'offers', 'offer_detail'],
      child: MaterialApp.router(routerConfig: _router),
    );
  }
}

Key rules for ShellRoute:

RuleWhy
Add EmbedNavigatorObserver() to the top-level GoRouterCatches pushes onto the root navigator (screens outside the shell)
Wrap each tab's screen with EmbedRouteListener(routeName: '...')Tells the SDK which tab is currently visible
The routeName must match what you put in enabledRoutesOtherwise the SDK cannot determine visibility

5.5 MaterialApp Bottom TabNavigator (BottomNavigationBar)

When using BottomNavigationBar with a plain MaterialApp, push a new named route for each tab using Navigator.pushReplacementNamed so the route stack stays shallow and the observer fires on every tab switch.

// ── main.dart ─────────────────────────────────────────────────────────────────
void main() {
  embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0');
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      // Agent appears only on the Plans and Exclusive tabs
      enabledRoutes: const ['plans_tab', 'exclusive_tab'],
      child: MaterialApp(
        navigatorObservers: [EmbedNavigatorObserver()], // ✅ required
        home: const HomeTab(),
        onGenerateRoute: (settings) {
          switch (settings.name) {
            case 'home_tab':
              return MaterialPageRoute(settings: settings, builder: (_) => const HomeTab());
            case 'plans_tab':
              return MaterialPageRoute(settings: settings, builder: (_) => const PlansTab());
            case 'exclusive_tab':
              return MaterialPageRoute(settings: settings, builder: (_) => const ExclusiveTab());
            case 'profile_tab':
              return MaterialPageRoute(settings: settings, builder: (_) => const ProfileTab());
            default:
              return null;
          }
        },
      ),
    );
  }
}

// ── Shared bottom nav scaffold ────────────────────────────────────────────────
class AppNavBar extends StatelessWidget {
  final Widget child;
  final int currentIndex;

  const AppNavBar({super.key, required this.child, required this.currentIndex});

  static const _routes = ['home_tab', 'plans_tab', 'exclusive_tab', 'profile_tab'];

  void _onTap(BuildContext context, int index) {
    if (index == currentIndex) return;
    // pushReplacementNamed so the back button doesn't cycle through tab history
    // and EmbedNavigatorObserver fires didReplace → SDK updates its route state
    Navigator.pushReplacementNamed(context, _routes[index]);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: currentIndex,
        type: BottomNavigationBarType.fixed,
        selectedItemColor: Colors.blue[700],
        unselectedItemColor: Colors.grey[600],
        onTap: (i) => _onTap(context, i),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home),        label: 'Home'),
          BottomNavigationBarItem(icon: Icon(Icons.description), label: 'Plans'),
          BottomNavigationBarItem(icon: Icon(Icons.star),        label: 'Exclusive'),
          BottomNavigationBarItem(icon: Icon(Icons.person),      label: 'Profile'),
        ],
      ),
    );
  }
}

// ── Tab screens ───────────────────────────────────────────────────────────────
class HomeTab extends StatelessWidget {
  const HomeTab({super.key});

  @override
  Widget build(BuildContext context) {
    return AppNavBar(
      currentIndex: 0,
      // Agent hidden — 'home_tab' is not in enabledRoutes
      child: Scaffold(
        appBar: AppBar(title: const Text('Home')),
        body: const Center(child: Text('Home content')),
      ),
    );
  }
}

class PlansTab extends StatelessWidget {
  const PlansTab({super.key});

  @override
  Widget build(BuildContext context) {
    return AppNavBar(
      currentIndex: 1,
      // ✅ Agent visible — 'plans_tab' is in enabledRoutes
      child: Scaffold(
        appBar: AppBar(title: const Text('Plans')),
        body: const Center(child: Text('Browse plans')),
      ),
    );
  }
}

class ExclusiveTab extends StatelessWidget {
  const ExclusiveTab({super.key});

  @override
  Widget build(BuildContext context) {
    return AppNavBar(
      currentIndex: 2,
      // ✅ Agent visible — 'exclusive_tab' is in enabledRoutes
      child: Scaffold(
        appBar: AppBar(title: const Text('Exclusive')),
        body: const Center(child: Text('Exclusive deals')),
      ),
    );
  }
}

class ProfileTab extends StatelessWidget {
  const ProfileTab({super.key});

  @override
  Widget build(BuildContext context) {
    return AppNavBar(
      currentIndex: 3,
      // Agent hidden — 'profile_tab' is not in enabledRoutes
      child: Scaffold(
        appBar: AppBar(title: const Text('Profile')),
        body: const Center(child: Text('Your profile')),
      ),
    );
  }
}

How it works:

  • Navigator.pushReplacementNamed fires didReplace on EmbedNavigatorObserver, which updates the current route in the SDK.
  • The SDK checks the new route name against enabledRoutes and shows or hides the agent button — no extra code needed.

5.6 Stable Screen Names

Separately from button visibility, the SDK files everything it captures under a screen name. The agent uses it to know which screen it is on and whether a tap changed screens, and the on-device screen cache is keyed by it. From 0.3.0 the name is built only from stable signals, so it is the same in debug, release and --obfuscate builds. Class names are never used.

The SDK resolves the screen name in this order:

PrioritySourceExample name
1The current route name: RouteSettings.name, your nameExtractor, or EmbedRouteListener(routeName:)checkout
2If an IndexedStack / PageView tab shell is in front, the active tab is appendedhome/portfolio
3An unnamed top page: its first short heading (an AppBar title of ≤ 4 words, ≤ 32 characters, no digits)my_orders
4An unnamed pushed page with no other signal: its depth above the last named routecheckout/page_1
5Otherwise the active visibility group, then unknown_flowunknown_flow

A tab inside an IndexedStack is named from, in order: a ValueKey<String> on the tab's root widget, a Semantics(identifier:) at the top of the tab, then the tab's first heading. A PageView page is named only from a ValueKey<String> on the page child. Names are slugged: "My Orders" becomes my_orders.

What to do in your app:

  1. Name every route. Pass settings: RouteSettings(name: '…') to every MaterialPageRoute (and in onGenerateRoute), and set name: on every GoRoute. Seeing …/page_1 in the logs means a page was pushed without a name.

  2. Give each tab root a ValueKey<String>, or wrap each tab body in EmbedRouteListener:

    IndexedStack(
      index: currentIndex,
      children: const [
        HomeTab(key: ValueKey('home')),
        PortfolioTab(key: ValueKey('portfolio')),
        OrdersTab(key: ValueKey('orders')),
      ],
    )

    Don't rely on headings for tabs whose title contains a number, a greeting or the user's name. Those headings are rejected as names.

  3. Name sensitive dialogs and bottom sheets with routeSettings: RouteSettings(name: '…') so you can list them in disabledRoutes (see Sensitive screens and dialogs).

  4. Never build names from runtimeType or toString() in a custom nameExtractor. Release builds strip toString() and --obfuscate renames classes.


6. Sending Events

6.1 USER_DATA (Required)

This is the only required event. Send it after you have a logged-in user ID. It activates voice features and associates all future analytics with that user.

embedEvent(
  EventKeys.USER_DATA,
  UserEventPayload(
    app_user_id: 'user_12345',     // required — your unique user identifier
    data: {
      'name':  'Priya Sharma',     // optional extra fields sent to the agent
      'email': 'priya@example.com',
      'plan':  'premium',
    },
  ),
);

When to call it:

ScenarioWhen to call
Auth flowImmediately after signIn() / token refresh succeeds
No auth (demo apps)In initState of your first meaningful screen

Send it whenever you have the user — you don't need to wait for the server config to load. Until a user is set, other events are not sent and embedEvent returns false.

The user is remembered across app restarts. From 0.4.0 the SDK keeps the signed-in user until logout, so the agent, its token and events work straight from a cold start. Sending USER_DATA again sets or replaces the user. When your user logs out, call:

Future<void> onLogout() async {
  await EmbedWidget.clearUserSession(); // ends any call, forgets the user
  // Navigate to your login screen
}

clearUserSession() ends any call, wipes the SDK's on-device screen cache and form values, and forgets the user in memory and on disk. Your API key and the widget config are kept. It works even when no EmbedWidget is on screen.


6.2 SCREEN_STATE (Optional)

Sends additional context about the current screen to the agent. Useful when you want the agent to know which step of a multi-step form the user is on, or when multiple routes share one screen widget.

embedEvent(
  EventKeys.SCREEN_STATE,
  ScreenEventPayload(
    screen: 'loan_application_step_2',
    data: {
      'step':        2,
      'total_steps': 5,
      'category':    'home_loan',
    },
  ),
);

Call it in initState or whenever meaningful context changes (e.g. a stepper advances).


6.3 CUSTOM_EVENT (Optional)

Captures any interaction you want to relay to the agent — button taps, option selections, modal opens, etc.

embedEvent(
  EventKeys.CUSTOM_EVENT,
  CustomEventPayload(
    data: {
      'context': 'plan_selection',
      'action':  'plan_selected',
      'plan_id': 'gold_12m',
      'price':   1499,
      'currency': 'INR',
    },
  ),
);

6.4 ANALYTICS_DATA (Optional)

Like CUSTOM_EVENT but requires a named event identifier. Use this for conversion events, funnel steps, or any event you need to categorise by a fixed name on the backend.

embedEvent(
  EventKeys.ANALYTICS_DATA,
  AnalyticsDataEventPayload(
    event_name: 'payment_completed',
    data: {
      'amount':         4999,
      'currency':       'INR',
      'payment_method': 'upi',
      'transaction_id': 'txn_abc123',
    },
  ),
);

Event reference table:

EventClassevent_name required?Typical use
USER_DATAUserEventPayload—Identifying the user and activating the agent
SCREEN_STATEScreenEventPayload—Providing screen or step context to the agent
CUSTOM_EVENTCustomEventPayloadNoAd-hoc interactions and selections
ANALYTICS_DATAAnalyticsDataEventPayloadYesNamed funnel and conversion events

7. EmbedWidget Parameters Reference

ParameterTypeDefaultDescription
childWidget—Required. Your MaterialApp / MaterialApp.router
showEmbedWidgetbooltrueMaster visibility toggle — set to false to hide the agent everywhere
enabledRoutesList<String>[]Route names where the agent button should appear
showOnAllRoutesboolfalseShow on every route. Use with disabledRoutes to exclude specific screens
disabledRoutesList<String>[]Routes where the agent is always hidden (takes priority over showOnAllRoutes); entering one ends a live call. Also the only list that keeps the agent from looking at a screen, and accepts named dialogs / bottom sheets (see Sensitive screens and dialogs)
routeMatchModeRouteMatchModeexactHow route names are compared — see section 7.1
onPermissionStatusChangedvoid Function(bool)?—Called after the mic permission dialog — true = granted, false = denied
onBackRequestedFuture<bool> Function()?—First-chance handler for the agent's back mission (rung 1 of the back ladder)
embedButtonDelayMsint0Delay (ms) before the FAB appears after landing on an enabled route
continuityEmbedButtonContinuity?resetWhether the FAB hides/re-shows on navigation between enabled routes (keepVisible / reset)
buttonDelayEmbedButtonDelay?—Pre-show delay + its EmbedButtonDelayPolicy (how often the delay applies)
buttonVisibilityEmbedButtonVisibilityConfig?—Per-screen visibility groups — different continuity/delay per set of screens (see §7.2)
usePlatformRouteAllowlistboolfalseOpt in to the manager (dashboard) route allowlist instead of enabledRoutes. Controls where the button shows; it does not keep the agent from looking at a screen
debugboolfalseNarrate the SDK's show/hide/call decisions to the console

7.1 Route Match Modes

By default route names must match exactly. Use routeMatchMode to relax this:

EmbedWidget(
  routeMatchMode: RouteMatchMode.startsWith,
  enabledRoutes: const ['/product'],  // matches /product, /product/detail, /product/42, etc.
  child: ...,
)
ModeBehaviourExample patternMatches
RouteMatchMode.exactExact string match (default)'cart'only 'cart'
RouteMatchMode.startsWithRoute starts with the pattern'/product'/product, /product/detail, /product/42
RouteMatchMode.containsRoute contains the pattern anywhere'product''product_list', 'new_product', '/v2/product/3'

Leading slashes are normalised — 'home' and '/home' are treated identically.


7.2 Visibility Groups

By default, continuity / buttonDelay apply to all enabled routes. To give different sets of screens different visibility behavior — e.g. re-show the FAB on every onboarding step but keep it up throughout checkout — pass buttonVisibility with named groups (React Native parity):

EmbedWidget(
  enabledRoutes: const ['home'],
  buttonVisibility: const EmbedButtonVisibilityConfig(
    defaultDelayMs: 0,                 // fallback for enabled screens in no group
    groups: [
      EmbedButtonGroupConfig(
        id: 'onboarding',
        screens: ['welcome', 'kyc', 'review'],
        continuity: EmbedButtonContinuity.reset,        // re-show each step
        delayMs: 1200,
        delayPolicy: EmbedButtonDelayPolicy.perScreen,
      ),
      EmbedButtonGroupConfig(
        id: 'checkout',
        screens: ['cart', 'payment', 'confirm'],
        continuity: EmbedButtonContinuity.keepVisible,  // stay up through checkout
        delayMs: 300,
        delayPolicy: EmbedButtonDelayPolicy.oncePerGroupEntry,
      ),
    ],
  ),
  child: MyApp(),
)
  • Resolution: for the current route, the first group whose screens contains it wins; its continuity / delayMs / delayPolicy override the widget-level values. A screen in no group falls back to defaultDelayMs → buttonDelay → embedButtonDelayMs, and the widget-level continuity.
  • A group's screens are part of the allow-list — listing a screen in a group makes the FAB show there even if it isn't in enabledRoutes.
  • delayPolicy scopes per group: oncePerGroupEntry delays once when you enter the group and skips while you stay in it; oncePerAppSession delays once per app launch; perScreen re-delays on every screen. Moving between two groups counts as a real group change.
EmbedButtonGroupConfig fieldTypeMeaning
idStringStable group identity (the delay/continuity scope).
screensList<String>Route names in this group (matched with routeMatchMode).
continuityEmbedButtonContinuitykeepVisible / reset for this group.
delayMsint?Pre-show delay for this group (falls back to widget-level).
delayPolicyEmbedButtonDelayPolicy?perScreen / oncePerGroupEntry / oncePerAppSession.

8. Advanced: Widget Visibility and Positioning

FAB Position

The floating button's position comes from the backend widget_config (widget_position / right_padding / bottom_padding), set from the Revrag dashboard. There are no host position props on EmbedWidget.

Show on All Routes with Exclusions

When you want the agent everywhere except a handful of screens:

EmbedWidget(
  showEmbedWidget: true,
  showOnAllRoutes: true,
  disabledRoutes: const ['splash', 'login', 'otp_verification'],
  child: ...,
)

The screen you pass to MaterialApp(home: …) is the route /, whatever its widget is called. To hide the agent on a splash screen shown as home:, list '/' in disabledRoutes. That's safe with the default exact matching, but with RouteMatchMode.contains or startsWith a '/' would match every route.

Sensitive screens and dialogs

From 0.3.0, disabledRoutes is the one list that keeps the agent away from a screen. On a screen in disabledRoutes nothing is captured or sent, and an agent action that would read it fails with screen_not_capturable instead. The following do not stop a running agent action from reading the screen. They only decide where the button shows and what telemetry is sent:

  • a route missing from enabledRoutes or from the platform allowlist
  • showEmbedWidget: false, or the button being hidden during a buttonDelay
  • the dashboard data-capture setting (action_config.flags.dataCapture: false stops telemetry only)

Telemetry also continues while the button is hidden, matching the Android SDK. Screens in disabledRoutes are still never covered. If this conflicts with your data policy, contact Revrag.

Dialogs and bottom sheets don't change the page route, so give sensitive ones a name and list that name in disabledRoutes:

showDialog(
  context: context,
  routeSettings: const RouteSettings(name: 'otp_dialog'),
  builder: (_) => const OtpDialog(),
);

showModalBottomSheet(
  context: context,
  routeSettings: const RouteSettings(name: 'card_entry_sheet'),
  builder: (_) => const CardEntrySheet(),
);

EmbedWidget(
  enabledRoutes: const ['home', 'checkout'],
  disabledRoutes: const ['login', 'otp', 'otp_dialog', 'card_entry_sheet'],
  child: ...,
)

While a named dialog or sheet in disabledRoutes is open, capture is blocked, but the agent button is not hidden: it follows the page underneath. Its open and close analytics are still sent.

Mark password, PIN and OTP fields obscureText: true as well. Their contents are redacted at capture on every screen, including screens that aren't in disabledRoutes.

Hide one part of a screen (from 0.4.0) by giving it a Semantics identifier that starts with embed-redact. Its content, and everything inside it, is left out of what the agent sees, the on-device screen cache and click names. The agent can still tap it.

Semantics(
  identifier: 'embed-redact-account-number',
  child: Text(accountNumber),
)

Microphone Permission Callback

Handle the case where the user denies microphone access when starting a call:

final _messengerKey = GlobalKey<ScaffoldMessengerState>();

EmbedWidget(
  showEmbedWidget: true,
  enabledRoutes: const ['home', 'dashboard'],
  onPermissionStatusChanged: (bool granted) {
    if (!granted) {
      _messengerKey.currentState?.showSnackBar(
        const SnackBar(
          content: Text(
            'Microphone access is required for voice calls. Please enable it in Settings.',
          ),
        ),
      );
    }
  },
  child: MaterialApp(
    scaffoldMessengerKey: _messengerKey,
    navigatorObservers: [EmbedNavigatorObserver()],
    home: const HomeScreen(),
  ),
)

When is onPermissionStatusChanged called?

  • After the user taps the agent button and the permission dialog is shown.
  • Before the call connection is established.
  • true = permission granted, the call will proceed.
  • false = permission denied or permanently denied.

9. Advanced: Listening to SDK Events

Subscribe to events emitted by the agent from anywhere in your app.

Agent lifecycle events

import 'package:embed_flutter/embed_flutter.dart';

class MyScreen extends StatefulWidget { ... }

class _MyScreenState extends State<MyScreen> {
  late final String _handle;

  @override
  void initState() {
    super.initState();
    // One-arg form = every agent event. Two-arg form subscribes to one:
    // embedOnAgent(SdkEventName.agentConversationStarted.value, (event) { ... });
    _handle = embedOnAgent((Map<String, dynamic> event) {
      final name = SdkEventName.fromValue(
          (event['type'] ?? event['event_name']) as String?);
      switch (name) {
        case SdkEventName.agentConversationStarted:
          print('Voice call started');
        case SdkEventName.agentConversationEnded:
          print('Voice call ended');
        case SdkEventName.microphonePermissionAllowed:
          print('Mic granted');
        case SdkEventName.microphonePermissionDenied:
          print('Mic denied');
        default:
          break;
      }
    });
  }

  @override
  void dispose() {
    embedOffAgent(_handle); // always unsubscribe to avoid memory leaks
    super.dispose();
  }
}

Events delivered to embedOnAgent (the same set as the React Native SDK). Your listeners get them first, even before USER_DATA:

ConstantValueFired when
SdkEventName.agentVisible'agent_visible'Agent FAB appears on screen
SdkEventName.agentConversationStarted'agent_conversation_started'A voice call connects
SdkEventName.agentConversationEnded'agent_conversation_ended'A connected call ends (exactly once per call)
SdkEventName.popupMessageVisible'popup_message_visible'Inactivity popup is shown
SdkEventName.agentTapToOpen'agent_tap_to_open'Agent widget expanded
SdkEventName.agentTapToClose'agent_tap_to_close'Agent widget collapsed
SdkEventName.genToolTriggered'gen_tool_triggered'Agent uses an AI tool
SdkEventName.microphonePermissionAllowed'microphone_permission_allowed'Mic permission granted
SdkEventName.microphonePermissionDenied'microphone_permission_denied'Mic permission denied

SdkEventName.rageClick ('rage_click') and SdkEventName.screenView ('screen_view') are analytics only. From 0.4.0 they no longer reach embedOnAgent; observe them with embedOn.

Automatic click events

The SDK reports taps on your app's controls as analytics, using the naming convention shared with the Android, React Native and web SDKs (from 0.4.0):

  • event_name is <name>_<suffix>, where the suffix is pressed, toggled, selected or opened. It doesn't include the screen; the screen is in context.screen.
  • <name> comes from, in order:
    1. the control's ValueKey<String>;
    2. its Semantics(identifier: …);
    3. its tooltip or visible text;
    4. for a control with no text, the image or icon it draws;
    5. its role.
  • Every tappable control is tracked: buttons, GestureDetector, InkWell, ListTile, checkboxes, switches, radios and tabs. Checkboxes, switches and radios report their state after the tap.
  • A click before USER_DATA is not sent.

To give a control a stable name, key it:

ElevatedButton(
  key: const ValueKey('apply_now'), // → "apply_now_pressed"
  onPressed: _apply,
  child: const Text('Apply now'),
)

Upgrading from 0.3.0? Click event_names change: they lose the _<screen> ending, and controls without a key are no longer named from a random hash. A keyed Material button also sends its old name as legacy_event_name in data, so existing dashboards can be mapped.


10. Utility Functions

FunctionDescription
embedInitialize({required apiKey, embedUrl, required appVersion, onResult})Initialize the SDK before runApp — the ONLY place credentials / app version enter
embedEvent(EventKeys, EventPayload)Send a typed event to the agent
embedTrackEvent(String key, Map<String, dynamic> payload)Send an analytics event under a raw string key
embedClearStorageCache()Log out and wipe all persisted SDK data (prefer EmbedWidget.clearUserSession())
embedOnAgent(listener) / embedOnAgent(String event, listener) → StringSubscribe to all agent lifecycle events, or just one; returns a handle
embedOffAgent(handle) / embedOffAgent(event, handle)Unsubscribe using the handle from embedOnAgent
embedOn(String key, listener) → String / embedOff(key, handle)Subscribe to any raw SDK event key
embedTrackRageClick(elementId, x, y)Report a rage-click from host code
embedTrackFormEvent(String formId, Map<String, dynamic> fields)Report a form interaction from host code
EmbedWidget.isActivetrue when the agent FAB is visible on the current route (config loaded, route enabled, not hidden)
EmbedWidget.isLivetrue once the SDK's widget config has loaded from the server (the agent is ready)
EmbedWidget.forceCleanup()Force-stop the agent and release resources

Call control

FunctionDescription
embedStartCall() / EmbedWidget.startCall({agentTriggerMode})Start a voice call. Omit the mode ⇒ co-pilot; pass AgentTriggerMode.WORKFLOW for the Revrag-owned panel/avatar surface.
embedEndCall() / EmbedWidget.endCall()End the in-progress call.
embedIsCallActive() / EmbedWidget.isCallActiveWhether a call is live.
EmbedWidget.openAvatarScreen()Open the full-screen avatar surface (starts a workflow call if none is running, else re-opens it).
embedCollapseWidget() / EmbedWidget.collapseWidget()Fold the expanded bar back to the FAB (the call keeps running).
embedExpandWidget() / EmbedWidget.expandWidget()Expand the FAB to the bar, as if the user tapped it (emits agent_tap_to_open). startCall() does not auto-expand — pair it with this to surface the in-call bar.
embedPauseAgent() / EmbedWidget.pauseAgent()Pause the agent without ending the call: it stops speaking and listening, and the mic is muted. Returns false with no call or no agent answer. (0.4.0)
embedResumeAgent() / EmbedWidget.resumeAgent()Resume a paused agent; the mic is turned back on if it was on before. (0.4.0)
embedIsAgentPaused() / EmbedWidget.isAgentPausedWhether the agent is paused. (0.4.0)
EmbedWidget.clearUserSession()Logout: ends any call, wipes the on-device screen cache and form values, and forgets the user. Call on logout — the user is otherwise kept across app restarts.

AgentTriggerMode — CO_PILOT (default): the host app owns the UI, the agent operates your own screens, and agent-driven panels are dropped. WORKFLOW: Revrag owns the UI, panels render, and the full-screen avatar opens on connect. The mode is declared at startCall and latched for the life of the call.

// co-pilot (default) — the agent drives your app's own screens
await EmbedWidget.startCall();

// workflow — the Revrag-owned panel / avatar surface
await EmbedWidget.startCall(agentTriggerMode: AgentTriggerMode.WORKFLOW);

On logout:

Future<void> onLogout() async {
  await EmbedWidget.clearUserSession();
  // Navigate to login screen
}

A user touching your app while the agent is acting on screen stops the agent's action (0.4.0). Touches on the SDK's own widget don't count.


11. Complete End-to-End Example

This example covers everything: GoRouter with a bottom tab shell, EmbedRouteListener for tabs, USER_DATA after login, SCREEN_STATE for sub-screen context, and CUSTOM_EVENT / ANALYTICS_DATA for interactions.

// pubspec.yaml dependencies:
//   flutter: sdk: flutter
//   embed_flutter: ^0.4.0
//   go_router: ^14.0.0

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:embed_flutter/embed_flutter.dart';

// ════════════════════════════════════════════════════════════════════════════════
// main.dart
// ════════════════════════════════════════════════════════════════════════════════

void main() {
  embedInitialize(apiKey: 'your-api-key', appVersion: '1.0.0');
  runApp(const MyApp());
}

// ════════════════════════════════════════════════════════════════════════════════
// Router
// ════════════════════════════════════════════════════════════════════════════════

final _router = GoRouter(
  initialLocation: '/login',
  observers: [EmbedNavigatorObserver()],
  routes: [
    GoRoute(
      path: '/login',
      name: 'login',
      builder: (_, __) => const LoginScreen(),
    ),
    // Persistent tab shell
    ShellRoute(
      builder: (_, __, child) => MainShell(child: child),
      routes: [
        GoRoute(
          path: '/home',
          name: 'home',
          builder: (_, __) => const HomeTab(),
        ),
        GoRoute(
          path: '/plans',
          name: 'plans',
          builder: (_, __) => const PlansTab(),
          routes: [
            GoRoute(
              path: 'detail/:id',
              name: 'plan_detail',
              builder: (_, s) => PlanDetailScreen(id: s.pathParameters['id']!),
            ),
          ],
        ),
        GoRoute(
          path: '/profile',
          name: 'profile',
          builder: (_, __) => const ProfileTab(),
        ),
      ],
    ),
    // Full-screen checkout screens outside the tab shell
    GoRoute(
      path: '/cart',
      name: 'cart',
      builder: (_, __) => const CartScreen(),
    ),
    GoRoute(
      path: '/payment',
      name: 'payment',
      builder: (_, __) => const PaymentScreen(),
    ),
    GoRoute(
      path: '/confirmation',
      name: 'confirmation',
      builder: (_, __) => const ConfirmationScreen(),
    ),
  ],
);

// ════════════════════════════════════════════════════════════════════════════════
// Root app widget
// ════════════════════════════════════════════════════════════════════════════════

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      // Agent appears on these screens; hidden on login and profile
      enabledRoutes: const ['home', 'plans', 'plan_detail', 'cart', 'payment', 'confirmation'],
      onPermissionStatusChanged: (granted) {
        if (!granted) debugPrint('Mic permission denied');
      },
      child: MaterialApp.router(
        routerConfig: _router,
        theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.blue),
      ),
    );
  }
}

// ════════════════════════════════════════════════════════════════════════════════
// Shell scaffold
// ════════════════════════════════════════════════════════════════════════════════

class MainShell extends StatelessWidget {
  final Widget child;
  const MainShell({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).uri.toString();
    int idx = 0;
    if (location.startsWith('/plans'))   idx = 1;
    if (location.startsWith('/profile')) idx = 2;

    return Scaffold(
      body: child,
      bottomNavigationBar: NavigationBar(
        selectedIndex: idx,
        onDestinationSelected: (i) {
          switch (i) {
            case 0: context.go('/home');    break;
            case 1: context.go('/plans');   break;
            case 2: context.go('/profile'); break;
          }
        },
        destinations: const [
          NavigationDestination(icon: Icon(Icons.home),        label: 'Home'),
          NavigationDestination(icon: Icon(Icons.description), label: 'Plans'),
          NavigationDestination(icon: Icon(Icons.person),      label: 'Profile'),
        ],
      ),
    );
  }
}

// ════════════════════════════════════════════════════════════════════════════════
// Login screen — identify the user after sign-in
// ════════════════════════════════════════════════════════════════════════════════

class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key});

  void _login(BuildContext context) {
    // Your auth logic here ...

    // Identify the user to the SDK after successful authentication
    embedEvent(
      EventKeys.USER_DATA,
      UserEventPayload(
        app_user_id: 'user_42',
        data: {
          'name':  'Rahul Verma',
          'email': 'rahul@example.com',
          'plan':  'free',
        },
      ),
    );

    context.go('/home');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => _login(context),
          child: const Text('Sign In'),
        ),
      ),
    );
  }
}

// ════════════════════════════════════════════════════════════════════════════════
// Tab screens — each wrapped with EmbedRouteListener
// ════════════════════════════════════════════════════════════════════════════════

class HomeTab extends StatelessWidget {
  const HomeTab({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedRouteListener(
      routeName: 'home', // ✅ in enabledRoutes — agent visible
      child: Scaffold(
        appBar: AppBar(title: const Text('Home')),
        body: Center(
          child: ElevatedButton(
            onPressed: () => context.go('/cart'),
            child: const Text('Go to Cart'),
          ),
        ),
      ),
    );
  }
}

class PlansTab extends StatelessWidget {
  const PlansTab({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedRouteListener(
      routeName: 'plans', // ✅ in enabledRoutes — agent visible
      child: Scaffold(
        appBar: AppBar(title: const Text('Plans')),
        body: ListView(
          children: [
            ListTile(
              title: const Text('Gold Plan — ₹999/mo'),
              onTap: () {
                // Send context so the agent knows which plan is being viewed
                embedEvent(
                  EventKeys.SCREEN_STATE,
                  ScreenEventPayload(
                    screen: 'plan_detail',
                    data: {'plan_id': 'gold', 'price': 999},
                  ),
                );
                context.pushNamed('plan_detail', pathParameters: {'id': 'gold'});
              },
            ),
            ListTile(
              title: const Text('Platinum Plan — ₹1999/mo'),
              onTap: () {
                embedEvent(
                  EventKeys.SCREEN_STATE,
                  ScreenEventPayload(
                    screen: 'plan_detail',
                    data: {'plan_id': 'platinum', 'price': 1999},
                  ),
                );
                context.pushNamed('plan_detail', pathParameters: {'id': 'platinum'});
              },
            ),
          ],
        ),
      ),
    );
  }
}

class ProfileTab extends StatelessWidget {
  const ProfileTab({super.key});

  @override
  Widget build(BuildContext context) {
    return EmbedRouteListener(
      routeName: 'profile', // not in enabledRoutes — agent hidden
      child: Scaffold(
        appBar: AppBar(title: const Text('Profile')),
        body: const Center(child: Text('Your profile')),
      ),
    );
  }
}

// ════════════════════════════════════════════════════════════════════════════════
// Plan detail screen
// ════════════════════════════════════════════════════════════════════════════════

class PlanDetailScreen extends StatelessWidget {
  final String id;
  const PlanDetailScreen({super.key, required this.id});

  @override
  Widget build(BuildContext context) {
    // plan_detail is a top-level GoRoute so EmbedNavigatorObserver catches the push
    return Scaffold(
      appBar: AppBar(title: Text('Plan: $id')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text('Plan ID: $id', style: const TextStyle(fontSize: 18)),
            const SizedBox(height: 24),
            ElevatedButton(
              onPressed: () {
                // Inform the agent the user selected a plan
                embedEvent(
                  EventKeys.CUSTOM_EVENT,
                  CustomEventPayload(
                    data: {'action': 'plan_selected', 'plan_id': id},
                  ),
                );
                context.go('/cart');
              },
              child: const Text('Add to Cart'),
            ),
          ],
        ),
      ),
    );
  }
}

// ════════════════════════════════════════════════════════════════════════════════
// Checkout screens
// ════════════════════════════════════════════════════════════════════════════════

class CartScreen extends StatelessWidget {
  const CartScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Cart')),
      body: Center(
        child: ElevatedButton(
          onPressed: () => context.go('/payment'),
          child: const Text('Proceed to Payment'),
        ),
      ),
    );
  }
}

class PaymentScreen extends StatelessWidget {
  const PaymentScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Payment')),
      body: Center(
        child: ElevatedButton(
          onPressed: () {
            // Track the conversion event
            embedEvent(
              EventKeys.ANALYTICS_DATA,
              AnalyticsDataEventPayload(
                event_name: 'payment_completed',
                data: {'amount': 999, 'currency': 'INR', 'method': 'upi'},
              ),
            );
            context.go('/confirmation');
          },
          child: const Text('Pay ₹999'),
        ),
      ),
    );
  }
}

class ConfirmationScreen extends StatelessWidget {
  const ConfirmationScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Confirmed!')),
      body: const Center(child: Text('Your order is placed.')),
    );
  }
}

12. Safety — the SDK cannot crash your app

The SDK is a guest in your app and is engineered so that nothing it does can bring the host down. You do not need any defensive wrapping around it.

  • Public API never throws at your call site. A mismatched embedEvent payload returns false; a wrong-signature or bad-argument embedOnAgent returns an empty handle; a throwing nameExtractor you pass to EmbedNavigatorObserver falls back to the route name instead of breaking navigation; embedClearStorageCache() never rejects.
  • Async work is fully fenced. Every SDK-owned timer, stream listener, post-frame callback and background task is individually guarded, so nothing the SDK does in the background escapes into your app's error zone.
  • Malformed backend config can't crash a build. Every field from the server widget_config is type-coerced (a wrong-typed color, size or flag falls back to a default) rather than cast blindly, so no configuration response can throw during a widget build.
  • The LiveKit voice engine is fenced. A chained PlatformDispatcher.onError (installed by embedInitialize) swallows only the voice stack's own async exceptions — LiveKit's typed errors (e.g. a renegotiation NegotiationError) and any untyped error thrown from inside livekit_client / flutter_webrtc (such as a signaling race) — and passes every other error straight through to your existing handler, so your own crash reporting and behaviour are untouched.

The guard is scoped: it suppresses only errors originating in the SDK's voice dependency. Your app's own exceptions reach your handlers exactly as before.


13. Troubleshooting

Agent button not visible

CheckFix
showEmbedWidget is falseSet it to true
Route name is not listed in enabledRoutesAdd it to enabledRoutes
EmbedNavigatorObserver is missingAdd it to navigatorObservers on MaterialApp or observers on GoRouter
USER_DATA event not sentThe agent requires user identity. Call embedEvent(USER_DATA, ...) after login.
Invalid API keyVerify the key passed to embedInitialize
Network error on startupCheck the onResult callback in embedInitialize for the error message. From 0.4.0 the SDK retries a failed start-up on its own

Agent button shows on the splash screen

CheckFix
The splash is MaterialApp(home: …) and disabledRoutes lists its widget nameThe home: screen's route is /. Add '/' to disabledRoutes

Call audio plays on the loudspeaker with a Bluetooth headset

CheckFix
Android 12+ and the user declined Nearby devicesAllow it in the app's settings. The SDK asks at the next call start while a headset is connected
Host app pins livekit_client below 2.9From 0.4.0 the SDK no longer forces the speaker, so headsets work on any version. Upgrading livekit_client is still recommended

Agent not showing on a specific tab (ShellRoute / BottomNavigationBar)

CheckFix
Tab screen not wrapped with EmbedRouteListenerWrap each tab body with EmbedRouteListener(routeName: '...')
pushReplacementNamed not used for tab switches (MaterialApp)Switch to pushReplacementNamed so didReplace fires on EmbedNavigatorObserver
Tab route name not in enabledRoutesAdd it to enabledRoutes
routeName in EmbedRouteListener doesn't match enabledRoutesBoth must be identical strings

iOS microphone permission never requested

CheckFix
NSMicrophoneUsageDescription missing from Info.plistAdd the key with a usage description string
PERMISSION_MICROPHONE=1 missing from PodfileAdd the macro inside post_install and run pod install

GoRouter routes not tracked

CheckFix
Observer missingAdd EmbedNavigatorObserver() to GoRouter.observers
Using path strings in enabledRoutes instead of route namesUse the name: field value of GoRoute, not the path: string
nameExtractor neededIf GoRoute name values differ from what you want to match, supply a custom nameExtractor

flutter pub get can't resolve embed_flutter ^0.4.0

CheckFix
Flutter older than 3.29 (flutter --version)Upgrade Flutter to 3.29+, or pin embed_flutter: 0.2.0 until you can
environment: sdk: lower bound below 3.7Raise it to ">=3.7.0 <4.0.0"

Screen names show unknown_flow, …/page_1, or every tab has the same name

CheckFix
A page was pushed without a route name (…/page_1)Pass settings: RouteSettings(name: '…') to the route, or set name: on the GoRoute
All tabs of an IndexedStack / PageView share one nameGive each tab root a ValueKey<String>, or wrap each tab body in EmbedRouteListener
A custom nameExtractor uses runtimeType / toString()Return a fixed string instead. These change in release and --obfuscate builds

See Stable Screen Names.

An agent action reports a failure

From 0.3.0 a step counts as succeeded only when the intended control was acted on and the result was seen. Otherwise it fails with a specific reason. Most reasons point at a fix in your widgets:

failure_reasonWhat it meansFix
target_not_foundNo control matched, even after scrollingLabel the control with Semantics(label: …). Anything TalkBack / VoiceOver can't name is invisible to the agent
ambiguous_targetTwo controls match equally well; the SDK won't guessGive them distinct labels or a Semantics(identifier: …)
target_mismatchThe only reachable control names something else (for example a field labelled "PIN" when asked for "Full Name")Put each field's label in the field's own container, or use InputDecoration(labelText: …); name your routes
target_disabledThe control is disabled (onPressed: null, enabled: false)Expected: the control really is disabled
verification_unavailableThe action ran but its effect couldn't be tied to the controlMake the change visible on the control itself: expose Semantics(selected: …) / Semantics(checked: …) on custom tabs, chips and radios
tap_had_no_observable_effectNothing tied to the control changed, even after a second look ~1.5 s later. The tap is never repeatedMake sure the handler fires for accessibility taps: wrap a bare GestureDetector in Semantics(button: true, onTap: …). A change only in a counter or badge elsewhere doesn't count
check_not_applied / select_not_appliedThe checkbox, switch or option isn't shown in its new stateExpose Semantics(checked: …) / Semantics(selected: …) on the custom control
screen_not_capturableThe screen or an open named dialog is in disabledRoutesExpected for excluded screens

Icon-only buttons need a label: Semantics(label: 'Portfolio', button: true, child: …). Without one, 0.3.0 names the control from its image file or icon (pie chart), or as Tab 2 of 4 for an unlabelled tab. That describes the icon, not what it does.

Clearing SDK data on logout

From 0.4.0 the SDK remembers the signed-in user across app restarts, so logout must tell it:

Future<void> onLogout() async {
  await EmbedWidget.clearUserSession();
  // Navigate to login screen
}

If you skip this, the next person to use the app is treated as the previous user until a new USER_DATA arrives.


Reference

Packageembed_flutter
pub.devhttps://pub.dev/packages/embed_flutter
Flutter≥ 3.29 (reference: current stable) · Dart ≥ 3.7
Versions & pitfallsCompatibility & Dependency Matrix
Release notesFlutter SDK release notes

Support

Last updated: October 2026 · Flutter: 3.29.0+ · embed_flutter: 0.4.0