find_ai_chat 0.8.0 copy "find_ai_chat: ^0.8.0" to clipboard
find_ai_chat: ^0.8.0 copied to clipboard

Platformweb

Flutter SDK for the Find AI chat assistant. Drop-in widget with floating, drawer and embedded modes, SSE streaming, and signed-mode visitor auth.

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.
  • Voice input (opt-in): mic button in the composer, live audio visualization while recording, and on-device/browser transcription
  • Tappable links in assistant messages, opened outside the app by default or handled in-app with onLinkTap, restricted to safe schemes
  • Configurable empty state: title, description and clickable suggestions before the first message
  • Callbacks & events: typed hooks plus a single onEvent channel for analytics
  • Spanish and English built in, error messages included, and every visible string overridable on top — a language is one parameter
  • Headless mode: use FindChatClient directly for custom UIs or bots
  • Session persistence via shared_preferences (visitor & conversation IDs)

Platform support #

Platform Chat Voice input
Web ✅ tested ✅ browser-dependent (see Voice input)
Android ⚠️ untested ⚠️ untested — needs RECORD_AUDIO
iOS ⚠️ untested ⚠️ untested — needs the usage-description keys
macOS ⚠️ untested ⚠️ untested
Windows ⚠️ untested ⚠️ untested
Linux ⚠️ untested ❌ no engine

The package declares platforms: web because that is the only target we test. Nothing in the code is web-only — the other platforms are expected to work, they just have no CI behind them.

Installation #

dependencies:
  find_ai_chat: ^0.7.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 action menu opened by the composer's "+" button (see Action menu). Unset ⇒ SDK defaults.
hiddenActions Set<String> {} Ids of actions to hide from the "+" menu, SDK or host-added (see Action menu). Empty ⇒ all shown.
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.
audio FindChatAudioConfig? — Enables the mic button and dictation (see Voice input). Unset ⇒ no mic, composer unchanged.
emptyState FindChatEmptyState? — Initial screen with title, description and suggestions (see Empty state). Unset ⇒ the connection's welcome_message as an assistant bubble, as before.
callbacks FindChatCallbacks? — Hooks for suggestions, recording, transcription and link taps, plus onEvent for analytics (see Callbacks & events).
strings FindChatStrings? — Language for every SDK text (see Texts & translation). Unset ⇒ Spanish; FindChatStrings.of(context) follows the app's locale.
labels FindChatLabels? — Panel texts: header title, close tooltip, action menu, load error (see Texts & translation). Unset fields keep whatever strings says.

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.
composerLabels FindChatComposerLabels? Composer texts: field placeholder, keyboard hint (and whether to show it), and the status label while a turn is in flight. Unset fields keep the SDK defaults.

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 on this device. The thread stays on the server.
startNewConversation() Alias of clearConversation() — the next sendMessage starts a fresh conversation on the backend.
deleteConversation({conversationId}) Deletes a conversation on the server (messages, trace and engine state). Defaults to the active one, which also clears it locally; pass an id to delete another from your own list. Returns bool. See below.
emptyConversation() Empties the active conversation on the server but keeps the thread: same id, still listed by listConversations(), no messages. Returns bool.
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, errorKind, errorDetail, config.
isOpen Whether the panel is open (floating/drawer).

One rule covers the whole surface: clear* only touches this device, delete* talks to the server.

The two server-side gestures need the connection to allow it — always the case in signed mode, opt-in per connection in public ones — which the SDK reads from state.config.visitorCanDelete. Neither throws: a failure lands in state.errorMessage (the panel shows it in its error banner) and the call returns false. true means the server accepted the request — not that there was anything to delete: the API answers 204 for ids that never existed, deliberately, since a 404 would let a visitor probe other people's ids. Both are no-ops while a turn is in flight, because the worker recreates the conversation row when the turn ends.

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 #

The "+" button on the left of the composer opens a menu of chat actions. It lives in the composer rather than the header because the header is optional (showHeader: false) while the composer is always visible, so the actions stay reachable in all three display modes.

The SDK ships three actions. All confirm before they run, and all render disabled while a turn is in flight.

Nueva conversación (FindChatActionIds.newConversation) always shows. It runs startNewConversation(): the messages and the persisted conversation id are dropped on this device only, and the next message starts a new conversation on the backend. The previous one still exists there, with its messages, and keeps showing up in listConversations().

Vaciar conversación (FindChatActionIds.emptyConversation) runs emptyConversation(): the messages, the trace and the engine's state (flow node, AI history, half-finished data collection) are removed from the server, but the thread survives — same id, still listed, empty — so the next message continues in it.

Eliminar conversación (FindChatActionIds.deleteConversation) runs deleteConversation(): the same wipe, plus the thread itself, which disappears from listConversations(). This device is cleared too, so the next message opens a new conversation.

The last two show only when the connection allows deleting — visitor_can_delete in GET /config, surfaced as state.config.visitorCanDelete: always true on signed connections, opt-in per connection on public ones. Both are also disabled when there is no conversation yet, since there is nothing to act on.

The three leave an equally empty panel, but they are three different outcomes in a visitor's chat list, which is why all three ship. On a public connection there is no such list (listConversations() is signed-only), so the difference between emptying and starting over is invisible there — and the SDK cannot tell one mode from the other reliably (auth_mode never reaches the config, and the controller's tokenProvider is null when the host injects its own FindChatClient). If a shorter menu suits your connection better, drop what you don't want by id:

FindChatWidget(
  connectionId: 'your-connection-id',
  hiddenActions: const {FindChatActionIds.emptyConversation},
)

Success shows nothing: the chat is simply empty. That is deliberate. The API answers 204 whether or not the conversation existed, so "conversation deleted" would claim more than the server said. A failure (a public connection without the flag, no network) leaves the messages alone and shows the reason in the panel's error banner.

Two parameters shape the menu. Both default to "show everything the SDK ships".

hiddenActions hides actions by id. It applies to the final list, so it hides SDK actions and host-added ones alike; when nothing is left, the "+" button is not rendered.

FindChatWidget(
  connectionId: 'your-connection-id',
  hiddenActions: const {FindChatActionIds.newConversation},
)

actionsBuilder composes the list. 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 replace 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, replace or hide an action without relying on ordering. The built-in ids live in FindChatActionIds.
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 — defaults to "Confirmar" —, cancelLabel). Null ⇒ runs immediately.

Returning an empty list from actionsBuilder turns the menu off entirely: the "+" button is not rendered.

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,
      ),
    );
  },
)

Composer & message list #

The bottom composer follows the Claude mobile / Claude Code layout: a bordered, rounded box with a single row — the "+" action button on the left, the text field, and the send button on the right — and a discreet caption line below the box for the keyboard hint or the in-flight status. The text field grows up to 6 lines (the buttons stay bottom-aligned); inputHeight pins it to a fixed height instead. The same composer is used by both visual styles — only the accent changes (brand color in classic, monochrome in gptStyle).

  • Desktop (native and web): Enter sends, Shift+Enter inserts a newline. The caption under the box reminds the visitor of both.

  • Touch: the keyboard's action key sends; the caption only appears while a turn is in flight.

  • The "+" button on the left opens the action menu.

  • All composer texts are overridable through composerLabels:

    FindChatWidget(
      connectionId: '...',
      composerLabels: const FindChatComposerLabels(
        placeholder: 'Ask the assistant…',
        keyboardHint: 'Enter to send · Shift+Enter for a new line',
        pendingLabel: 'Thinking…',
        // null (default) shows the hint on desktop only; true/false force it.
        showKeyboardHint: null,
      ),
    )
    
  • While a turn is in flight the send button is disabled and the caption shows "Pensando…", but the field stays editable and focused, so the next message can be typed without waiting for the reply.

  • The message list opens on the last message (first load with history, conversation switch, clear) and auto-follows streaming only while the visitor is at the bottom: scrolling up to re-read pauses the follow, sending a message resumes it.

  • Surfaces use a neutral gray palette in both light and dark mode; the brand color is reserved for accents (classic header, user bubble, send button, links). themeBuilder runs after this and can override any of it.

Voice input #

Not enabled by default. Passing audio adds a mic button to the left of the send button; everything else about the composer stays the same.

FindChatWidget(
  connectionId: '...',
  audio: const FindChatAudioConfig(),
)

While recording, the text field is replaced by a recording row — [cancel] ‖|‖|‖ bars ‖|‖|‖ 00:12 [finish] — and the draft that was in the field is kept for when it comes back. Finishing switches the bars for a discreet "transcribing" indicator until the text arrives.

What happens with the transcript is up to transcriptMode:

Mode Behaviour
editInComposer (default) Appended to the draft, focused, cursor at the end — the visitor reviews and sends.
autoSend Sent as a user message straight away. If a turn is in flight (nothing can be sent), it falls back to the composer so the dictation is never lost.
preview Shown in a card above the composer with Send / Edit / Discard.

All the options:

Option Type Default What it does
enabled bool true Master switch — false hides the mic without touching the rest of the config.
transcriptMode FindChatTranscriptMode editInComposer See the table above.
localeId String? system/browser Recognition language, e.g. es_AR, en_US.
maxDuration Duration 2 min Auto-finishes the recording.
showPartialTranscript bool false Shows the live partial text under the bars. Off by default: partial text rewrites itself while you speak and distracts more than it helps.
visualization FindChatAudioVisualization see below The animated bars.
labels FindChatAudioLabels? from strings Every visible string, error messages included. Overrides the chosen language.
recognizer FindChatSpeechRecognizer? speech_to_text Plug in your own engine.
levelSource FindChatAudioLevelSource? platform Plug in your own amplitude source.

Audio visualization #

The bars react to the real microphone amplitude: louder speech makes them taller, quiet speech shorter, and silence leaves them almost still. On web the level is measured with the browser's own APIs — getUserMedia → AudioContext → AnalyserNode → RMS — running alongside the recognizer (the visitor still sees a single permission prompt). On mobile/desktop it comes from the level the recognition engine reports.

When no amplitude can be measured, the animation does not freeze. It falls back, in this order:

  1. Real microphone amplitude.
  2. Voice-activity detection (partial results arriving = someone is talking).
  3. A smoothed pseudo-random animation while recording is true.

A failure to visualize never blocks recording or transcription.

audio: const FindChatAudioConfig(
  visualization: FindChatAudioVisualization(
    enabled: true,          // false ⇒ only the recording dot and the timer
    useRealAmplitude: true, // false ⇒ skips the microphone measurement
    fallbackAnimation: true,// false ⇒ bars stay still with no signal
    barCount: 24,           // 4..64
    smoothing: 0.6,         // 0 = snappy, 1 = very smooth
    color: null,            // null ⇒ the composer accent
  ),
)

Rendering does not rebuild any widget per frame: a single ticker advances the animation and only the bars repaint.

Platform notes #

Transcription uses the platform's speech recognition through speech_to_text. Consequences worth knowing before you ship it:

  • Browser support varies. Chrome and Edge work; Firefox does not. Where the API is missing the mic button hides itself after the first attempt instead of offering something that cannot work.
  • Chrome sends the audio to Google's servers and needs a connection.
  • Chrome ends the session on its own after a long silence. The SDK treats that as "finish" and transcribes what it got.
  • The engine is a per-process singleton. Two chat widgets on the same screen share it: whichever records last takes the session.
  • Native builds need permissions you declare in your app: NSMicrophoneUsageDescription and NSSpeechRecognitionUsageDescription on iOS/macOS, RECORD_AUDIO on Android.

Bringing your own engine #

Implement FindChatSpeechRecognizer to use a different transcription service (or a fake, for tests) — the SDK only needs partial/final results and a couple of lifecycle calls:

audio: FindChatAudioConfig(recognizer: MyRecognizer()),

Empty state & suggestions #

Before the first message, emptyState replaces the welcome bubble with a centered screen and clickable shortcuts:

FindChatWidget(
  connectionId: '...',
  emptyState: const FindChatEmptyState(
    title: 'How can I help?',
    description: 'Ask anything, or start with one of these.',
    placeholder: 'Type or dictate your question…',
    suggestions: [
      FindChatSuggestion(
        id: 'order',
        label: 'Track my order',
        message: 'I want to know the status of my last order.',
        icon: Icons.local_shipping_outlined,
      ),
    ],
  ),
)
  • label is what the visitor reads; message is what gets sent — so a short button can carry a long prompt.
  • Tapping one sends it immediately and the empty state disappears.
  • title falls back to the connection's title and description to its welcome_message, so an existing setup needs no duplication.
  • placeholder only applies while the empty state is visible.
  • Not passing emptyState (or enabled: false) keeps the previous behaviour.

Callbacks & events #

FindChatWidget(
  connectionId: '...',
  callbacks: _callbacks, // build it once, not inside build()
)

final _callbacks = FindChatCallbacks(
  onEvent: (event) => analytics.track(event.type),
  onSuggestionClick: (s) => print('suggestion ${s.id}'),
  onTranscriptionError: (e) => log.warn('voice: ${e.kind}'),
);

onEvent receives everything, which is what you want for analytics — one hook instead of one per feature. The typed callbacks are sugar for reacting to a single thing.

Event (type) Typed callback
suggestion_selected onSuggestionClick
recording_started onRecordingStart
recording_cancelled onRecordingCancel(Duration)
recording_completed onRecordingComplete(Duration)
transcription_completed onTranscriptionComplete(String)
transcription_failed onTranscriptionError(FindChatVoiceError)
message_sent — (carries source: text / voice / suggestion)
link_tapped onLinkTap(Uri) -> bool (carries the target url)

Markdown links in an assistant message are tappable. By default the SDK opens them outside the app; onLinkTap lets you take the ones that are yours:

FindChatCallbacks(
  onLinkTap: (url) {
    if (url.host != 'app.mycompany.com') return false; // let the SDK open it
    context.go(url.fragment);                          // handle it in-app
    return true;
  },
)

Assistants usually link back into your own app, and bouncing a visitor to a browser tab to reach a screen they already have open loses them. Returning true means you handled it and the SDK does nothing else.

Only http, https, mailto and tel are opened, and anything else is dropped before your callback runs. The link target is written by a model out of your tenant's data, so javascript: and data: never reach either your app or the browser. Relative links are dropped too: a chat message has no base URL to resolve them against.

onAudioActivityChange(bool) fires only when voice activity starts or stops (it has hysteresis), never per frame, so it is a callback and not an event.

These are UI events: calling controller.sendMessage() from your own code does not emit anything.

Texts & translation #

Every visible string can be replaced, and the SDK ships two full languages.

Pick a language with strings. FindChatStrings is the complete bundle — panel, composer, voice and error messages — so one line translates everything:

FindChatWidget(connectionId: '...', strings: FindChatStrings.en)

Without it the chat renders in Spanish, the SDK's historical default. To follow the app's own language instead, pass the resolver:

FindChatWidget(connectionId: '...', strings: FindChatStrings.of(context))

That is opt-in on purpose. MaterialApp resolves the locale against supportedLocales, which is [en_US] until you configure it — so an app that never touched localization reports English even on a Spanish device, and deducing the language would flip existing chats to English unasked.

For a language the SDK doesn't ship, copy the closest one:

final ptBr = FindChatStrings.en.copyWith(
  headerTitle: 'Suporte',
  composerPlaceholder: 'Escreva uma mensagem…',
  errorTurnFailed: 'Algo deu errado ao processar sua mensagem. Tente novamente.',
);

Override single strings with the three per-area objects, which apply on top of the chosen language — null means "keep what the language says":

FindChatWidget(
  connectionId: '...',
  labels: const FindChatLabels(
    headerTitle: 'Assistant',
    closeTooltip: 'Close',
    actionsMenuLabel: 'Chat actions',
    newConversationLabel: 'New conversation',
    newConversationConfirmTitle: 'Start over?',
    newConversationConfirmMessage: 'The current messages stop showing on this device.',
    newConversationConfirmLabel: 'Start',
    newConversationCancelLabel: 'Cancel',
    emptyConversationLabel: 'Empty conversation',
    emptyConversationConfirmTitle: 'Empty this conversation?',
    emptyConversationConfirmMessage: 'Its messages are removed from the server; the chat stays.',
    emptyConversationConfirmLabel: 'Empty it',
    emptyConversationCancelLabel: 'Cancel',
    deleteConversationLabel: 'Delete conversation',
    deleteConversationConfirmTitle: 'Delete this conversation?',
    deleteConversationConfirmMessage: 'It and its messages are removed from the server.',
    deleteConversationConfirmLabel: 'Delete',
    deleteConversationCancelLabel: 'Cancel',
    loadErrorMessage: 'The chat is unavailable right now.',
  ),
  composerLabels: const FindChatComposerLabels(placeholder: 'Ask anything…'),
  audio: const FindChatAudioConfig(
    labels: FindChatAudioLabels(
      recordLabel: 'Record a message',
      processingLabel: 'Transcribing…',
      errorPermissionDenied: 'We need permission to use the microphone.',
    ),
  ),
)

Any field left null keeps the SDK default. Setting loadErrorMessage also replaces the reason the backend returned, which is the point: it stops a technical or foreign-language string from reaching the visitor.

Error messages are translated too. The controller has no BuildContext, so it cannot know the language: it stores a typed reason in state.errorKind (FindChatErrorKind) and the widget resolves it against the chosen language at render time. state.errorMessage still carries the Spanish default text, for code that already reads it; to show it yourself in another language, resolve the reason:

final text = state.errorKind == null
    ? null
    : FindChatStrings.en.errorMessage(state.errorKind!, detail: state.errorDetail);

errorDetail is the reason the backend gave, when it gave one. It is not translated — it comes from the server — and only accompanies FindChatErrorKind.loadFailed, where it wins over the generic text.

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
                },
                trailing: IconButton(
                  icon: const Icon(Icons.delete_outline),
                  onPressed: () async {
                    // Deletes it on the server; clears the panel too if it was
                    // the active one. `true` = the server took the request, not
                    // proof the conversation existed.
                    if (await widget.controller.deleteConversation(
                      conversationId: c.conversationId,
                    )) {
                      setState(() => _chats = widget.controller.listConversations());
                    }
                  },
                ),
              ),
          ],
        );
      },
    );
  }
}

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), visitor_id ≠ token claim (signed), or a delete on a public connection without visitor_can_delete Fix the connection config / pass the right externalVisitorId
FindChatApiException .statusCode == 404 Connection missing/inactive, or listConversations() on a public connection —
FindChatApiException .statusCode == 405 Deleting against a backend that does not expose the delete endpoints yet Only reachable through FindChatClient directly — the menu item is gated on visitor_can_delete
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).
  • Voice input depends on the platform's speech recognition: unsupported browsers (Firefox) hide the mic, and Chrome needs a connection because it transcribes server-side. See Platform notes.
  • Only Spanish and English ship with the SDK; other languages are a FindChatStrings.copyWith away, per app.

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

Also server-side deletion: verb, path and whether visitor_id travels (public) or comes from the token (signed), the local state each gesture leaves behind, the in-flight-turn guard, and failures surfacing in the banner instead of throwing — including the menu item staying hidden when the connection does not return visitor_can_delete.

Also the UI: composer behaviour, action menu, empty state and suggestions, label overrides, and voice input end to end — the recording state machine (spontaneous engine end, late results after cancelling, permission vs. unsupported, guard timeouts, cleanup on unmount) with a scripted fake engine, plus the waveform driver (real amplitude, voice activity, and the fallback animation) as pure Dart.

License #

MIT — see LICENSE.

2
likes
160
points
212
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter SDK for the Find AI chat assistant. Drop-in widget with floating, drawer and embedded modes, SSE streaming, and signed-mode visitor auth.

Homepage
Repository (GitHub)

License

MIT (license)

Dependencies

flutter, flutter_markdown_plus, http, shared_preferences, speech_to_text, url_launcher, web

More

Packages that depend on find_ai_chat