#An AI agent in your Flutter app
To put an AI agent inside your Flutter app, add Vatio's Dart package, create a publishable key that allows your app on iOS and Android, and listen to a VatioChat from your own widgets. The agent is the same one that answers in your web widget, WhatsApp and Instagram. In the app it can also know who the user is: your backend signs a token, and the agent's private tools read that user's own data.
This guide is the task path; every method and option is in the Flutter SDK reference.
#Start from an agent
- Fastest, from any chat. Add the remote MCP server
https://vatio.ai/mcpas a connector in Claude, ChatGPT or Cursor, sign in with a Vatio account and ask for the agent. It is created in a preview to try; nothing reaches users until you publish (Vatio in Claude and ChatGPT). - To build the agent into your app's code. In the app's project run
npx @vatio-ai/skills, then/vatio-init(in Cursor or Codex, ask it to use that skill), then ask your coding agent to add the package, the tokens and a chat screen in your app's style, and/vatio-improvedaily. See Build an AI agent.
The steps below are what your coding agent adds, for you to review, using a gym's app as the example. A native app has its own SDK: the iOS guide does the same in SwiftUI, and the Android guide in Kotlin and Compose.
#Why put the agent in the app
A user signed in to your app is a visitor whose identity you already know. On WhatsApp and Instagram, a contact has to sign in on your site before a private tool runs. In the app, the same agent can answer "which classes am I booked into?" from that member's own records.
It is not a second agent. A workspace runs one agent, and the app is one more channel for it. Instructions, tools, knowledge and the inbox are shared, so a fix you publish reaches every channel at once.
#What the SDK gives you, and what you build
The SDK is a Dart package for Android, iOS and macOS, Flutter 3.22 or later. Its dependencies are http, shared_preferences and package_info_plus, and it has no UI.
| The SDK handles | You build |
|---|---|
The conversation as a ChangeNotifier: messages, isTyping, status | Every screen: message list, bubbles, composer, typing indicator |
The user's message shown at once with isPending, then confirmed | Putting the text back in the field when send throws |
| Reconnecting with backoff, polling meanwhile, fetching missed messages, deduplicating by id | Rendering content, which is Markdown the agent wrote: treat it as untrusted |
Storing the conversation with shared_preferences, per workspace and signed-in user | The photo picker and voice recorder that produce uploads |
| Uploads, voice-note transcription and the feedback moment, as state | The rating card, and any progressive "typewriter" effect |
The agent's name, avatar, accent color, greeting and suggestions, from vatio.config() | Your sign-in, and the backend endpoint that signs the user's token |
#Allow your app and add the package
A Flutter app has one origin per platform, so allow both on the same key (ios-app:// plus the bundle id, android-app:// plus the package name), and add the package with flutter pub add vatio. The platform settings Flutter's templates leave out (the Android release INTERNET permission, macOS network entitlements) and the rule that a vat_ developer token never ships in an app are in Setup:
vatio keys create publishable --env live --label app \ --origin ios-app://example.northpoint.app --origin android-app://example.northpoint.appThe bundle id is PRODUCT_BUNDLE_IDENTIFIER in ios/Runner.xcodeproj, and the package name is applicationId in android/app/build.gradle.kts; they are often the same. A key that does not allow the one the app sends fails with origin_not_allowed. The origin keeps the key on your app; it says nothing about who the user is. That comes from your backend, below.
#A first conversation
import 'package:flutter/material.dart';import 'package:vatio/vatio.dart';
class AssistantScreen extends StatefulWidget { const AssistantScreen({super.key, required this.chat});
final VatioChat chat;
@override State<AssistantScreen> createState() => _AssistantScreenState();}
class _AssistantScreenState extends State<AssistantScreen> { final _text = TextEditingController();
@override void initState() { super.initState(); widget.chat.resume(); }
@override void dispose() { _text.dispose(); super.dispose(); }
Future<void> _send() async { final question = _text.text; _text.clear(); try { await widget.chat.send(question); } on VatioException { _text.text = question; // send removed the pending message } }
@override Widget build(BuildContext context) { final chat = widget.chat; return Column( children: [ Expanded( child: ListenableBuilder( listenable: chat, builder: (context, _) => ListView( reverse: true, // stays on the newest message padding: const EdgeInsets.all(16), children: [ if (chat.isTyping) const LinearProgressIndicator(), for (final message in chat.messages.reversed) Align( alignment: message.isFromVisitor ? Alignment.centerRight : Alignment.centerLeft, child: Opacity( opacity: message.isPending ? 0.6 : 1, child: Card( child: Padding(padding: const EdgeInsets.all(12), child: Text(message.content)), ), ), ), ], ), ), ), SafeArea( child: Row( children: [ Expanded(child: TextField(controller: _text, onSubmitted: (_) => _send())), IconButton(icon: const Icon(Icons.send), onPressed: _send), ], ), ), ], ); }}Create the chat once, where your app keeps its long-lived services, as VatioChat(Vatio(workspace: 'northpoint-fitness', token: 'vatpub_...')), and dispose() it when that goes. Creating it opens nothing: the conversation starts on the first send, so a user who never writes never becomes a contact. resume() reopens the stored conversation if there is one and never starts a new one. send throws a VatioException with a code; failures outside a send land in chat.lastError. Because VatioChat is a ChangeNotifier, it also drops into Provider's or Riverpod's ChangeNotifierProvider as it is.
#Sign the user in with a token your backend signs
To tell the agent who the user is, your backend signs a JWT and the app passes it along. The private key never goes in the app: an app bundle can be unpacked, and with that key anyone could claim to be any of your users. Vatio holds only the public half, so it can check an identity but never mint one.
Run vatio auth --new-key once. identity.pub stays in the workspace, named by the auth: block in vatio.yml. identity.pem goes into your backend as a secret, not into vatio secrets and not into the app. Then add an endpoint behind your app's own login that signs a short-lived RS256 token:
// Your backend, e.g. POST /me/vatio-token, behind your existing loginimport jwt from "jsonwebtoken";
const visitorToken = jwt.sign( { sub: String(user.id), name: user.name, email: user.email }, process.env.VATIO_IDENTITY_PRIVATE_KEY, { algorithm: "RS256", audience: "northpoint-fitness", expiresIn: "1h" });sub becomes $auth.subject, aud must be the workspace slug exactly, and exp is re-checked on every tool call. Add a metadata claim with a profile link and badges and the inbox shows them in the conversation header, so whoever reads it knows which member it is.
VatioChat takes the token when it is created, so signing in or out means a new one. A small store does it, and the screen listens to the store to pick up the new chat:
class AssistantStore extends ChangeNotifier { AssistantStore(this.vatio) : chat = VatioChat(vatio);
final Vatio vatio; VatioChat chat;
// visitorToken: what your backend's /me/vatio-token returned Future<void> signedIn(String visitorToken) async { _replace(VatioChat(vatio, visitorToken: visitorToken)); await chat.resume(); }
void signedOut() => _replace(VatioChat(vatio));
void _replace(VatioChat next) { chat.dispose(); chat = next; notifyListeners(); }
@override void dispose() { chat.dispose(); super.dispose(); }}#Private tools now run for that user
With a verified token, tools marked access: private run, and they reach your API with the user's own token. tools/my_bookings.yml:
description: The signed-in member's upcoming class bookings.when_to_use: A signed-in member asks about their own bookings or classes.access: privaterequest: method: GET base_url: "$env.NORTHPOINT_API" path: /me/bookings headers: Authorization: "Bearer $auth.token"respond: data: bookings: "$.bookings" message: when_empty: This member has no upcoming bookings. default: "Found {{count}} upcoming bookings."Your endpoint verifies the JWT with the same public key and scopes the query to its sub. The tool takes no member id, and it cannot: a private tool that declares member_id, user_id, customer_id, account_id, patient_id or subject fails the deploy, because the model fills parameters and can be talked into filling in someone else's. book_class is private too, and takes only the class id.
workspace: northpoint-fitnessagent: name: Northpoint instructions: | You help members of Northpoint Fitness with classes, schedules and their own bookings.
Schedules: call class_schedule. A member's own bookings: call my_bookings, and book_class to book. If you cannot see their bookings because they are not signed in, ask them to sign in to the app first. Never guess a booking. tools: [class_schedule, my_bookings, book_class, remember, recall]auth: public_key: identity.pubclass_schedule is public, so it also answers someone who has not signed in, in the app or on any other channel. Tools and Authentication cover tools and tokens in depth.
#Conversation continuity
The SDK stores the conversation per workspace and signed-in user; call resume() when the screen appears. An anonymous conversation carries over when the user signs in, a different sub gets its own conversation, and chat credentials last 12 hours (Delivery and storage). Memory belongs to the contact: with remember and recall enabled, the agent sees the notes kept about a signed-in user, so a new conversation can start from what earlier ones established.
#Photos and voice notes
send(text, attachments: ...) takes up to four files, and a voice note is transcribed before the agent answers. The types, sizes and the isMediaLabel rule are in Files and voice notes. Recording needs NSMicrophoneUsageDescription in ios/Runner/Info.plist and the RECORD_AUDIO permission on Android.
#Test against preview
The key decides which environment the app talks to, so a development build should not carry the live one. Create one for preview, with the same origins:
vatio pushvatio keys create publishable --env preview --label app-dev \ --origin ios-app://example.northpoint.app --origin android-app://example.northpoint.appvatio env shows preview's details. Pick the key by build mode:
import 'package:flutter/foundation.dart' show kReleaseMode;
final vatio = Vatio( workspace: 'northpoint-fitness', token: kReleaseMode ? 'vatpub_LIVE_TOKEN' : 'vatpub_PREVIEW_TOKEN',);vatio chat speaks as you, the developer, not as a member, so test private tools from the app itself. Run it on a simulator or emulator, sign in, ask about your bookings, and read the conversation back with vatio chat show CHAT_ID (chat.conversationId holds the id). It shows every tool call, what your backend answered and whether the token was accepted: a wrong key, an expired exp or a wrong aud leaves the user anonymous instead of failing. no_agent_deployed means nothing is deployed to the token's environment yet. Then ask for another member's bookings: with no id parameter there is nothing for the model to fill in, and your endpoint answers only for the token's sub.
The package's example app is a quick way to try a key before you write any UI: flutter create . adds its platform folders, and --dart-define=VATIO_WORKSPACE=... --dart-define=VATIO_TOKEN=... configures it.
#Limits
Message length, file limits, rate limits and reply style are the same as on the web: see SDK limits. On rate limiting the SDK throws rate_limited: back off rather than retry at once.
#What the SDK does not do
- No web builds. It runs on Android, iOS and macOS. On the web, use the web widget or the browser SDK.
- No push notifications. Nothing in the SDK registers for them. Replies arrive while the app is connected, and when it comes back from the background the SDK reconnects and fetches what it missed.
- No voice calls. Voice notes, yes; a live voice conversation, no.
- No files from the agent. It reads images, audio and PDFs, but its replies are text: it cannot generate or send files or images.
- No first messages. The agent answers what the user sends; it never starts a conversation.
#Ship it
When the preview answers well, publishing is your decision: ask to publish (vatio publish), which goes through the checks. The Free plan includes the owner's own WhatsApp number or Instagram account if you want those channels too; see Pricing.
Ship the release build with the live key. Conversations from the app land in the same inbox as the web and any other channel, marked iOS or Android, with every tool call. A supervisor can take one over or report a wrong reply with Report this reply, and each report becomes a test: /vatio-improve fixes the cause and tests it on preview, and you approve publication. See Improve.
#Frequently asked questions
#How do I add an AI chat agent to my Flutter app?
Run flutter pub add vatio, create a publishable key whose allowed origins are ios-app:// plus your bundle id and android-app:// plus your package name, and create a VatioChat with it. VatioChat is a ChangeNotifier, so a ListenableBuilder redraws as replies arrive. If you build the app with Claude Code, Cursor or Codex, ask your coding agent to do each of these steps and write the chat screen.
#Is it safe to ship the Vatio token inside a Flutter app?
The publishable vatpub_ key is meant to ship in the app. It selects one workspace and environment and only works from its allowed origins, but it does not prove who the user is. Never put a vat_ developer token or your identity private key in an app.
#Can the agent read the signed-in user's own data in my app?
Yes. Your backend signs a short-lived RS256 JWT for the user, the app passes it to VatioChat as the visitor token, and tools marked access: private run for that user. Your endpoint verifies the same token and scopes the query to its sub.
#Does the Vatio Flutter SDK include a chat UI?
No. It is a Dart package with no UI: it gives you messages, typing, connection status, files and the feedback moment as a ChangeNotifier, and you draw the screens with your own widgets. The package README has a complete example, and the repository includes a runnable example app.
#Does the Flutter SDK run on the web?
No. It runs on Android, iOS and macOS. In a web build, use Vatio's web widget or the browser SDK, which talk to the same agent.
#Related guides
- An AI agent in your iOS app: The same agent inside your iPhone app with the iOS SDK.
- An AI agent in your Android app: The same agent inside your Android app with the Kotlin SDK and a Compose screen.
- An AI chat for your web app or site: One script tag puts the agent in your web app or site.
- Build an AI agent for your app: The two-step entry and trying the preview before publishing.