Build an AI Assistant with Flutter

In this tutorial, we will demonstrate how easy it is to create an AI assistant for Flutter using Stream Chat and our stream_chat_flutter_ai package. We use the Stream Chat integration with Vercel's AI SDK on the backend, but you are free to use whichever LLM provider you like and still benefit from Stream's rich UI support for Markdown, tables, code samples, charts, LaTeX and more.

To follow along, create a free account and check out our main Flutter Chat SDK tutorial as a refresher. You will need Flutter 3.41.0 or newer (Dart 3.11 or newer) and Node.js 24, which runs the backend's TypeScript directly. This tutorial walks through the key parts of the sample apps and links to the rest. For reference documentation, see the AI integrations guide and the Stream Chat integration page.

Here's a video of the end result:

Running the Backend

Before adding AI features to our Flutter app, let's set up our node.js backend. It is the ai-sdk-sample project from the chat-ai-samples repository, built on the @stream-io/chat-ai-sdk package. Rather than building it from scratch, we clone it and look at the parts the Flutter app talks to.

The backend exposes four endpoints:

Endpoint Purpose
POST /start-ai-agent Starts (or reuses) an AI agent for a channel. The agent listens to new messages, sends them to the LLM provider of your choice, and streams the reply by sending a message and updating its text.
POST /stop-ai-agent Stops the agent for a channel and cleans up.
POST /register-tools Registers the client-side tools a channel's agent may call.
POST /summarize Turns a piece of text into a short title. The Flutter app uses it to name each new conversation after its first message.

The sample also supports different states of the typing indicator (for example, Thinking, Checking external sources, etc), suggestions, memory with mem0 and much more.

1. Clone and Install

git clone https://github.com/GetStream/chat-ai-samples.git
cd chat-ai-samples/ai-sdk-sample
npm install

2. Configure Credentials

Copy .env.example to .env and fill in your keys:

cp .env.example .env
STREAM_API_KEY=your_key
STREAM_API_SECRET=your_secret
OPENAI_API_KEY=your_open_api_key
ANTHROPIC_API_KEY=your_anthropic_api_key
XAI_API_KEY=your_xai_api_key
GEMINI_API_KEY=your_google_api_key
MEM0_API_KEY=your_mem0_api_key

Apart from the Stream API key and secret, every LLM key is optional, but you need at least one for the AI to work. The Flutter app in this tutorial asks for OpenAI, so set OPENAI_API_KEY. The .env.example file also lists optional keys for the weather tool example and for mem0 memory. Use the same Stream app for the backend and the Flutter app, or the agent will never appear in your conversations.

3. Start the Server

npm start

The server listens on http://localhost:3000 unless you set PORT. Node.js 24 runs the TypeScript in src/index.ts directly, so there is no build step.

4. How It Works

src/index.ts creates an Express app and an AgentManager, which owns the agent cache, pending state and inactivity cleanup. Each channel gets an agent user with an ID such as ai-bot-{channelId}. Here is a simplified version of the endpoint that starts an agent. In the sample, buildAgentUserId and normalizeChannelId live in src/utils.ts, and the AgentManager has a few more options for the demo's extras:

import "dotenv/config";
import express from "express";
import cors from "cors";
import {
  AgentManager,
  AgentPlatform,
  createDefaultTools,
} from "@stream-io/chat-ai-sdk";
import { buildAgentUserId, normalizeChannelId } from "./utils.ts";

const app = express();
app.use(express.json());
app.use(cors({ origin: "*" }));

const agentManager = new AgentManager({
  serverToolsFactory: () => createDefaultTools(),
  agentIdResolver: buildAgentUserId,
});

app.post("/start-ai-agent", async (req, res) => {
  const {
    channel_id,
    channel_type = "messaging",
    platform = AgentPlatform.OPENAI,
    model,
  } = req.body ?? {};

  const channelId = normalizeChannelId(channel_id ?? "");
  if (!channelId) {
    res.status(400).json({ error: "Invalid channel_id" });
    return;
  }

  const resolvedPlatform = Object.values(AgentPlatform).find(
    (value) => value === String(platform).toLowerCase(),
  );
  if (!resolvedPlatform) {
    res.status(400).json({ error: "Unsupported platform" });
    return;
  }

  try {
    await agentManager.startAgent({
      userId: buildAgentUserId(channelId),
      channelId,
      channelType: channel_type,
      platform: resolvedPlatform,
      model: model?.trim(),
    });
    res.json({ message: "AI Agent started" });
  } catch (error) {
    res.status(500).json({
      error: "Failed to start AI Agent",
      reason: (error as Error).message,
    });
  }
});

/stop-ai-agent calls agentManager.stopAgent(...) the same way. /register-tools lets clients register tools that are handled client-side:

app.post("/register-tools", (req, res) => {
  const { channel_id, tools } = req.body ?? {};

  if (typeof channel_id !== "string" || !channel_id.trim()) {
    res.status(400).json({ error: "Missing or invalid channel_id" });
    return;
  }
  if (!Array.isArray(tools)) {
    res.status(400).json({ error: "Missing or invalid tools array" });
    return;
  }

  const channelId = normalizeChannelId(channel_id);
  agentManager.registerClientTools(channelId, tools);
  res.json({ message: "Client tools registered", count: tools.length });
});

/summarize accepts text (and optionally platform and model) and returns { summary }. Its default platform is Anthropic, so the Flutter app passes openai explicitly to match the key you configured. See the complete file for the full implementation.

Flutter Integration

Next, let's set things up on the Flutter side. The complete app lives in the flutter folder of the same repository. The snippets below show the key parts of each file in lib/src/ with the imports they need, and link to the full file wherever we abbreviate. If you'd rather skip typing, copy flutter/lib/ from the sample into your own project and read on to see how the pieces fit. This is how the app is laid out:

File What it contains
lib/main.dart main, MyApp: creates the client, connects the user, owns the theme mode
lib/src/agent_service.dart AgentService: calls to the backend
lib/src/typing_state_handler.dart TypingStateHandler: the AI typing state of a channel
lib/src/conversation_view.dart ConversationView, AITypingIndicatorStateView: message list and typing indicator
lib/src/ai_message_item.dart AIMessageItem: renders an AI reply
lib/src/code_highlighter.dart highlightCode: syntax highlighting for code blocks
lib/src/home_page.dart HomePage, _ConversationDrawer, _LandingView: the only screen
lib/src/connection_banner.dart ConnectionBanner: offline/reconnecting strip
lib/src/client_tools.dart GreetUserTool, SetThemeModeTool, ClientToolListener: client-side tools

1. Add the Required Packages

Create a new Flutter project (or open an existing one):

flutter create stream_chat_ai_assistant_flutter_example

Then add the following packages to your pubspec.yaml. These are the versions the sample uses:

Package Purpose Version
stream_chat_flutter Stream Chat client and UI components ^10.0.1
stream_chat_flutter_ai AI components: streaming markdown, typing indicator, composer, client tools ^0.1.0
dio HTTP calls to the backend ^5.7.0
uuid IDs for new conversations ^4.5.1
dependencies:
  flutter:
    sdk: flutter
  dio: ^5.7.0
  stream_chat_flutter: ^10.0.1
  stream_chat_flutter_ai: ^0.1.0
  uuid: ^4.5.1
  # Optional, for rich replies:
  flutter_math_fork: ^0.7.4 # typesets LaTeX
  re_highlight: ^0.0.3 # syntax-highlights code blocks
  url_launcher: ^6.3.1 # opens links in replies

stream_chat_flutter_ai has no dependency on Stream Chat. Every widget works on plain strings, callbacks and controllers, so you can use it with any backend. It also parses LaTeX and draws code blocks, but ships no math engine or language grammars, since those are often larger than the package itself. You plug in the ones you want, which we will do below.

stream_chat_flutter still exports its older built-in AI widgets under the same names, so hide those when you import both packages:

import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    hide
        StreamingMessageView,
        AITypingIndicatorView,
        TypewriterState,
        TypewriterController,
        StreamTypewriterBuilder;
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';

2. Platform Setup

The AI composer can attach photos and use speech-to-text, so each platform needs permissions declared. Without them, the microphone and photo picker won't work, and iOS and macOS will terminate the app when it touches a protected resource without a usage description. These are the values from the sample.

iOS: add the usage descriptions to ios/Runner/Info.plist. Speech recognition, microphone, camera and photo library each need one:

<key>NSCameraUsageDescription</key>
<string>Camera access is needed to attach a new photo to your message.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access is needed for voice input.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Photo library access is needed to attach images to your message.</string>
<key>NSSpeechRecognitionUsageDescription</key>
<string>Speech recognition is used to convert your voice to text.</string>

The sample also targets iOS 15.0, while a new Flutter project starts lower. Open ios/Runner.xcworkspace in Xcode and set the Runner target's Minimum Deployments to 15.0.

Android: in android/app/src/main/AndroidManifest.xml, declare the permissions inside <manifest>, and add a <queries> entry so the app can discover the device's speech recognition service:

<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>

<queries>
    <intent>
        <action android:name="android.speech.RecognitionService"/>
    </intent>
</queries>

The sample's manifest also keeps Flutter's default PROCESS_TEXT query next to it.

macOS: enable these entitlements in both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements. The debug profile additionally has com.apple.security.cs.allow-jit and com.apple.security.network.server, which flutter create adds for you:

<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.personal-information.photos-library</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>

On macOS, macos/Runner/Info.plist needs the microphone, photo library and speech recognition descriptions from above (NSMicrophoneUsageDescription, NSPhotoLibraryUsageDescription and NSSpeechRecognitionUsageDescription).

3. Set Up Stream Chat

Open lib/main.dart, create the StreamChatClient, connect a user and wrap the app in StreamChat:

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

import 'src/home_page.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Use the same Stream app as the backend. The token must be signed with
  // that app's secret.
  final client = StreamChatClient('YOUR_STREAM_API_KEY');
  await client.connectUser(
    User(id: 'anakin_skywalker'),
    'USER_TOKEN', // demo only, see the notes below
  );

  runApp(MyApp(client: client));
}

class MyApp extends StatefulWidget {
  const MyApp({super.key, required this.client});

  final StreamChatClient client;

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  // The assistant can change this through the `setThemeMode` client tool.
  var _themeMode = ThemeMode.light;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Stream Chat AI Assistant',
      theme: ThemeData.light(),
      darkTheme: ThemeData.dark(),
      themeMode: _themeMode,
      builder: (context, child) => StreamChat(client: widget.client, child: child),
      home: HomePage(
        client: widget.client,
        onThemeModeChanged: (mode) => setState(() => _themeMode = mode),
      ),
    );
  }
}

Key points:

  • StreamChatClient must contain your Stream API key, from the same Stream app as the backend's .env.
  • connectUser needs a token signed with your app's API secret. The sample hardcodes a demo API key, user (anakin_skywalker) and a pre-generated token to keep the tutorial focused on the AI parts. Replace them with credentials from your own Stream app: generate a token for your user with your app's API secret (for example, with the token generator in the dashboard), and use the same app's key and secret in the backend's .env. Never ship a hardcoded token: in a real app your server generates tokens after the user logs in, and the app fetches them (for example through a token provider passed to connectUser).
  • MyApp keeps the theme mode in its state and hands HomePage a callback to change it. One of the client tools we build later uses that callback to switch the app to dark mode.

4. Build the AI Agent Service Layer

lib/src/agent_service.dart centralizes all calls to your backend:

// lib/src/agent_service.dart
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

class AgentService {
  /// Where the backend runs. The Android emulator reaches your machine at
  /// 10.0.2.2, because `localhost` there is the emulator itself.
  ///
  /// On a physical device, run `adb reverse tcp:3000 tcp:3000` and pass
  /// `--dart-define=AGENT_BASE_URL=http://localhost:3000`.
  static final baseUrl = switch (const String.fromEnvironment('AGENT_BASE_URL')) {
    '' when !kIsWeb && defaultTargetPlatform == TargetPlatform.android => 'http://10.0.2.2:3000',
    '' => 'http://localhost:3000',
    final url => url,
  };

  final _dio = Dio(
    BaseOptions(
      baseUrl: baseUrl,
      // Dio waits forever by default. `/start-ai-agent` makes several Stream
      // API calls of its own, hence the roomier receive timeout.
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 30),
    ),
  );

  /// Starts the AI agent in [channelId], which then answers every new message.
  Future<void> startAgent(String channelId, {String? platform}) async {
    await _dio.post<void>('/start-ai-agent', data: {
      'channel_id': channelId,
      if (platform != null) 'platform': platform,
    });
  }

  /// Tells the backend which client-side tools this app can run.
  Future<void> registerTools(String channelId, List<Map<String, Object?>> tools) async {
    if (tools.isEmpty) return;
    await _dio.post<void>('/register-tools', data: {'channel_id': channelId, 'tools': tools});
  }

  /// Asks the backend to boil [text] down to a short conversation title.
  Future<String?> summarize(String text, {String? platform}) async {
    final response = await _dio.post<Map<String, Object?>>('/summarize', data: {
      'text': text,
      if (platform != null) 'platform': platform,
    });
    return response.data?['summary'] as String?;
  }
}

Each conversation starts its own AI agent (/start-ai-agent), registers the app's client tools, and asks for a summary of its first message to use as the title. The app never calls /stop-ai-agent: the backend stops idle agents on its own.

baseUrl defaults to your local backend. On Android, localhost is the device or emulator itself, not your machine, so the Android emulator connects to http://10.0.2.2:3000 instead, which needs no extra setup. On a physical Android device, forward the port and point the app at localhost:

adb reverse tcp:3000 tcp:3000
flutter run --dart-define=AGENT_BASE_URL=http://localhost:3000

When you're ready to test and ship, pass your deployed backend's URL the same way with --dart-define=AGENT_BASE_URL=....

Plain HTTP to a local backend needs no extra setup on either Android or iOS. Dio uses Dart's own HTTP client, which isn't subject to Android's cleartext policy or iOS's App Transport Security. Your deployed backend should still use HTTPS.

Get started! Activate your free Stream account today and start prototyping your chat app.

5. Show AI Replies and the Typing Indicator

Next, let's build some UI and integrate it with Stream Chat's Flutter SDK. The SDK works seamlessly with the AI components, enabling you to build AI assistants quickly.

First, we need to know what the assistant is doing. The backend sends ai_indicator.update, ai_indicator.clear and ai_indicator.stop events while it works on a reply. TypingStateHandler (lib/src/typing_state_handler.dart) is a small ValueNotifier that listens to them on the channel and exposes the current AITypingState:

// lib/src/typing_state_handler.dart
import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

class TypingStateHandler extends ValueNotifier<AITypingState> {
  TypingStateHandler({required Channel channel}) : super(AITypingState.idle) {
    _subscription = channel.on().listen(_onEvent);
  }

  late final StreamSubscription<Event> _subscription;

  void _onEvent(Event event) {
    final state = switch (event.type) {
      // On failure the backend sends `ai_indicator.update` with
      // `AI_STATE_ERROR`, not a clear or stop, so `error` also ends a reply.
      EventType.aiIndicatorUpdate => event.aiState,
      EventType.aiIndicatorClear || EventType.aiIndicatorStop => AITypingState.idle,
      _ => null,
    };
    if (state != null) value = state;
  }

  @override
  void dispose() {
    unawaited(_subscription.cancel());
    super.dispose();
  }
}

We will use this state for two things: the typing indicator below the message list, and the stop button in the composer.

ConversationView (lib/src/conversation_view.dart) shows the message list for the active channel. We use the messageBuilder of StreamMessageListView to render AI-generated messages with our own widget, and keep the SDK's default bubble for the user's messages:

// lib/src/conversation_view.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    hide
        StreamingMessageView,
        AITypingIndicatorView,
        TypewriterState,
        TypewriterController,
        StreamTypewriterBuilder;
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';

import 'ai_message_item.dart';
import 'typing_state_handler.dart';

class ConversationView extends StatefulWidget {
  const ConversationView({
    super.key,
    required this.channel,
    this.onGeneratingChanged,
  });

  final Channel channel;

  /// Called when the assistant starts or stops replying, so the composer can
  /// swap its send button for a stop button.
  final ValueChanged<bool>? onGeneratingChanged;

  @override
  State<ConversationView> createState() => _ConversationViewState();
}

class _ConversationViewState extends State<ConversationView> {
  late final _typingStateHandler = TypingStateHandler(channel: widget.channel)
    ..addListener(_reportGenerating);
  var _typewriterState = TypewriterState.idle;

  @override
  void dispose() {
    _typingStateHandler.dispose();
    super.dispose();
  }

  void _reportGenerating() {
    widget.onGeneratingChanged?.call(switch (_typingStateHandler.value) {
      AITypingState.thinking || AITypingState.checkingSources || AITypingState.generating => true,
      _ => false,
    });
  }

  void _onTypewriterStateChanged(TypewriterState state) {
    if (state == _typewriterState) return;
    // Reported while the message list builds, so wait for the frame to end.
    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (!mounted) return;
      setState(() => _typewriterState = state);
    });
  }

  @override
  Widget build(BuildContext context) {
    return StreamChannel(
      channel: widget.channel,
      child: Column(
        children: [
          Expanded(
            child: StreamMessageListView(
              messageBuilder: (context, message, defaultProps) {
                if (message.isAI) {
                  return AIMessageItem(
                    message: message,
                    onTypewriterStateChanged: _onTypewriterStateChanged,
                  );
                }
                return DefaultStreamMessageItem(props: defaultProps);
              },
            ),
          ),
          ValueListenableBuilder(
            valueListenable: _typingStateHandler,
            builder: (context, aiTypingState, _) => AITypingIndicatorStateView(
              typewriterState: _typewriterState,
              aiTypingState: aiTypingState,
            ),
          ),
        ],
      ),
    );
  }
}

extension on Message {
  /// Whether the AI assistant wrote this message.
  bool get isAI => extraData['ai_generated'] == true;
}

The backend sets ai_generated in the message's extraData, which is how we tell the two apart. onGeneratingChanged passes the typing state up to the home page, which uses it to show the composer's stop button, as we'll see in the next section. You can find the complete view here.

AIMessageItem (lib/src/ai_message_item.dart) renders the reply with StreamingMessageView from the AI components: plain, padded markdown with no avatar and no bubble, animated as the text streams in. This is where we plug in the optional code highlighter, math engine and link handling:

// lib/src/ai_message_item.dart
import 'package:flutter/material.dart';
import 'package:flutter_math_fork/flutter_math.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'
    hide StreamingMessageView, TypewriterState;
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';
import 'package:url_launcher/url_launcher.dart';

import 'code_highlighter.dart';

class AIMessageItem extends StatelessWidget {
  const AIMessageItem({super.key, required this.message, this.onTypewriterStateChanged});

  final Message message;
  final ValueChanged<TypewriterState>? onTypewriterStateChanged;

  @override
  Widget build(BuildContext context) {
    // Use the same text style as the regular message bubbles.
    final bodyStyle = context.streamTextTheme.bodyDefault.copyWith(
      color: context.streamColorScheme.textPrimary,
    );

    return Padding(
      padding: const EdgeInsets.all(16),
      child: StreamingMessageView(
        text: message.text ?? '',
        onTypewriterStateChanged: onTypewriterStateChanged,
        styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith(p: bodyStyle),
        codeHighlighter: highlightCode,
        mathBuilder: _buildMath,
        // Models write `$…$` more often than the default `\(…\)` / `\[…\]`.
        useDollarDelimitersForMath: true,
        onTapLink: (text, href, title) => _openLink(href),
      ),
    );
  }

  static Widget _buildMath(
    BuildContext context,
    String tex,
    TextStyle? style, {
    required bool inline,
  }) {
    return Math.tex(
      tex,
      textStyle: style,
      mathStyle: inline ? MathStyle.text : MathStyle.display,
      // Models emit malformed TeX often enough; show the source instead.
      onErrorFallback: (error) => Text(tex, style: style),
    );
  }

  static Future<void> _openLink(String? href) async {
    final uri = href == null ? null : Uri.tryParse(href);
    if (uri == null) return;
    try {
      if (await launchUrl(uri, mode: LaunchMode.externalApplication)) return;
    } catch (e) {
      debugPrint('Failed to open link $href: $e');
      return;
    }
    debugPrint('Could not open link: $href');
  }
}

Fenced code blocks get a header with the language and a copy button, and chart fences render as charts, without any extra setup. The highlightCode function used above (defined in lib/src/code_highlighter.dart) registers a handful of re_highlight grammars. It is about 90 lines of setup that isn't specific to AI chat, so copy code_highlighter.dart from the sample into your lib/src/ folder.

Finally, AITypingIndicatorStateView, which lives in the same conversation_view.dart file and uses the same imports, shows the AITypingIndicatorView from the AI components below the message list whenever the assistant is thinking, checking sources, or generating. It also shows a short message when the reply fails, and it takes its colors from the theme, so it works in dark mode too:

class AITypingIndicatorStateView extends StatelessWidget {
  const AITypingIndicatorStateView({
    super.key,
    required this.aiTypingState,
    required this.typewriterState,
  });

  final AITypingState aiTypingState;
  final TypewriterState typewriterState;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    // `error` wins over a still-running typewriter: the reply won't finish.
    final indicatorText = switch ((typewriterState, aiTypingState)) {
      (_, AITypingState.error) => 'Something went wrong. Please try again.',
      (TypewriterState.typing, _) || (_, AITypingState.generating) => 'Generating',
      (_, AITypingState.thinking) => 'Thinking',
      (_, AITypingState.checkingSources) => 'Checking sources',
      _ => null,
    };
    final isError = aiTypingState == AITypingState.error;

    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 300),
      child: switch (indicatorText) {
        final text? => Container(
          key: ValueKey(text),
          width: double.infinity,
          color: isError ? colors.errorContainer : colors.surfaceContainerHighest,
          padding: const EdgeInsets.all(8),
          child: isError
              ? Text(text, style: TextStyle(color: colors.onErrorContainer, fontSize: 16))
              : AITypingIndicatorView(
                  text: text,
                  textStyle: TextStyle(color: colors.onSurface, fontSize: 16),
                ),
        ),
        null => const SizedBox.shrink(),
      },
    );
  }
}

6. Build the Conversation UI

Building the conversation UI is quite straightforward with the available components from the Stream Chat Flutter SDK and the AI components. HomePage is the app's only screen. It opens straight into a "new chat" view instead of a channel list, and past conversations live in a drawer.

The full file is here. The snippets below are its building blocks, and every method they call lives in the same _HomePageState class in lib/src/home_page.dart. This is the state class they hang off:

// lib/src/home_page.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';
import 'package:uuid/uuid.dart';

import 'agent_service.dart';
import 'client_tools.dart';
import 'connection_banner.dart';
import 'conversation_view.dart';

/// The sample hardcodes OpenAI, the key the backend's `.env` sets up.
const _aiPlatform = 'openai';

class HomePage extends StatefulWidget {
  const HomePage({super.key, required this.client, required this.onThemeModeChanged});

  final StreamChatClient client;

  /// Called when the assistant asks the app to switch themes.
  final ValueChanged<ThemeMode> onThemeModeChanged;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _agentService = AgentService();
  final _composerController = ChatComposerController(chatOptions: _chatOptions);
  Channel? _activeChannel;

  // The tool registry, shown in the client tools section below.
  late final _toolRegistry = AIToolRegistry()
    ..register(GreetUserTool(onGreet: _showGreeting))
    ..register(SetThemeModeTool(onChange: widget.onThemeModeChanged));

  /// Listens on the client, so it keeps working across conversations.
  late final ClientToolListener _toolListener;

  late final _channelListController = StreamChannelListController(
    client: widget.client,
    filter: Filter.in_('members', [widget.client.state.currentUser!.id]),
    limit: 30,
  );

  @override
  void initState() {
    super.initState();
    _toolListener = ClientToolListener(client: widget.client, registry: _toolRegistry);
  }

  @override
  void dispose() {
    _toolListener.dispose();
    _channelListController.dispose();
    _composerController.dispose();
    super.dispose();
  }

  // _showGreeting, _ensureAgentStarted, _showError, _setChannelTitle,
  // _sendMessage, _stopResponse, _openChannel and build follow below.
}

The sample hardcodes _aiPlatform = 'openai' and sends it to /start-ai-agent and /summarize, because OpenAI is the key the backend's .env sets up above. If you configured a different provider, change this value to anthropic, gemini or xai, or let the user pick one. The _chatOptions and _landingSuggestions constants at the top of the file hold the composer options and landing prompts, shown in the snippets below.

build puts it all together:

@override
Widget build(BuildContext context) {
  final activeChannel = _activeChannel;

  return Scaffold(
    drawer: _ConversationDrawer(
      controller: _channelListController,
      onChannelSelected: _openChannel,
    ),
    // The composer is in the body rather than `bottomNavigationBar`, because
    // only the body moves up when the keyboard opens.
    body: SafeArea(
      child: Column(
        children: [
          const ConnectionBanner(),
          Expanded(
            child: activeChannel == null
                ? _LandingView(onSuggestionTap: _sendMessage)
                : ConversationView(
                    key: ValueKey(activeChannel.cid),
                    channel: activeChannel,
                    onGeneratingChanged: (isGenerating) =>
                        _composerController.isGenerating = isGenerating,
                  ),
          ),
          ChatComposer(
            controller: _composerController,
            enableSpeechToText: true,
            onSendPressed: (text, option, attachments) =>
                _sendMessage(text, option: option, attachments: attachments),
            onStopPressed: _stopResponse,
          ),
        ],
      ),
    ),
  );
}

There is deliberately no app bar. _ConversationDrawer is reached by swiping from the left edge. It holds a "New chat" button and a plain StreamChannelListView with just the conversation names:

class _ConversationDrawer extends StatelessWidget {
  const _ConversationDrawer({required this.controller, required this.onChannelSelected});

  final StreamChannelListController controller;

  /// Called with the chosen channel, or `null` for a new chat.
  final ValueChanged<Channel?> onChannelSelected;

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: SafeArea(
        child: Column(
          children: [
            ListTile(
              leading: const Icon(Icons.add_comment_outlined),
              title: const Text('New chat'),
              onTap: () => onChannelSelected(null),
            ),
            const Divider(height: 1),
            Expanded(
              child: StreamChannelListView(
                controller: controller,
                itemBuilder: (context, items, index, defaultWidget) {
                  final channel = items[index];
                  return ListTile(
                    title: Text(channel.name ?? channel.id ?? ''),
                    onTap: () => onChannelSelected(channel),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

The ChatComposer from the AI components stays docked at the bottom in both states. It comes with an attachment button, speech-to-text input and a send button that turns into a stop button while ChatComposerController.isGenerating is true. The home page sets that flag from ConversationView's onGeneratingChanged. The options in the composer's "+" sheet (such as "Create image" or "Deep research") also come from the controller:

// Top of lib/src/home_page.dart (the sample defines six options)
const _chatOptions = [
  ChatOption(id: 'image', text: 'Create image', description: 'Visualize anything', icon: Icons.palette_outlined),
  ChatOption(id: 'research', text: 'Deep research', description: 'Get a detailed report', icon: Icons.travel_explore),
  ChatOption(id: 'search', text: 'Web search', description: 'Find real-time news and info', icon: Icons.public),
];

Note that the composer lives in the body, not in bottomNavigationBar. Scaffold only applies the keyboard inset to its body, so a composer in the bottom navigation bar would end up behind the keyboard.

When there is no active conversation, _LandingView shows the AISuggestionsView from the AI components right above the composer. Tapping a suggestion sends it immediately:

// Top of lib/src/home_page.dart (the sample defines six suggestions)
const _landingSuggestions = [
  'Create a painting in Renaissance-style',
  'Create a workout plan for resistance training',
  'Help me study vocabulary for an exam',
  'Top 5 restaurants in New York',
];

class _LandingView extends StatelessWidget {
  const _LandingView({required this.onSuggestionTap});

  final ValueChanged<String> onSuggestionTap;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        const Spacer(),
        AISuggestionsView(
          suggestions: _landingSuggestions,
          itemMaxWidth: 190,
          onSuggestionSelected: onSuggestionTap,
        ),
      ],
    );
  }
}

Sending the first message creates the channel, starts the agent, sends the message and then names the conversation:

Future<void> _sendMessage(
  String text, {
  ChatOption? option,
  List<XFile> attachments = const [],
}) async {
  if (text.trim().isEmpty && attachments.isEmpty) return;

  var channel = _activeChannel;
  final isNewChannel = channel == null;
  if (channel == null) {
    channel = widget.client.channel(
      'messaging',
      id: const Uuid().v4(),
      extraData: {
        'members': [widget.client.state.currentUser!.id],
      },
    );
    await channel.watch();
    if (!mounted) return;
    setState(() => _activeChannel = channel);
    // Wait for the agent before sending: it only answers messages that
    // arrive after it has started.
    await _ensureAgentStarted(channel);
  }

  // `sendMessage` uploads the attachments itself.
  final messageAttachments = await Future.wait(
    attachments.map((file) => file.toAttachment(type: AttachmentType.image)),
  );
  await channel.sendMessage(
    Message(
      text: option == null ? text : '${option.text}: $text',
      attachments: messageAttachments,
    ),
  );

  if (isNewChannel) {
    unawaited(_setChannelTitle(channel, text.trim().isNotEmpty ? text : 'Photo'));
  }
}

Awaiting _ensureAgentStarted matters. The agent does not replay channel history, and starting it takes a few round trips to Stream, while sending a message takes one. If you don't wait, the first message usually arrives before the agent is listening and never gets a reply. The snippets here are the happy path. The full sample also lets a second send wait for the new conversation instead of creating another one, and if the agent can't start it keeps the message in the composer instead of sending it into a conversation with no agent.

_ensureAgentStarted starts the agent and then registers the client tools. It reports each failure separately in a snackbar, because otherwise the app looks fine but the assistant never replies:

Future<void> _ensureAgentStarted(Channel channel) async {
  final channelId = channel.id;
  if (channelId == null) return;

  try {
    await _agentService.startAgent(channelId, platform: _aiPlatform);
  } catch (e) {
    debugPrint('Failed to start the AI agent: $e');
    _showError(
      "Couldn't start the assistant. Is ai-sdk-sample running at ${AgentService.baseUrl}?",
    );
    return;
  }

  try {
    // Registering again for a channel the backend already knows is harmless,
    // and keeps it in sync with the tools this build has.
    await _agentService.registerTools(channelId, _toolRegistry.registrationPayloads());
  } catch (e) {
    debugPrint('Failed to register client tools: $e');
    _showError("The assistant is running, but can't use app actions like switching the theme.");
  }
}

void _showError(String message) {
  if (!mounted) return;
  ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}

_setChannelTitle calls _agentService.summarize(...) and stores the result with channel.updatePartial(set: {'name': title}), falling back to the start of the message if the call fails:

Future<void> _setChannelTitle(Channel channel, String firstMessage) async {
  var title = firstMessage.length > 40 ? '${firstMessage.substring(0, 40)}…' : firstMessage;
  try {
    final summary = await _agentService.summarize(firstMessage, platform: _aiPlatform);
    if (summary != null && summary.trim().isNotEmpty) title = summary.trim();
  } catch (e) {
    debugPrint('Failed to summarize the title, using the message: $e');
  }

  try {
    await channel.updatePartial(set: {'name': title});
  } catch (e) {
    debugPrint('Failed to set the channel title: $e');
  }
}

Opening a conversation from the drawer starts its agent too, so there is no "Start AI" button: the agent joins whenever a conversation becomes active. The stop button calls stopAIResponse on the active channel:

Future<void> _stopResponse() async {
  try {
    await _activeChannel?.stopAIResponse();
  } catch (e) {
    debugPrint('Failed to stop the AI response: $e');
    _showError("Couldn't stop the reply.");
  }
}

void _openChannel(Channel? channel) {
  Navigator.of(context).pop(); // close the drawer
  setState(() => _activeChannel = channel);
  // The next conversation view reports its own state once it's listening.
  _composerController.isGenerating = false;
  if (channel == null) {
    _composerController.clear();
  } else {
    unawaited(_ensureAgentStarted(channel));
  }
}

The stop button follows the backend's typing state, so it appears when the first ai_indicator event arrives, shortly after you send a message, not at the moment you tap send.

The build method above puts a small ConnectionBanner at the top of the screen. It is built with StreamConnectionStatusBuilder, while the connection is offline or reconnecting. Without it, a dropped connection just looks like the assistant has stopped responding. Put it in lib/src/connection_banner.dart:

// lib/src/connection_banner.dart
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

/// A thin banner shown while the realtime connection is down.
///
/// Without it a dropped connection looks like the assistant misbehaving:
/// replies stay empty and client-tool invocations are missed, since both
/// arrive as channel events. Takes no space while connected.
class ConnectionBanner extends StatelessWidget {
  const ConnectionBanner({super.key});

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;

    return StreamConnectionStatusBuilder(
      statusBuilder: (context, status) {
        final banner = switch (status) {
          ConnectionStatus.connected => null,
          ConnectionStatus.connecting => (
              message: 'Reconnecting…',
              background: colors.secondaryContainer,
              foreground: colors.onSecondaryContainer,
            ),
          ConnectionStatus.disconnected => (
              message: 'Offline — new messages and AI actions may not arrive',
              background: colors.errorContainer,
              foreground: colors.onErrorContainer,
            ),
        };

        return AnimatedSize(
          duration: const Duration(milliseconds: 200),
          alignment: Alignment.topCenter,
          child: switch (banner) {
            null => const SizedBox(width: double.infinity),
            final banner => Container(
                width: double.infinity,
                color: banner.background,
                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
                child: Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    if (status == ConnectionStatus.connecting) ...[
                      SizedBox.square(
                        dimension: 12,
                        child: CircularProgressIndicator(
                          strokeWidth: 2,
                          color: banner.foreground,
                        ),
                      ),
                      const SizedBox(width: 8),
                    ],
                    Flexible(
                      child: Text(
                        banner.message,
                        textAlign: TextAlign.center,
                        style: TextStyle(color: banner.foreground, fontSize: 13),
                      ),
                    ),
                  ],
                ),
              ),
          },
        );
      },
    );
  }
}

7. Register Client Tools and Surface Their Actions

With the help of stream_chat_flutter_ai, you can handle native "client tools" coming from your LLM through custom events. For this, you can use our server-side SDKs that integrate with Vercel's AI SDK and Langchain, or build your own. The client-side tools guide covers the protocol in detail.

Define your tools by implementing AIClientTool. Instead of acting directly, handleInvocation returns deferred actions, so the registry decides when they run. Each tool in the sample takes a callback, and HomePage supplies what should happen in the app.

GreetUserTool takes no arguments. Both tools are defined in lib/src/client_tools.dart, which starts with these imports:

// lib/src/client_tools.dart
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_ai/stream_chat_flutter_ai.dart';

class GreetUserTool implements AIClientTool {
  const GreetUserTool({required this.onGreet});

  final VoidCallback onGreet;

  @override
  AIToolDefinition get definition => const AIToolDefinition(
        name: 'greetUser',
        description: 'Display a native greeting to the user',
        instructions: 'Use the greetUser tool when the user asks to be '
            'greeted. The tool shows a greeting alert in the Flutter app.',
      );

  @override
  List<AIToolAction> handleInvocation(AIToolInvocation invocation) => [onGreet];
}

A tool that does take arguments describes them with a JSON schema in parameters. SetThemeModeTool lets the assistant switch the app to dark mode:

class SetThemeModeTool implements AIClientTool {
  const SetThemeModeTool({required this.onChange});

  final ValueChanged<ThemeMode> onChange;

  @override
  AIToolDefinition get definition => const AIToolDefinition(
        name: 'setThemeMode',
        description: 'Switch the app between its light and dark theme',
        instructions: 'Use setThemeMode when the user asks for dark mode, '
            'light mode, or to change how the app looks.',
        parameters: {
          'type': 'object',
          'properties': {
            'mode': {
              'type': 'string',
              'enum': ['light', 'dark', 'system'],
              'description': 'The theme the app should switch to.',
            },
          },
          'required': ['mode'],
          'additionalProperties': false,
        },
      );

  @override
  List<AIToolAction> handleInvocation(AIToolInvocation invocation) {
    final mode = ThemeMode.values.asNameMap()[invocation.args['mode']];
    // Outside the schema's enum: better to do nothing than to guess.
    if (mode == null) return const [];
    return [() => onChange(mode)];
  }
}

To register a tool, add it to an AIToolRegistry. HomePage creates one registry for all conversations and passes each tool its callback: a dialog for the greeting, and the onThemeModeChanged callback from MyApp for the theme. _ensureAgentStarted sends the registry's registrationPayloads() to /register-tools, as we saw above. This is the registry and the greeting dialog, both inside _HomePageState:

late final _toolRegistry = AIToolRegistry()
  ..register(GreetUserTool(onGreet: _showGreeting))
  ..register(SetThemeModeTool(onChange: widget.onThemeModeChanged));

void _showGreeting() {
  if (!mounted) return;
  showDialog<void>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('Greetings!'),
      content: const Text('👋 Hello there! The assistant asked me to greet you.'),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(),
          child: const Text('OK'),
        ),
      ],
    ),
  );
}

When the model decides to call one of the tools, the agent sends a custom_client_tool_invocation event over the normal chat connection. ClientToolListener (also in client_tools.dart) listens for it on the client rather than a channel, so invocations keep arriving while the user switches conversations. It parses the event into an AIToolInvocation and dispatches it to the registry:

class ClientToolListener {
  ClientToolListener({required StreamChatClient client, required AIToolRegistry registry})
      : _registry = registry {
    _subscription = client.on(kClientToolInvocationEventType).listen(_onEvent);
  }

  final AIToolRegistry _registry;
  late final StreamSubscription<Event> _subscription;

  Future<void> _onEvent(Event event) async {
    final invocation = AIToolInvocation.tryParse(event.toClientToolPayload());
    if (invocation == null) return;

    final handled = await _registry.dispatch(invocation);
    if (!handled) debugPrint('No client tool registered as "${invocation.tool.name}"');
  }

  void dispose() => unawaited(_subscription.cancel());
}

extension EventClientToolPayload on Event {
  /// Rebuilds the JSON [AIToolInvocation.tryParse] reads.
  ///
  /// `Event` has no `tool` or `args` fields, so the SDK puts them in
  /// [extraData], while `cid` and `message_id` are regular fields.
  Map<String, Object?> toClientToolPayload() => {
        'type': type,
        'cid': cid,
        'message_id': messageId,
        ...extraData,
      };
}

HomePage creates the listener in initState (_toolListener = ClientToolListener(client: widget.client, registry: _toolRegistry), as in the state class above) and disposes it with the page. dispatch returns false when no tool is registered under that name. That is normal rather than an error: the backend keeps registrations per channel, so an older conversation can still ask for a tool the current build no longer has.

The theme change needs nothing extra, because MyApp rebuilds when its callback runs. Ask the assistant to "greet me" or to "switch to dark mode" to try it.

Keep in mind that a client tool is a side effect, not a function call with a result: nothing is returned to the model. With the ai-sdk-sample backend, each tool call is followed by a message such as Client tool "greetUser" invocation dispatched.. That text comes from the backend, which returns it to the model as soon as the event is sent. It doesn't mean the tool ran, and you can change it in your backend.

This gives your AI agent the ability to ask the client to run arbitrary UX-side actions such as surfacing alerts, opening screens, or changing settings.

You can find the complete implementation of the client-side tool handling here.

Conclusion

In this tutorial, you learned how to build a complete AI-powered chat experience by combining Stream Chat, the stream_chat_flutter_ai package, and a customizable Node.js backend.

We set up an AI agent capable of generating responses, streaming typing indicators, and triggering client-side tools, then integrated it into a Flutter app using Stream's Flutter components for a polished, real-time chat interface.

With a few endpoints, a lightweight service layer, and fully customizable UI, you now have a foundation for creating rich conversational AI experiences in Flutter using any LLM provider you prefer.

If you want to learn more about our AI capabilities, head to our AI solutions page. For the details behind this tutorial, see the Flutter AI integrations guide, the Stream Chat integration page, the client-side tools guide and the @stream-io/chat-ai-sdk reference. Additionally, check our Flutter docs to learn how you can provide more customizations to your chat apps. Create your free Stream account to get started today.