tandem 0.1.0
tandem: ^0.1.0 copied to clipboard
Makes an existing Flutter screen agent-aware over AG-UI. The agent sees what its state class lists and calls its methods; important actions wait for approval.
tandem #
Add an AI agent to your Flutter screens. It works on the screen together with the user, conveniently.
How it works #
The model does not run in the app. Your agent backend calls it and keeps the API key, and the app talks to the backend over AG-UI, an open protocol between agents and apps. Google ADK, LangChain, CrewAI, Mastra and many other agent frameworks support it. Tandem is the app's side.
Your state class lists what the agent may do:
List<AgentCapability> get agentCapabilities => [
.sees(draft),
.calls(setQuantity),
if (draft.lines.isNotEmpty) .callsWithApproval(placeOrder),
];
.sees(draft): the agent sees the draft as it is at each turn, and nothing the class does not list..calls(setQuantity): the agent calls the method a button calls, so the same validation runs and the screen changes as after a tap..callsWithApproval(placeOrder): the method runs only after the user approves the call.
Note
Tandem is experimental. Its API can change before 1.0.
Quick start #
Requires Dart 3.13 (Flutter 3.47) or newer.
-
Add the packages. Keep both on the same minor version: the generated code follows the API of
tandem.flutter pub add tandem flutter pub add dev:build_runner dev:tandem_generator -
List what the agent may see and call:
part 'order_cubit.g.dart'; @TandemAgent() class OrderCubit({required final Orders orders}) extends Cubit<OrderState> with _$OrderCubit { this : super(.new(draft: .empty)); @override List<AgentCapability> get agentCapabilities => [ .sees(draft), .calls(setQuantity), if (draft.lines.isNotEmpty) .callsWithApproval(placeOrder), ]; @override Stream<Object?> get changes => stream; /// The order as the user sees it on the screen. OrderDraft get draft => state.draft; /// Sets how many pieces of [sku] the order has. void setQuantity(String sku, int quantity) => emit(.new(draft: draft.withQuantity(sku, quantity))); /// Places the order exactly as it is on the screen. Future<OrderId> placeOrder() => orders.place(draft); }The agent gets exactly what is on the list, with the doc comments, and the parameter types become the schema. The list is read again before every turn, so it can depend on the state or on who the user is.
changesreports every change, here the Cubit'sstream. Not on bloc? Any state library shows the same with provider and Riverpod. -
Generate the code:
dart run build_runner buildEvery class with
@TandemAgent()gets a_$Namemixin. Each.seesstands directly in the list, not inside anif, afor, a?:or a spread, or the build fails. In CI, rundart run build_runner build && git diff --exit-code: an outdated.g.dartis not always caught at runtime. -
Wrap the screen and its chat:
final class const OrderScreen({super.key}) extends StatelessWidget { @override Widget build(BuildContext context) => BlocProvider( create: (context) => OrderCubit(orders: context.read<Orders>()), child: TandemScope( surface: (context) => context.read<OrderCubit>(), agent: Uri.parse('http://localhost:8000/agent'), approvals: const .fromState(), onEvent: showOrderMessages, child: const Row( children: [ Expanded(child: OrderForm()), SizedBox(width: 360, child: AgentChat()), ], ), ), ); }The scope reads the Cubit with
surfacewhen it opens its session, and keeps the session until it leaves the tree or gets anotheragent. Approvals and Events explain the other two lines. -
Build the chat. Tandem has no chat widget, so the chat has your app's texts, language and look.
TandemSessionScope.of(context)gives the session and rebuilds the widget when it changes. Each item ofsession.itemsis a sealedChatItem:Widget build(BuildContext context) => switch (item) { UserMessage(:final text) => _Bubble(text, fromUser: true), AgentMessage(:final text, :final stopped) => _Bubble( text, stopped: stopped, ), ChatNotice(:final kind) => _Notice(kind), // A card of another call that lands at the same index gets a new state. final ApprovalRequest request => _ApprovalCard( request, approvalBuilder, key: ValueKey((session, request.toolCallId)), ), };AgentChatin the example is the whole chat in one file, ready to copy. Its field callssession.send(text), and its button switches on the status.
Tip
Setting this up with a coding agent? dart run skills@ get installs Tandem's skill from your dependencies, and the agent follows these steps.
Try it without a key #
The example gallery runs six screens on Android, iOS and macOS with scripted agents, without a backend or an API key. Its order desk can also talk to the example's agent backend, which has a scripted model too.
To try your own screen without a backend, give TandemScope.connected a ScriptedAgent (how). More examples have screens to copy with bloc, Riverpod and provider.
Session status #
session.status says what the session does now. It is sealed, so a switch covers every case:
final class const StatusLine({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) =>
switch (TandemSessionScope.of(context).status) {
Idle() => const SizedBox.shrink(),
Thinking() => const LinearProgressIndicator(),
Calling(:final tool) => Text('Running $tool…'),
AwaitingApproval(:final request) => Text(
'Waiting for you: ${request.tool}',
),
Stopping() => const Text('Stopping…'),
};
}
Stop is available while the status is Thinking, or Calling a method the user did not approve. An approved method runs to its end.
Approvals #
A method listed with .callsWithApproval runs only after the user approves it. The approval covers what the agent saw when it asked, so list everything the method depends on with .sees. When a value listed with .sees changes, Tandem invalidates the request for good and the agent has to ask again. This holds from the start of the agent's run: a change made by the agent's own calls of that turn invalidates the turn's requests before they show. A change outside .sees, such as a selected tab, leaves it pending.
approvals: on the scope says how the screen asks:
approvals: |
For |
|---|---|
.fromState() |
a UI that draws the requests itself, from session.items or the status AwaitingApproval(request) |
.callback((context, request, session) async { ... }) |
a dialog, a bottom sheet or anything imperative |
.none() |
a screen without .callsWithApproval: such a call is refused to the agent and reported in debug |
A dialog, from an example that passes approvals: const .callback(_confirmSend):
Future<void> _confirmSend(
BuildContext context,
ApprovalRequest request,
TandemSession session,
) async {
final reply = context.read<ReplyCubit>().reply;
final send = await showDialog<bool>(
context: context,
builder: (context) => _SendDialog(reply),
);
try {
if (send ?? false) {
session.approve(request);
} else {
session.reject(request);
}
} on ApprovalNotPending {
// The reply changed, or the screen closed, while the dialog showed.
}
}
- Several approvals of one turn show one at a time. A rejection cancels the rest, and the agent plans again.
approveandrejecteither decide or throwApprovalNotPending. The same decision twice does nothing, so a double tap is harmless.session.isPending(request)tells whether a request still waits.- A message the user sends while a request waits cancels it. A request the callback leaves undecided waits until then.
Events #
onEvent on the scope hears what happens in the session, as a sealed TandemEvent. It gets the scope's context and the session:
void showOrderMessages(
BuildContext context,
TandemEvent event,
TandemSession session,
) {
final message = switch (event) {
AgentCall(tool: 'place_order', :final result) => 'Placed order $result.',
RunFailed() || ConnectionLost() => 'The agent did not answer. Try again.',
AgentCall() || RunStopped() => null,
};
if (message case final text?) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
}
AgentCall comes after a listed method returned, with its tool, arguments and result. RunFailed and ConnectionLost also add a ChatNotice to session.items, and a stop adds one or marks the last message stopped.
Any state library #
Tandem reads the state object with surface and hears it change in changes, so a state library needs no adapter package. changes is a broadcast stream with an event for every change:
| State | changes |
|---|---|
bloc's Cubit |
stream |
ChangeNotifier or ValueNotifier |
a StreamController.broadcast() that an override of notifyListeners feeds |
Riverpod's Notifier |
a StreamController.broadcast() that listenSelf in build feeds |
With provider and a ChangeNotifier:
@TandemAgent()
class TodoList() extends ChangeNotifier with _$TodoList {
final _changes = StreamController<Object?>.broadcast();
final _todos = <String>[];
@override
List<AgentCapability> get agentCapabilities => [
.sees(todos),
.calls(add),
if (todos.isNotEmpty) .callsWithApproval(clear),
];
@override
Stream<Object?> get changes => _changes.stream;
@override
void notifyListeners() {
super.notifyListeners();
_changes.add(null);
}
/// The to-dos as the user sees them, oldest first.
List<String> get todos => List.unmodifiable(_todos);
/// Adds [todo] at the end of the list.
void add(String todo) {
_todos.add(todo);
notifyListeners();
}
/// Removes every to-do.
void clear() {
_todos.clear();
notifyListeners();
}
}
With Riverpod:
List<String> build() {
// Riverpod drops this listener when it builds again, so it is never
// there twice.
listenSelf((_, next) => _changes.add(next));
return const [];
}
surface reads the object from the scope's context, such as context.read<TodoList>(), or ref.read(cartProvider.notifier) in a Riverpod ConsumerWidget. Put the scope below what owns the object, so the scope disposes its session first. riverpod_generator cannot be used on the same class: it writes a _$Name too. The examples have a screen for each.
Testing #
ScriptedAgent from package:tandem/testing.dart plays a script instead of a model, so widget tests run without a backend. TandemScope.connected takes it in place of the endpoint. Here cubit is an OrderCubit from setUp:
testWidgets('the agent sets a quantity from the chat', (tester) async {
final agent = ScriptedAgent([
(_) => const [
AgentStep.callTool('set_quantity', {'sku': 'VM-27Q', 'quantity': 15}),
],
(_) => const [AgentStep.say('Done.')],
]);
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: cubit,
child: TandemScope.connected(
surface: (context) => context.read<OrderCubit>(),
connection: agent,
approvals: const .fromState(),
child: const AgentChat(),
),
),
),
);
await tester.enterText(find.byType(TextField), 'fifteen monitors');
await tester.tap(find.text('Send'));
await tester.pumpAndSettle();
expect(cubit.draft.lines.single.quantity, 15);
expect(find.text('Done.'), findsOneWidget);
});
Every turn of the script is one run. agent.requests holds the body of every run, so a test can check what the agent saw. To check only what a class gives the agent, use AgentListing.of(cubit).
The agent backend #
Any AG-UI endpoint works. The backend has to:
- Put the AG-UI
contextinto the model's prompt. ag-ui-adk does not, so its agent needs an instruction provider that adds it. - Accept
toolson every run. Tandem always sends it, an empty list when no method is listed. - On the web, allow the app's origin with CORS.
- Treat the
tools, their descriptions and thecontextas untrusted input: they come from the app, and anyone can send a run with other ones.
To send the user's token, give HttpAgentConnection an http.Client that adds it, and pass the connection to TandemScope.connected (example). A sandboxed macOS app needs the com.apple.security.network.client entitlement to reach the agent. example/agent is a Google ADK agent to copy from.
Project status #
Not supported yet: AG-UI interrupts, several state objects on one screen, and a conversation that goes on across screens. A run that ends with an interrupt fails, and the next message starts a new conversation.
AI #
Tandem is written with AI assistance and reviewed by hand, except the AG-UI protocol layer (packages/tandem/lib/src/protocol) and tandem_generator, which have not had a full human review yet.
🛠️ Maintained by LeanCode #
This package is built with 💙 by LeanCode. We are top-tier experts focused on Flutter Enterprise solutions.
Why LeanCode? #
- Creators of Patrol – the next-gen testing framework for Flutter.
- Full-Cycle Product Development – we take your product from scratch to long-term maintenance.
Licensed under the Apache License 2.0.

