find_ai_chat

pub package

Flutter SDK for embedding the Find AI chat assistant into Flutter apps (mobile & web) — equivalent to widget.js for websites, talking to the same public webchat API (/api/v1/channels/webchat/{connectionId}/...).

Features

  • Three display modes: embedded widget, floating bubble, or drawer panel
  • Light/dark theme with system auto-detection
  • Two visual styles: classic and GPT-style
  • Programmatic control: open, close, toggle, send messages, clear conversation
  • SSE streaming with automatic reconnection and Last-Event-ID resume
  • Signed mode (auth_mode: "signed" connections): per-visitor JWT auth via a tokenProvider callback, with automatic retry on token expiry
  • Multiple conversations per visitor (signed mode): list, switch, and start new conversations
  • Per-turn scoping: pass an opaque options map (built by your backend) to limit what each turn can consult — RAG documents, dataset rows, etc.
  • Headless mode: use FindChatClient directly for custom UIs or bots
  • Session persistence via shared_preferences (visitor & conversation IDs)

Platform support

Platform Supported
Android
iOS
Web
macOS
Windows
Linux

Installation

dependencies:
  find_ai_chat: ^0.4.0

Quick start

import 'package:find_ai_chat/find_ai_chat.dart';

FindChatWidget(
  connectionId: 'your-connection-id',
  baseUrl: 'https://api.example.com',
  theme: FindChatTheme.light,
  mode: FindChatMode.floating,
)

Global base URL

Configure once in main() instead of repeating on every widget:

void main() {
  FindChatEnvironment.configure(baseUrl: 'https://api.example.com');
  runApp(const MyApp());
}

All parameters

FindChatWidget:

Parameter Type Required What it does
connectionId String Public id of the webchat connection created in the Studio. Identifies which flow, branding, and auth mode the chat uses.
baseUrl String? Backend origin, no trailing slash (e.g. https://api.example.com). Falls back to FindChatEnvironment.configure(...) or --dart-define=FIND_CHAT_BASE_URL. Throws StateError if none is set.
externalVisitorId String? signed: ✅ Your own user id for the visitor (format ^[a-zA-Z0-9_-]{8,64}$). Public mode: optional — if omitted or invalid, an anonymous id is generated and persisted. Signed mode: required and must equal the visitor_id claim of the token (the backend returns 403 on any mismatch).
tokenProvider Future<String> Function()? signed: ✅ Returns the visitor JWT minted by your backend. Called before every request — cache the token and refresh only when expired/near expiry. Null for public connections.
originOverride String? Public mode only: value sent as the Origin header, must be in the connection's allowed_origins. Ignored by the backend in signed mode.
controller FindChatController? Bring your own controller for programmatic control from outside the widget tree. If provided, the widget does NOT initialize/dispose it.
theme FindChatTheme? .light / .dark / .system. Unset ⇒ uses the connection's color_scheme.
mode FindChatMode? .embedded / .floating / .drawer. Unset ⇒ uses the connection's display_mode.
visualStyle FindChatVisualStyle? .classic / .gptStyle. Unset ⇒ uses the connection's visual_style.
themeBuilder FindChatThemeBuilder? Advanced styling hook. Receives the SDK-resolved ThemeData and returns a modified copy.
showHeader bool Whether to render the chat panel header. Defaults to true; set false to hide the title/avatar/new-conversation/close bar.
actionsBuilder FindChatActionsBuilder? Composes the floating action menu opened by long-pressing the send button (see Action menu). Unset ⇒ SDK defaults.
options Map<String, dynamic>? Opaque per-turn scope sent as the options field of every message (see Scoping a turn). Changing it across rebuilds re-scopes the chat. Only applies when the widget owns its controller.

FindChatClient (headless) additionally accepts httpClient (for testing / custom transports).

Additional visual parameters on FindChatWidget:

Parameter Type What it does
userBubbleColor Color? Overrides the user message bubble background color.
assistantBubbleColor Color? Overrides the assistant message bubble background color in classic style.
userBubbleTextColor Color? Overrides the user message text color.
assistantBubbleTextColor Color? Overrides the assistant message text color.
inputHeight double? Fixed height for the bottom text field. Unset keeps automatic height.

FindChatController shares the transport/session parameters: connectionId, baseUrl, externalVisitorId, tokenProvider, originOverride and options.

Programmatic control

final controller = FindChatController(
  connectionId: 'your-connection-id',
  baseUrl: 'https://api.example.com',
);
Member What it does
initialize() Loads remote config (+ history of the persisted conversation, if any). Call once; FindChatWidget does it for its own implicit controller.
sendMessage(String) Sends one turn and streams the reply (state.streamingText updates per delta). No-op while state.pending.
open() / close() / toggle() Show/hide the chat in floating/drawer modes.
clearConversation() Clears messages and the persisted conversation id.
startNewConversation() Alias of clearConversation() — the next sendMessage starts a fresh conversation on the backend.
listConversations({limit, offset}) Signed mode: the visitor's conversations, newest activity first. See below.
selectConversation(String id) Switches the active conversation: persists it and loads its history into state.messages.
conversationId Currently active conversation id (null until the first message of a new one).
state Immutable FindChatState: status (idle / loadingConfig / ready / sending / streaming / error), pending, messages, streamingText, errorMessage, config.
isOpen Whether the panel is open (floating/drawer).

It's a standard ChangeNotifier — listen with ListenableBuilder / AnimatedBuilder.

If you provide your own controller to FindChatWidget, the widget will not initialize or dispose it — you are responsible for calling controller.initialize() and controller.dispose().

Display modes

Mode Behavior
embedded Inline widget in the tree. Place it inside an Expanded, SizedBox, or Scaffold.body.
floating Floating bubble (bottom-right corner) that opens a chat card overlay.
drawer No bubble — opened only via code (controller.open()). Side panel on wide screens, bottom sheet on narrow screens (<600dp).

When mode is not specified, the SDK uses the display_mode configured on the connection (defaults to floating). embedded is a Flutter-only mode with no backend equivalent.

Both floating and drawer render via Overlay — they float above the entire app. Mount FindChatWidget once anywhere under MaterialApp/WidgetsApp; no need to remount on every screen.

Action menu

Long-pressing the send button opens a floating menu of chat actions. It works in all three display modes and with showHeader: false, which makes it the only built-in way to reach these actions when the host app hides the header.

The SDK ships one action, Eliminar conversación. It asks for confirmation first, and on confirm it runs clearConversation(): the messages and the persisted conversation id are dropped on this device only. The conversation still exists on the backend and keeps showing up in listConversations(). It renders disabled while a turn is in flight, matching the header's ↻ button (which stays available and unchanged).

Use actionsBuilder to compose the menu. It receives the SDK defaults — already resolved against the current state, so enabled is correct — and returns the final list, so you can append, reorder, or drop entries:

FindChatWidget(
  connectionId: 'your-connection-id',
  actionsBuilder: (context, defaults) => [
    ...defaults,
    FindChatAction(
      id: 'contact_support',
      label: 'Talk to a human',
      icon: Icons.support_agent_rounded,
      onInvoke: () => openSupportTicket(),
    ),
  ],
)

FindChatAction fields:

Field Type What it does
id String Stable identifier. Lets you find and replace a default without relying on ordering; the built-in one is 'clear_conversation'.
label String Menu item text.
onInvoke FutureOr<void> Function() Runs when the item is picked (after confirmation, if any). The menu closes first.
icon IconData? Optional leading icon.
enabled bool false renders the item dimmed and unresponsive. Defaults to true.
isDestructive bool Paints the item with the theme's error color. Styling only.
confirmation FindChatActionConfirmation? Adds a confirmation step (title, message, confirmLabel, cancelLabel). Null ⇒ runs immediately.

Returning an empty list turns the menu off entirely — the send button goes back to being a plain button with no long-press gesture.

The menu and its confirmation render through OverlayPortal rather than showMenu/showDialog. In floating and drawer modes the chat panel is inserted above the Navigator's overlay entries, so route-based popups would be painted behind the chat.

Theming

  • FindChatTheme.light / .dark / .system — explicit override. Without an override, the SDK uses the color_scheme from the connection config (defaults to light).
  • FindChatVisualStyle.classic / .gptStyle — explicit override. Without an override, uses the visual_style from the connection config (defaults to classic).
  • themeBuilder runs after the SDK resolves brightness and remote primary color, so the host app can tune typography, colors, input decoration, etc.
  • showHeader: false hides the SDK panel header when the host app already provides its own chrome around the chat.

Precedence: explicit widget parameter → remote connection config → factory default.

FindChatWidget(
  connectionId: 'your-connection-id',
  themeBuilder: (context, baseTheme) {
    return baseTheme.copyWith(
      textTheme: baseTheme.textTheme.apply(fontFamily: 'Inter'),
      inputDecorationTheme: const InputDecorationTheme(
        border: InputBorder.none,
      ),
    );
  },
)

Architecture

FindChatWidget           — entry point; resolves theme/mode and delegates
  ├─ FindChatEmbeddedView
  ├─ FindChatFloatingBubble  (via Overlay)
  └─ FindChatDrawerView      (via Overlay)
FindChatController       — ChangeNotifier: init, sendMessage, open/close/toggle, clearConversation
FindChatClient           — HTTP + SSE, stateless
FindChatSessionStore     — visitor_id/conversation_id (shared_preferences)

FindChatClient can be used standalone (without the UI) for headless integrations or fully custom UIs — it depends only on package:http.

Signed mode (authenticated visitors)

For apps whose users are already authenticated, create the webchat connection with config.auth_mode: "signed" in the Studio. Your app backend (never the app itself) mints a short-lived JWT for its logged-in user — HS256, signed with the connection's signing secret (obtained via the Studio's "rotate secret" action), claims visitor_id + exp — and the SDK attaches it to every request:

FindChatWidget(
  connectionId: 'your-connection-id',
  externalVisitorId: currentUser.id,      // must equal the token's visitor_id claim
  tokenProvider: () async => myAuthService.getFindChatToken(),
  mode: FindChatMode.drawer,
)

The provider is called before every HTTP request (including each SSE reconnection attempt), so it must be cheap on the hot path. Cache the token and only call your backend when it's expired or about to expire:

class FindChatAuth {
  String? _cachedToken;
  DateTime? _expiresAt;

  Future<String> getToken() async {
    if (_cachedToken != null && _expiresAt != null &&
        DateTime.now().isBefore(_expiresAt!.subtract(const Duration(seconds: 30)))) {
      return _cachedToken!;
    }
    // Hit YOUR backend — never call the Find AI API directly for this.
    final res = await http.post(
      Uri.parse('https://your-backend.com/api/find-chat/token'),
      headers: {'Authorization': 'Bearer ${session.accessToken}'},
    );
    final body = jsonDecode(res.body);
    _cachedToken = body['token'] as String;
    // exp claim is seconds since epoch
    final exp = body['expires_at'] as int;
    _expiresAt = DateTime.fromMillisecondsSinceEpoch(exp * 1000);
    return _cachedToken!;
  }
}

Then pass it:

final findChatAuth = FindChatAuth();

FindChatWidget(
  connectionId: 'your-connection-id',
  externalVisitorId: currentUser.id,
  tokenProvider: findChatAuth.getToken,
  mode: FindChatMode.floating,
)

Your backend's token endpoint

Your backend (not the Flutter app) is the only place that has access to the connection's signing secret. A minimal endpoint:

# Example (Python / FastAPI)
@app.post("/api/find-chat/token")
async def get_webchat_token(request: Request):
    user = await get_current_user(request)  # your existing auth
    token = generate_webchat_visitor_token(
        connection_id="your-connection-id",
        visitor_id=user.id,               # same id you pass as externalVisitorId
        signing_secret=FIND_CHAT_SECRET,  # from Studio → connection → "Rotate secret"
        ttl_seconds=3600,                 # recommended: 1h
    )
    return {"token": token, "expires_at": int(time.time()) + 3600}

Never embed the signing secret in the Flutter app. It must stay server-side. The secret lets anyone mint tokens for any visitor — leaking it compromises the entire connection.

The tokenProvider is called before every request (including each SSE reconnection), so it should cache the JWT and only hit your backend when the token is missing or about to expire. On a 401 the SDK asks the provider for a fresh token and retries once; a second 401 surfaces as FindChatAuthException — renew the user's session then.

In signed mode the backend ignores the Origin header entirely (native apps don't send one), so neither originOverride nor allowed_origins matter.

Multiple conversations — fetching a user's chats

Signed connections unlock GET /conversations: every conversation the authenticated visitor ever had on this connection, newest activity first. The SDK exposes it on the controller and leaves the list UI to your app.

final result = await controller.listConversations(limit: 20, offset: 0);

result.visitorId;      // the visitor the token belongs to
result.total;          // total count (for pagination)
result.conversations;  // List<FindChatConversationSummary>, one per chat:
//   .conversationId  — pass to selectConversation()
//   .startedAt       — ISO-8601 timestamp of the first message
//   .lastMessageAt   — ISO-8601 timestamp of the latest activity (sort key)
//   .messageCount    — number of messages in the conversation

A minimal "my chats" screen wired to the drawer:

class MyChatsScreen extends StatefulWidget {
  const MyChatsScreen({super.key, required this.controller});
  final FindChatController controller;

  @override
  State<MyChatsScreen> createState() => _MyChatsScreenState();
}

class _MyChatsScreenState extends State<MyChatsScreen> {
  late Future<FindChatConversationsResult> _chats;

  @override
  void initState() {
    super.initState();
    _chats = widget.controller.listConversations();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: _chats,
      builder: (context, snapshot) {
        if (snapshot.hasError) return Text('No se pudieron cargar tus chats');
        final chats = snapshot.data?.conversations ?? const [];
        return ListView(
          children: [
            ListTile(
              leading: const Icon(Icons.add),
              title: const Text('Nueva conversación'),
              onTap: () async {
                await widget.controller.startNewConversation();
                widget.controller.open();
              },
            ),
            for (final c in chats)
              ListTile(
                title: Text('Chat del ${DateTime.parse(c.lastMessageAt).toLocal()}'),
                subtitle: Text('${c.messageCount} mensajes'),
                selected: c.conversationId == widget.controller.conversationId,
                onTap: () async {
                  await widget.controller.selectConversation(c.conversationId);
                  widget.controller.open();  // opens the chat with its history loaded
                },
              ),
          ],
        );
      },
    );
  }
}

Notes:

  • The listing is scoped server-side to the token's visitor — a user can never see (or guess their way into) another user's chats.
  • On public connections listConversations() throws a 404 FindChatApiException: the endpoint only exists where visitor identity is verified. Anonymous visitors keep the single persisted conversation.
  • The backend has no per-conversation titles yet — build labels from lastMessageAt/messageCount, or load a preview via the history endpoint.

Error handling

All API errors are typed:

Exception When Typical reaction
FindChatAuthException (extends FindChatApiException, always 401) Signed mode: token missing/invalid/expired, even after one retry with a fresh token Renew the user's session; check the signing secret matches the connection
FindChatApiException .statusCode == 403 Origin not allowed (public) or visitor_id ≠ token claim (signed) Fix the connection config / pass the right externalVisitorId
FindChatApiException .statusCode == 404 Connection missing/inactive, or listConversations() on a public connection
FindChatApiException .statusCode == 429 Per-visitor or per-connection rate limit Show "wait a moment"
FindChatTurnFailedException The flow emitted a terminal error event Offer retry
FindChatTurnUnavailableException SSE could not be sustained (3 failed attempts) or cancelled Offer retry

FindChatController already maps all of these to user-facing state.errorMessage strings — the table matters when you call FindChatClient directly.

Scoping a turn with options

By default a turn can consult everything the flow has access to. options narrows that down per message — for example, so a chat opened from a product screen only answers about that product.

The map is opaque to the SDK: it is serialized as-is into the options field of POST /messages and never inspected. Build it in your backend and forward it untouched; extending the contract server-side then needs no new SDK release.

// Your backend returns the scope for the screen the user is on.
final options = await myApi.fetchChatOptions(productId); // Map<String, dynamic>

FindChatWidget(
  connectionId: 'your-connection-id',
  baseUrl: 'https://api.example.com',
  options: options,
)

The shape is defined by your backend and the flow's node slugs. For instance:

{
  "knowledge_bases": [
    { "slug": "kb_products", "document_ids": ["doc_abc", "doc_def"] }
  ],
  "datasets": [
    { "slug": "products", "filters": { "id": "42" } }
  ]
}

Re-scoping while the chat is open

Passing a different map on a rebuild propagates to the chat, so navigating from one product to another re-scopes it without recreating the widget. With your own controller, call setOptions() instead — the widget does not overwrite a controller it does not own:

controller.setOptions(await myApi.fetchChatOptions(otherProductId));

It applies to subsequent messages only: a turn already in flight keeps the scope it was sent with. Pass null to remove the scope entirely.

Verifying it works. If the field name or a slug does not match what the backend expects, the turn is usually accepted and answered anyway — just without the scope applied. A successful reply is therefore not proof that scoping is in effect. Test it by asking about something that lives outside the intended scope: if the assistant answers, the scope is not being applied.

Advanced topics

Origin header in native apps (public mode)

On auth_mode: "public" connections the backend validates the Origin header against the connection's allowed_origins. Since native HTTP clients can set this header freely, two options exist:

  • Set allowed_origins: ["*"] on the connection (recommended for first-party apps).
  • Pass originOverride to FindChatWidget / FindChatController / FindChatClient with a value in the allowlist.

For real access control in native apps, prefer signed mode (above).

SSE reconnection

FindChatClient.streamTurn reconnects automatically using Last-Event-ID on network cuts or server timeout (CHAT_STREAM_SSE_MAX_SECONDS, 300s default). Reconnection never duplicates already-delivered text. Gives up after 3 consecutive failures with no events received (FindChatTurnUnavailableException).

Limitations

  • No unread message badge in floating mode.
  • Cancellation sets a client-side flag but cannot abort the underlying TCP connection (package:http limitation).
  • Mid-stream app kills cannot resume the specific turn on restart (conversation history is still available via /history).

Running tests

flutter test

Covers SSE frame parsing (including split UTF-8 chunks), turn accumulator (node retries, multi-node text, terminal events), FindChatClient (request building, error mapping, reconnection with Last-Event-ID), and signed mode (Bearer token on every endpoint, 401 retry-once, token renewal across SSE reconnections, conversation listing/switching) and per-turn options (forwarded verbatim, omitted when empty, re-scoping via setOptions).

License

MIT — see LICENSE.

Libraries

find_ai_chat
Flutter SDK for embedding the Find AI chat assistant — equivalent to widget.js for Flutter apps (mobile & web), talking to the same public webchat API.