Skip to main content

Embed Flutter SDK — Complete Integration Guide

Package: embed_flutter · Version: 0.0.17 · Requires: Flutter ≥ 3.0.0 / Dart ≥ 3.0.0

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. Troubleshooting

1. Installation

Add the package to your pubspec.yaml:
dependencies:
  embed_flutter: ^0.0.17
Then fetch:
flutter pub get

1.1 Android Setup

Open android/app/src/main/AndroidManifest.xml and add:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MICROPHONE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- WAKE_LOCK keeps the CPU active during voice calls so audio is not interrupted
     when the screen turns off. Required by the underlying WebRTC (LiveKit) engine. -->

iOS Configuration

Add the following permissions to your ios/Runner/Info.plist:
<key>NSMicrophoneUsageDescription</key>
<string>This app needs microphone access for voice calls.</string>
2. Configure Podfile for microphone permission The SDK requires explicit configuration in your ios/Podfile to enable microphone permission requests. Add the following configuration to your 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)',
        ## dart: PermissionGroup.microphone
        'PERMISSION_MICROPHONE=1',
        # ...other permissions...
      ]
    end
  end
end
Important: Without the PERMISSION_MICROPHONE=1 macro in the Podfile, the microphone permission dialog will not appear on iOS and the permission will be reported as permanently denied even if it hasn’t been requested before.
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
Important: 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 map 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, flowName: 'main')     ← before runApp
  └─ runApp(
       EmbedWidget(                                ← outermost widget
         enabledRoutes: {
           'main': ['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(
    'your-api-key',
    flowName: 'main',                        // identifies your agent configuration
    embedUrl: 'https://embed.revrag.ai',     // optional; defaults to revrag.ai
    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());
}
Optional: set app version (sent with every analytics event):
embedSetAppVersion('2.4.1');

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 {
        'main': ['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:
embedEvent(
  EventKeys.CUSTOM_EVENT,
  CustomEventPayload(
    data: {
      'context': 'pricing_screen',
      'event': 'option_selected',
      'option_id': 'plan_12_months',
      'price_per_month': 249,
      'currency': 'INR',
    },
  ),
);

Available Events

EventPurposeRequiredWhen to Use
USER_DATAInitialize user context and activate EmbedWidgetRequiredAfter user authentication or when user ID is available
SCREEN_STATEProvide screen context and navigation infoOptionalWhen navigating between screens or when screen context changes
CUSTOM_EVENTCapture bespoke user interactionsOptionalWhenever you need additional tracking for specific actions

Basic Usage

Here’s a complete example showing how to use the SDK with flow-based activation:
import 'package:flutter/material.dart';
import 'package:embed_flutter/embed_flutter.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      enabledRoutes: const {
        'main': ['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 {
        'main': ['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;
          }
        },
      ),
    );
  }
}
Tip: 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('your-api-key', flowName: 'main');
  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 {
        'main': ['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('your-api-key', flowName: 'main');
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return EmbedWidget(
      showEmbedWidget: true,
      enabledRoutes: const {
        'main': ['home', 'offers', 'offer_detail'],
        // 'profile' is intentionally excluded — agent hidden there
      },
      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('your-api-key', flowName: 'main');
  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 {
        'main': ['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.

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
The SDK queues this event internally if the server config hasn’t loaded yet and flushes it automatically — you do not need to wait or retry.

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_DATAUserEventPayloadIdentifying the user and activating the agent
SCREEN_STATEScreenEventPayloadProviding screen or step context to the agent
CUSTOM_EVENTCustomEventPayloadNoAd-hoc interactions and selections
ANALYTICS_DATAAnalyticsDataEventPayloadYesNamed funnel and conversion events

7. EmbedWidget Parameters Reference

ParameterTypeDefaultDescription
childWidgetRequired. Your MaterialApp / MaterialApp.router
showEmbedWidgetbooltrueMaster visibility toggle — set to false to hide the agent everywhere
enabledRoutesMap<String, List<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)
routeMatchModeRouteMatchModeexactHow route names are compared — see section 7.1
apiKeyString?from embedInitializeOverride API key directly on the widget
embedUrlString?from embedInitializeOverride server base URL
rightPaddingdouble?server configFAB distance from the right edge in logical pixels
bottomPaddingdouble?server configFAB distance from the bottom edge in logical pixels
onPermissionStatusChangedvoid Function(bool)?Called after the mic permission dialog — true = granted, false = denied

7.1 Route Match Modes

By default route names must match exactly. Use routeMatchMode to relax this:
EmbedWidget(
  routeMatchMode: RouteMatchMode.startsWith,
  enabledRoutes: const {
    'main': ['/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'
Note: Leading slashes are normalised — 'home' and '/home' are treated identically.

8. Advanced: Widget Visibility and Positioning

Custom FAB Position

Override the default floating button position with rightPadding and bottomPadding:
EmbedWidget(
  showEmbedWidget: true,
  enabledRoutes: const {
    'main': ['home', 'plans', 'cart'],
  },
  rightPadding: 20.0,    // 20px from the right edge
  bottomPadding: 100.0,  // 100px from the bottom edge
  child: MaterialApp(
    navigatorObservers: [EmbedNavigatorObserver()],
    home: const HomeScreen(),
  ),
)

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: ...,
)

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 {
    'main': ['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();
    _handle = embedOnAgent((event) {
      final type = event['type'] as String;
      switch (type) {
        case SdkEventName.agentConversationStarted:
          print('Voice call started');
          break;
        case SdkEventName.agentConversationEnded:
          print('Voice call ended');
          break;
        case SdkEventName.microphonePermissionAllowed:
          print('Mic granted');
          break;
        case SdkEventName.microphonePermissionDenied:
          print('Mic denied');
          break;
      }
    });
  }

  @override
  void dispose() {
    embedOffAgent(_handle); // always unsubscribe to avoid memory leaks
    super.dispose();
  }
}
Available SdkEventName constants:
ConstantValueFired when
SdkEventName.agentVisible'agent_visible'Agent FAB appears on screen
SdkEventName.agentConversationStarted'agent_conversation_started'User starts a voice call
SdkEventName.agentConversationEnded'agent_conversation_ended'Voice call ends
SdkEventName.popupMessageVisible'popup_message_visible'Inactivity popup is shown
SdkEventName.agentTapTopOpen'agent_tap_top_open'Agent widget expanded
SdkEventName.agentTapTopClose'agent_tap_top_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

10. Utility Functions

FunctionDescription
embedInitialize(apiKey, {flowName, embedUrl, onResult})Initialize the SDK before runApp
embedSetAppVersion(String version)Tag all events with an app version string
embedEvent(EventKeys, EventPayload)Send a typed event to the agent
embedTrackEvent(String key, EventPayload)Send an event using a raw string key
embedClearStorageCache()Wipe all persisted SDK data — call on logout
embedOnAgent(handler)StringSubscribe to agent lifecycle events
embedOffAgent(String handle)Unsubscribe using the handle from embedOnAgent
EmbedWidget.isActivetrue when the agent FAB is visible and the session is live
EmbedWidget.isLivetrue when the server marks the agent as live
EmbedWidget.forceCleanup()Force-stop the agent and release resources

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.0.17
//   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('your-api-key', flowName: 'main');
  embedSetAppVersion('1.0.0'); // optional
  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,
      enabledRoutes: const {
        // Agent appears on these screens; hidden on login and profile
        'main': ['home', 'plans', 'plan_detail', 'cart', 'payment', 'confirmation'],
      },
      rightPadding: 16,
      bottomPadding: 100,
      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. Troubleshooting

Agent button not visible

CheckFix
showEmbedWidget is falseSet it to true
Route name is not listed in enabledRoutesAdd it under the 'main' key
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

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 the 'main' list
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

Clearing SDK data on logout

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

For additional help visit https://docs.revrag.ai/embed/integration/flutter or email contact@revrag.ai.