Flutter AI Actions with Gemini

Build a Flutter package that confirms Gemini-planned CRM actions via Workers.

Introduction

30 Second Summary

Typing “Mark this contact as qualified” is easy. Turning that sentence into a safe action gets harder when the person using the app needs the final say.

In this project, you will build a reusable Flutter package that converts CRM requests into confirmed local callbacks. A protected Cloudflare Worker will use Gemini to plan each action without exposing the API key.

What You'll Build

Your Android demo will turn “Mark contact_42 as qualified” into a confirmation dialog, a locally updated contact card, and Gemini's final response.

By the end of this project, you'll have:

  • The ability to register CRM actions dynamically by supplying a JSON Schema, local callback, and confirmation widget without editing the package core.
  • A protected planning flow that sends tool schemas to the Worker and returns Gemini's structured function call while the API key stays hidden.
  • A confirmed local action loop where you approve the proposed change, watch the contact card update, and read Gemini's final response.
  • Secret Mission: Register a second CRM action named add_contact_note with its own schema, confirmation experience, callback, and visible note list.

Are there any prerequisites?

You need advanced Flutter and Dart experience plus familiarity with asynchronous code and JSON. Have an existing Gemini API key and the installed tools listed for this project ready.

The guide includes the Cloudflare account and project setup.

Before We Start

Before any hands-on work, let's lock in that your reusable Flutter package demos Gemini proposing update_contact_status for contact_42. Only the Flutter app confirms the proposal plus executes the local CRM mutation.

Set Up the Package and Worker

The action bridge spans a Flutter app plus a Cloudflare Worker. Both runtimes must work independently before they can cooperate.

This step validates the Flutter plus Node.js toolchains. You will then scaffold each workspace so both runtimes can prove they start.

In this step, get ready to:
  • Confirm that Flutter detects the Android emulator.
  • Create the Flutter package plus Worker workspace.
  • Prove that the starter app plus health route run locally.
Validate the installed tools

Flutter handles the package plus Android example. Node.js supplies the local runtime for the Worker tooling.

  • Keep your configured Android Emulator running.
  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Terminal to find your terminal application.
  • Press Enter to open the terminal.
  • Validate the mobile plus server toolchains by running these checks:
flutter doctor
flutter devices
node --version
npm --version

What Do These Checks Prove?

  • The first check reports the health of your Flutter toolchain.
  • The second check confirms that Flutter can target your running Android emulator.
  • The final checks confirm that Node.js plus its package manager are available for the Worker workspace.

You should see the Android emulator in the device list. You should also see version output for Node.js plus npm.

Missing a Tool or Emulator?

If the emulator is missing, launch your configured virtual device from Android Studio before repeating the device check.

If either Node.js check fails, confirm that your existing installation is available on the terminal path.

Help me diagnose my Flutter or Node.js tool check.

Create the account and workspaces

A Cloudflare account gives Wrangler somewhere to authenticate when the local Worker starts. Your account credentials stay inside Cloudflare's browser flow.

  • Open the official Cloudflare Workers guide in your browser.
  • Follow the account creation link from the guide.
  • Create your Cloudflare account with your preferred email address.
  • Complete any email verification requested by Cloudflare.
  • Return to the terminal from the tool checks.

The two workspaces separate reusable Dart code from the server that later talks to Gemini. Keeping that boundary visible now prevents app-specific CRM logic from leaking into the Worker.

  • Create the Flutter package plus example app on your Desktop by running these commands:
cd ~/Desktop
flutter create --template=package ai_action_bridge
cd ai_action_bridge
flutter create example
rm -rf test example/test && rm -f analysis_options.yaml example/analysis_options.yaml
cd ~/Desktop
mkdir -p ai-action-server/src

What Does This Setup Create?

  • The package scaffold creates the reusable ai_action_bridge workspace.
  • The nested app scaffold creates the Android example inside ai_action_bridge/example.
  • The cleanup command removes generated test plus analysis files that this project replaces.
  • The final command creates ai-action-server/src for the Worker entry file.
  • Confirm that both workspace folders exist by running:
ls ai_action_bridge ai-action-server

What Does This Check Show?

The listing confirms that the Flutter package plus Worker workspace now exist under your Desktop. It also shows the generated package files before you customize them.

You should see files listed beneath both workspace names. Your local project now has a clear mobile side plus server side.

Cannot Find the Workspaces?

Check that the first command moved the terminal to your Desktop before creating either folder.

If a scaffold command stopped early, use its terminal output to identify the missing Flutter component.

Help me fix my workspace scaffold.

The package manifests now need the exact names plus dependencies used by the later action bridge. The root manifest adds the Dart HTTP client while the example manifest links back to the local package.

  • Press Cmd+Space on macOS or the Windows key on Windows to open system search.
  • Type Android Studio to find the application.
  • Press Enter to open Android Studio.
  • Click File in the top menu.
  • Click Open.
  • Select the ai_action_bridge folder on your Desktop.
  • Click Open to load the package workspace.
  • Select pubspec.yaml in the project panel.
  • Replace the root manifest with this content:
name: ai_action_bridge
description: A reusable Flutter bridge from AI tool calls to confirmed local callbacks.
version: 0.1.0
publish_to: none

environment:
  sdk: ^3.0.0

dependencies:
  flutter:
    sdk: flutter
  http: 1.6.0

flutter:
  uses-material-design: true

What Does This Manifest Configure?

  • The package metadata identifies this workspace as ai_action_bridge.
  • The SDK constraint records the Dart language range expected by the package.
  • The http dependency at version 1.6.0 provides the client used in a later step.
  • Save ai_action_bridge/pubspec.yaml.
  • Return to the setup terminal.
  • Resolve the package dependencies by running:
cd ~/Desktop/ai_action_bridge
flutter pub get

What Does This Command Confirm?

Flutter reads the updated manifest plus resolves its dependencies. A successful completion proves that the root package configuration is valid.

You should see dependency resolution complete without a manifest error. That confirms the package can use the pinned HTTP dependency.

Dependency Resolution Failed?

Check the indentation beneath flutter in the dependency section. YAML nesting depends on spaces.

Confirm that the version value remains 1.6.0.

Help me fix my root Flutter manifest.

  • Expand the example folder in the Android Studio project panel.
  • Select example/pubspec.yaml.
  • Replace the example manifest with this content:
name: ai_action_bridge_example
description: CRM example for ai_action_bridge.
publish_to: none
version: 0.1.0

environment:
  sdk: ^3.0.0

dependencies:
  flutter:
    sdk: flutter
  ai_action_bridge:
    path: ../

flutter:
  uses-material-design: true

How Does the Example Find the Package?

The local path dependency points one folder upward from example to ai_action_bridge. This lets the example use package changes directly from the same workspace.

  • Save ai_action_bridge/example/pubspec.yaml.
  • Return to the setup terminal.
  • Resolve the example dependencies by running:
cd ~/Desktop/ai_action_bridge/example
flutter pub get

What Does This Resolution Prove?

The example resolves its local dependency from the parent folder. Successful output confirms that the package plus example are connected.

You should see dependency resolution finish without a missing-path error. The example can now load code from the reusable package.

Local Package Path Not Found?

Confirm that example sits directly inside ai_action_bridge.

Check that the local dependency path is exactly ../.

Help me fix the example dependency path.

Install and run the Worker

Hono supplies the Worker's HTTP route. Wrangler installs locally in the server workspace so the project uses the pinned CLI version 4.151.1.

  • Click File in Android Studio.
  • Click Open.
  • Select the ai-action-server folder on your Desktop.
  • Open the folder in a new window if Android Studio asks where to load it.
  • Create package.json inside ai-action-server from the project panel.
  • Add this package configuration:
{"name":"ai-action-server","private":true,"type":"module","scripts":{"dev":"wrangler dev","deploy":"wrangler deploy","delete":"wrangler delete"},"dependencies":{"hono":"4.13.13"},"devDependencies":{"wrangler":"4.151.1"}}

What Does the Server Package Define?

  • The package stays private because it is application infrastructure.
  • The scripts expose consistent commands for local development plus later deployment.
  • The dependency ranges pin Hono to 4.13.13 plus Wrangler to 4.151.1.
  • Create .gitignore inside ai-action-server from the project panel.
  • Add these ignored paths:
node_modules/
.wrangler/
.dev.vars*
.env*

Why Ignore These Paths?

The dependency plus Wrangler cache folders are generated locally. The secret-file patterns prevent later development credentials from entering version control.

  • Save ai-action-server/package.json.
  • Save ai-action-server/.gitignore.
  • Return to the setup terminal.
  • Install the pinned server dependencies by running these commands:
cd ~/Desktop/ai-action-server
npm install hono@4.13.13
npm install --save-dev wrangler@4.151.1

What Do These Installs Add?

  • The first install adds the Hono application dependency.
  • The second install adds Wrangler as a development-only command-line dependency.
  • Installing both locally keeps their versions tied to this Worker workspace.

The first install may spend a minute downloading packages. You should finish with installed dependencies plus no fatal npm error.

Package Installation Failed?

Confirm that the terminal is inside ~/Desktop/ai-action-server before repeating the failed install.

Check that package.json contains valid JSON with no trailing comma.

Help me diagnose the npm installation.

The Worker now needs a configuration file plus one HTTP route. The health route gives you a small response that proves Hono is running before any Gemini logic exists.

  • Create wrangler.jsonc inside ai-action-server from the Android Studio project panel.
  • Add this Worker configuration:
{"$schema":"node_modules/wrangler/config-schema.json","name":"ai-action-server","main":"src/index.ts","compatibility_date":"2026-10-17"}

What Does the Worker Configuration Control?

  • The schema path lets compatible editors validate the configuration.
  • The Worker name identifies the project as ai-action-server.
  • The main field points Wrangler to src/index.ts.
  • The compatibility date fixes the Worker runtime behavior to the project date.
  • Select the existing src folder in the Android Studio project panel.
  • Create index.ts inside ai-action-server/src.
  • Add the minimal health route:
import { Hono } from 'hono'

const app = new Hono()

app.get('/health', (c) => c.json({ ok: true }))

export default app

What Does the Health Route Do?

  • The Hono application receives HTTP requests for the Worker.
  • The /health route returns a small JSON object.
  • The default export gives Wrangler the application it must run.
  • Save ai-action-server/wrangler.jsonc.
  • Save ai-action-server/src/index.ts.

✔️ Awesome, I've got everything!

Your Flutter manifests plus Worker files are ready for their first local run.

ⓧ I'd like to double check the full code

node_modules/
.wrangler/
.dev.vars*
.env*
{"name":"ai-action-server","private":true,"type":"module","scripts":{"dev":"wrangler dev","deploy":"wrangler deploy","delete":"wrangler delete"},"dependencies":{"hono":"4.13.13"},"devDependencies":{"wrangler":"4.151.1"}}
import { Hono } from 'hono'

const app = new Hono()

app.get('/health', (c) => c.json({ ok: true }))

export default app
{"$schema":"node_modules/wrangler/config-schema.json","name":"ai-action-server","main":"src/index.ts","compatibility_date":"2026-10-17"}
name: ai_action_bridge_example
description: CRM example for ai_action_bridge.
publish_to: none
version: 0.1.0

environment:
  sdk: ^3.0.0

dependencies:
  flutter:
    sdk: flutter
  ai_action_bridge:
    path: ../

flutter:
  uses-material-design: true
name: ai_action_bridge
description: A reusable Flutter bridge from AI tool calls to confirmed local callbacks.
version: 0.1.0
publish_to: none

environment:
  sdk: ^3.0.0

dependencies:
  flutter:
    sdk: flutter
  http: 1.6.0

flutter:
  uses-material-design: true

Wrangler may open a browser during its first local run. That browser flow authenticates the CLI without placing your Cloudflare password inside the project.

  • Return to the setup terminal.
  • Start the local Worker by running:
cd ~/Desktop/ai-action-server
npx wrangler dev

What Happens During Local Development?

Wrangler loads the configured TypeScript entry file plus starts a local HTTP server. The command keeps control of this terminal while the Worker is running.

Your browser may open for Cloudflare authorization on this first run. Complete the authorization so Wrangler can authenticate your new account.

You should see a local development address that uses port 8787. Keep this terminal running for the health check.

Worker Did Not Start?

Confirm that the terminal still points to ai-action-server.

If the browser authorization did not finish, return to that browser tab before retrying the local server.

Help me diagnose the local Worker startup.

Before you check the route, what JSON response do you expect from the health handler?

  • Open a second terminal session from your terminal application's new-tab control.
  • Check the local health route by running:
curl http://localhost:8787/health

What Does the Health Check Prove?

The request reaches the local Worker plus passes through the Hono route. A JSON response proves that the server scaffold is runnable.

You should see {"ok":true}. That is the first working response from your action server.

Health Request Cannot Connect?

Confirm that the first terminal still shows the local Worker as running.

Check that the route path is exactly /health.

Help me fix the local health request.

Before you run the example, what generated starter screen do you expect the emulator to display?

  • Keep the Worker terminal running.
  • Use the second terminal session for the Flutter example.
  • Choose your Android emulator if Flutter asks you to select a target device.
  • Launch the generated example by running:
cd ~/Desktop/ai_action_bridge/example
flutter run

What Does This Run Confirm?

Flutter builds the example with the local package dependency. It then installs the generated app on the Android emulator.

The first Android build can take a few minutes while Gradle prepares the app. The terminal remains active during this process.

You should see the generated Flutter starter app on the emulator. Both sides of the project now run independently.

Example Did Not Reach the Emulator?

Confirm that the Android emulator still appears in the Flutter device list.

If dependency resolution fails, repeat the manifest checks from the package setup.

Help me diagnose the Flutter example run.

Your Flutter example plus local Worker are both alive. Next, you will replace the starter screen with the CRM contact card plus reusable action composer.

Render the Action Composer

Your Flutter example already runs on the Android emulator. Your local Cloudflare Worker answers health checks.

A package API becomes trustworthy when its integration surface is visible. This step turns the public contracts into a CRM composer backed by a JSON Schema.

In this step, get ready to:
  • Define the public contracts for registered tools and planned actions.
  • Build a reusable action composer for CRM prompts.
  • Render a contact card and test the temporary submit path.
Define the reusable tool contracts

Tool registration gives each consuming app control over its schemas and local callbacks. The package core only needs shared Dart types for describing those tools and proposed calls.

  • Use Android Studio to create a folder named src inside ai_action_bridge/lib.
  • Confirm that src appears beneath lib in the file tree.
  • Create ai_action_bridge/lib/src/ai_tool.dart with the code below using Android Studio's file controls:
import 'package:flutter/widgets.dart';

typedef AiToolExecutor = Future<Map<String, dynamic>> Function(Map<String, dynamic> arguments);
typedef AiConfirmationBuilder = Widget Function(BuildContext context, Map<String, dynamic> arguments);

class AiTool {
  const AiTool({required this.name, required this.description, required this.parameters, required this.execute, this.confirmationBuilder});
  final String name;
  final String description;
  final Map<String, dynamic> parameters;
  final AiToolExecutor execute;
  final AiConfirmationBuilder? confirmationBuilder;
  Map<String, dynamic> get declaration => {'type': 'function', 'name': name, 'description': description, 'parameters': parameters};
}

class AiToolCall {
  const AiToolCall({required this.id, required this.name, required this.arguments});
  final String id;
  final String name;
  final Map<String, dynamic> arguments;
}

class AiPlan {
  const AiPlan({required this.kind, required this.history, this.call, this.message});
  final String kind;
  final List<Map<String, dynamic>> history;
  final AiToolCall? call;
  final String? message;
}

What Do These Contracts Represent?

  • The AiTool class groups a function name with its description and parameter schema.
  • The execute callback keeps app-specific work inside the consuming Flutter app.
  • The optional confirmationBuilder lets each registered action provide its own approval interface.
  • The AiToolCall and AiPlan classes describe the structured proposal that a planner can return.
  • Save ai_action_bridge/lib/src/ai_tool.dart.
  • Confirm that ai_tool.dart appears beneath lib/src in the file tree.

Cannot Find the Tool File?

Check that ai_tool.dart sits inside ai_action_bridge/lib/src. A file created beneath example/lib belongs to the demo app instead.

Ask for help with the file location.

✔️ Awesome, I've got everything!

Your tool contracts are saved in the package source folder.

ⓧ I'd like to double check the full code

import 'package:flutter/widgets.dart';

typedef AiToolExecutor = Future<Map<String, dynamic>> Function(Map<String, dynamic> arguments);
typedef AiConfirmationBuilder = Widget Function(BuildContext context, Map<String, dynamic> arguments);

class AiTool {
  const AiTool({required this.name, required this.description, required this.parameters, required this.execute, this.confirmationBuilder});
  final String name;
  final String description;
  final Map<String, dynamic> parameters;
  final AiToolExecutor execute;
  final AiConfirmationBuilder? confirmationBuilder;
  Map<String, dynamic> get declaration => {'type': 'function', 'name': name, 'description': description, 'parameters': parameters};
}

class AiToolCall {
  const AiToolCall({required this.id, required this.name, required this.arguments});
  final String id;
  final String name;
  final Map<String, dynamic> arguments;
}

class AiPlan {
  const AiPlan({required this.kind, required this.history, this.call, this.message});
  final String kind;
  final List<Map<String, dynamic>> history;
  final AiToolCall? call;
  final String? message;
}

A reusable package needs a small public entry point. Exporting the contracts lets the example app import one library instead of reaching into lib/src.

  • Replace the contents of ai_action_bridge/lib/ai_action_bridge.dart with the code below:
library;

export 'src/ai_action_panel.dart';
export 'src/ai_tool.dart';

What Does the Library File Expose?

  • The library declaration identifies this file as the package entry point.
  • The export statements make the tool contracts and action panel available through the package import.
  • Save ai_action_bridge/lib/ai_action_bridge.dart.
  • Confirm that the file now shows exports for ai_action_panel.dart and ai_tool.dart.

Seeing an Unresolved Export?

Check that both exported files use the exact src paths shown above. File names must match their export paths.

Ask for help checking the package exports.

✔️ Awesome, I've got everything!

Your package entry point now exposes the shared contracts.

ⓧ I'd like to double check the full code

library;

export 'src/ai_action_panel.dart';
export 'src/ai_tool.dart';
Build the action composer

The composer is the package's visible integration surface. It collects a natural-language request and reports the current action state.

  • Create ai_action_bridge/lib/src/ai_action_panel.dart with the code below using Android Studio's file controls:
import 'package:flutter/material.dart';

import 'ai_tool.dart';

class AiActionPanel extends StatefulWidget {
  const AiActionPanel({super.key, required this.tools});
  final List<AiTool> tools;
  @override
  State<AiActionPanel> createState() => _AiActionPanelState();
}

class _AiActionPanelState extends State<AiActionPanel> {
  final _controller = TextEditingController();
  String _message = 'Describe a CRM update to begin.';
  @override
  void dispose() { _controller.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => Card(child: Padding(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [TextField(controller: _controller, decoration: const InputDecoration(labelText: 'Ask the app to perform an action', hintText: 'Mark contact_42 as qualified', border: OutlineInputBorder()), minLines: 2, maxLines: 4), const SizedBox(height: 12), FilledButton(onPressed: () => setState(() => _message = 'No remote planner is connected'), child: const Text('Plan action')), const SizedBox(height: 12), Text(_message)])));
}

How Does the Composer Work?

  • The tools property accepts actions registered by the consuming app.
  • The text controller holds the learner's CRM request.
  • The button updates _message through setState().
  • The dispose() method releases the text controller when the panel leaves the widget tree.
  • Save ai_action_bridge/lib/src/ai_action_panel.dart.
  • Confirm that ai_action_panel.dart appears beside ai_tool.dart beneath lib/src.

Panel File in the Wrong Folder?

The relative import expects ai_action_panel.dart and ai_tool.dart to share the same folder. Move the panel file into ai_action_bridge/lib/src if the import cannot resolve.

Ask for help checking the panel file structure.

✔️ Awesome, I've got everything!

Your reusable action composer is now part of the package source.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';

import 'ai_tool.dart';

class AiActionPanel extends StatefulWidget {
  const AiActionPanel({super.key, required this.tools});
  final List<AiTool> tools;
  @override
  State<AiActionPanel> createState() => _AiActionPanelState();
}

class _AiActionPanelState extends State<AiActionPanel> {
  final _controller = TextEditingController();
  String _message = 'Describe a CRM update to begin.';
  @override
  void dispose() { _controller.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => Card(child: Padding(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [TextField(controller: _controller, decoration: const InputDecoration(labelText: 'Ask the app to perform an action', hintText: 'Mark contact_42 as qualified', border: OutlineInputBorder()), minLines: 2, maxLines: 4), const SizedBox(height: 12), FilledButton(onPressed: () => setState(() => _message = 'No remote planner is connected'), child: const Text('Plan action')), const SizedBox(height: 12), Text(_message)])));
}
Render the CRM demo and test the boundary

The example app supplies the package with a real registration surface. Its local contact starts with a known status so every later mutation is easy to spot.

  • Replace the contents of ai_action_bridge/example/lib/main.dart with the code below:
import 'package:ai_action_bridge/ai_action_bridge.dart';
import 'package:flutter/material.dart';

void main() => runApp(const DemoApp());

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(home: CrmDemoPage());
}

class CrmDemoPage extends StatefulWidget {
  const CrmDemoPage({super.key});
  @override
  State<CrmDemoPage> createState() => _CrmDemoPageState();
}

class _CrmDemoPageState extends State<CrmDemoPage> {
  final _contact = ValueNotifier<Map<String, String>>({'id': 'contact_42', 'name': 'Avery Morgan', 'status': 'lead'});
  late final List<AiTool> _tools;
  @override
  void initState() { super.initState(); _tools = [AiTool(name: 'update_contact_status', description: 'Update the lifecycle status of one CRM contact.', parameters: const {'type': 'object', 'properties': {'contact_id': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['lead', 'qualified', 'customer']}}, 'required': ['contact_id', 'status']}, execute: (arguments) async => {})]; }
  @override
  void dispose() { _contact.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => Scaffold(appBar: AppBar(title: const Text('AI Action Bridge CRM')), body: ListView(padding: const EdgeInsets.all(16), children: [ValueListenableBuilder<Map<String, String>>(valueListenable: _contact, builder: (context, contact, child) => Card(child: ListTile(title: Text(contact['name']!), subtitle: Text('${contact['id']}  |  ${contact['status']}')))), const SizedBox(height: 12), AiActionPanel(tools: _tools)]));
}

What Does the Demo Register?

  • The _contact notifier holds Avery Morgan's local CRM record.
  • The update_contact_status registration describes one app-owned action.
  • The status schema limits proposed values to lead, qualified, or customer.
  • The empty executor keeps this step focused on the package surface without changing CRM state.
  • Save ai_action_bridge/example/lib/main.dart.

Before you run the example, what status do you expect the contact card to show?

  • Build the updated example from the terminal already positioned in ai_action_bridge/example by running this command:
flutter run

What Does This Check Prove?

The command rebuilds the example against the package's public exports. A successful launch confirms that the demo can construct AiTool and AiActionPanel through the package import.

You'll see the Android emulator show Avery Morgan with contact_42 | lead. The action composer appears below the contact card.

Example Fails to Build?

Check that both source files sit beneath ai_action_bridge/lib/src. Confirm that ai_action_bridge.dart exports both files.

Ask for help with the Flutter build.

Before you submit the prompt, do you think the composer can produce a tool call yet?

  • Enter Mark contact_42 as qualified in the action field.
  • Tap Plan action.

You'll see No remote planner is connected beneath the button. The contact remains lead.

What Did the Limitation Expose?

Submitting updates only the panel message. No remote planning request runs.

The contact stays unchanged because the temporary path never executes its registered callback.

✔️ Awesome, I've got everything!

Your CRM contact card and action composer now render together.

ⓧ I'd like to double check the full code

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

void main() => runApp(const DemoApp());

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});
  @override
  Widget build(BuildContext context) => MaterialApp(home: CrmDemoPage());
}

class CrmDemoPage extends StatefulWidget {
  const CrmDemoPage({super.key});
  @override
  State<CrmDemoPage> createState() => _CrmDemoPageState();
}

class _CrmDemoPageState extends State<CrmDemoPage> {
  final _contact = ValueNotifier<Map<String, String>>({'id': 'contact_42', 'name': 'Avery Morgan', 'status': 'lead'});
  late final List<AiTool> _tools;
  @override
  void initState() { super.initState(); _tools = [AiTool(name: 'update_contact_status', description: 'Update the lifecycle status of one CRM contact.', parameters: const {'type': 'object', 'properties': {'contact_id': {'type': 'string'}, 'status': {'type': 'string', 'enum': ['lead', 'qualified', 'customer']}}, 'required': ['contact_id', 'status']}, execute: (arguments) async => {})]; }
  @override
  void dispose() { _contact.dispose(); super.dispose(); }
  @override
  Widget build(BuildContext context) => Scaffold(appBar: AppBar(title: const Text('AI Action Bridge CRM')), body: ListView(padding: const EdgeInsets.all(16), children: [ValueListenableBuilder<Map<String, String>>(valueListenable: _contact, builder: (context, contact, child) => Card(child: ListTile(title: Text(contact['name']!), subtitle: Text('${contact['id']}  |  ${contact['status']}')))), const SizedBox(height: 12), AiActionPanel(tools: _tools)]));
}

Strong work: the package now renders a registered CRM action without changing local state. Next, you connect the planner so the same prompt can become a structured tool call.

Turn Natural Language Into a Tool Call

Your Flutter preview now exposes the action contract. However, the missing planner blocks every natural-language request from becoming a structured proposal.

Your local Cloudflare Worker closes that gap with Hono plus the Gemini Interactions REST API. It sends the registered JSON Schema to Gemini behind bearer authentication.

In this step, get ready to:
  • Add typed request contracts plus an authenticated planning route to the Worker.
  • Supply local Gemini credentials through ignored secret bindings.
  • Submit a CRM request that produces a structured function call.
Add the protected Gemini planning route

The Worker needs explicit types for its environment bindings plus the tool declarations it receives. These contracts keep the route aligned with the schema registered by the Flutter app.

  • Switch back to ai-action-server/src/index.ts in the editor from earlier.
  • Add the bearer import plus the planning request types by replacing the import section with this code:
import { Hono } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'

type Bindings = {
  GEMINI_API_KEY: string
  APP_TOKEN: string
}

type JsonMap = Record<string, unknown>

type ToolDeclaration = {
  type: 'function'
  name: string
  description: string
  parameters: JsonMap
}

type PlanRequest = {
  message: string
  tools: ToolDeclaration[]
}

What do these types define?

  • The Bindings type identifies the two secrets available through the Worker environment.
  • The ToolDeclaration type mirrors each function declaration sent by the Flutter package.
  • The PlanRequest type groups the natural-language message with its available tools.
  • Save ai-action-server/src/index.ts.
  • Confirm that the Worker still serves its health route by running this command in your second terminal:
curl http://localhost:8787/health

What should I see?

You should see {"ok":true}. This confirms that the new types compile without changing the public health route.

Did the Worker stop compiling?

Check that both imports remain at the top of index.ts. Make sure every type closes with a matching brace.

Ask for help with the new Worker types.

The Gemini helper sends a stateless Interaction request. Its response parser extracts readable text when Gemini answers without proposing a function.

  • Replace the existing const app = new Hono() line with the endpoint constants plus response helpers below:
const geminiEndpoint =
  'https://generativelanguage.googleapis.com/v1beta/interactions'
const model = 'gemini-3.8-flash'

const app = new Hono<{ Bindings: Bindings }>()

const isJsonMap = (value: unknown): value is JsonMap =>
  typeof value === 'object' && value !== null && !Array.isArray(value)

const modelText = (steps: JsonMap[]): string => {
  for (const step of steps) {
    if (step.type !== 'model_output' || !Array.isArray(step.content)) continue
    for (const item of step.content) {
      if (isJsonMap(item) && item.type === 'text' && typeof item.text === 'string') {
        return item.text
      }
    }
  }
  return ''
}

How does response parsing work?

  • The geminiEndpoint constant points every planning request at the Interactions endpoint.
  • The model constant selects gemini-3.8-flash for function calling.
  • The isJsonMap helper rejects null values plus arrays before the Worker reads object fields.
  • The modelText helper searches model output steps for the first text response.
  • Insert the Gemini request helper immediately below modelText by copying this code:
const createInteraction = async (
  apiKey: string,
  payload: JsonMap,
): Promise<JsonMap> => {
  const response = await fetch(geminiEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-goog-api-key': apiKey,
    },
    body: JSON.stringify(payload),
  })

  if (!response.ok) {
    throw new Error(`Gemini API ${response.status}: ${await response.text()}`)
  }

  const data: unknown = await response.json()
  if (!isJsonMap(data)) throw new Error('Gemini returned a non-object response')
  return data
}

What does the Gemini helper do?

  • The helper sends the payload as JSON with the Gemini API key in its authentication header.
  • A failed response becomes an exception containing the returned status plus response text.
  • A successful response must pass isJsonMap before the route can inspect it.
  • Save ai-action-server/src/index.ts.
  • Confirm that the expanded Worker still compiles by running the health check again:
curl http://localhost:8787/health

What does this check prove?

You should still see {"ok":true}. The endpoint constants plus Gemini helper now compile inside the running Worker.

Is the health check failing now?

Check the closing parenthesis on the fetch call. Confirm that createInteraction appears before the health route.

Ask for help with the Gemini request helper.

The health endpoint stays public so deployment checks remain simple. Every route under /v1/* must present the token held in APP_TOKEN.

  • Replace the current health route line with the public health route plus bearer middleware below:
app.get('/health', (c) => c.json({ ok: true }))

app.use('/v1/*', async (c, next) => {
  const auth = bearerAuth({ token: c.env.APP_TOKEN })
  return auth(c, next)
})

How is the route protected?

The middleware reads APP_TOKEN from the Worker environment. It checks the bearer credential before a protected route handler runs.

  • Save ai-action-server/src/index.ts.
  • Confirm that the public route remains reachable by running:
curl http://localhost:8787/health

What should remain public?

You should see {"ok":true} again. The bearer middleware only covers routes beneath /v1/*.

Did the public route become unavailable?

Confirm that the app.get('/health' line appears before the middleware. Check that the middleware path is /v1/*.

Ask for help with the middleware order.

The planning route validates the incoming message before contacting Gemini. It then preserves every returned step so the next request can continue the stateless interaction.

The route spans two code sections to keep each section readable. Keep the file unsaved after the first section because the callback closes in the second section.

  • Place your cursor immediately below the bearer middleware in ai-action-server/src/index.ts.
  • Add the request validation plus Gemini Interaction call by copying this first route section:
app.post('/v1/plan', async (c) => {
  const body = await c.req.json<PlanRequest>()
  if (!body.message?.trim() || !Array.isArray(body.tools) || body.tools.length === 0) {
    return c.json({ error: 'message and at least one tool are required' }, 400)
  }

  const userStep: JsonMap = {
    type: 'user_input',
    content: [{ type: 'text', text: body.message.trim() }],
  }

  const interaction = await createInteraction(c.env.GEMINI_API_KEY, {
    model,
    store: false,
    input: [userStep],
    tools: body.tools,
    system_instruction:
      'For CRM change requests, call exactly one available function. Do not claim an action happened before receiving its function_result.',
  })

How does planning begin?

  • The route rejects a blank message or an empty tool list before making an API request.
  • The userStep object converts the CRM prompt into Interactions input.
  • The store: false setting keeps the interaction stateless.
  • The system instruction prevents Gemini from claiming that an unexecuted CRM change already happened.
  • Complete the same route by adding this second section immediately below the first section:
  const steps = Array.isArray(interaction.steps)
    ? interaction.steps.filter(isJsonMap)
    : []
  const call = steps.find((step) => step.type === 'function_call')

  if (!call) {
    return c.json({
      kind: 'message',
      message: modelText(steps) || 'Gemini did not propose an action.',
    })
  }

  if (
    typeof call.id !== 'string' ||
    typeof call.name !== 'string' ||
    !isJsonMap(call.arguments)
  ) {
    return c.json({ error: 'Gemini returned an invalid function call' }, 502)
  }

  return c.json({
    kind: 'tool_call',
    history: [userStep, ...steps],
    call: {
      id: call.id,
      name: call.name,
      arguments: call.arguments,
    },
  })
})

How does the route choose its response?

  • A response without a function call becomes a message result.
  • A function call must contain a string ID plus a string name plus object arguments.
  • A valid proposal becomes a tool_call containing the full stateless history.
  • Save ai-action-server/src/index.ts.
  • Confirm that the completed route compiles by running the public health check:
curl http://localhost:8787/health

What confirms a clean compile?

You should see {"ok":true}. The complete planning route is now loaded by the local Worker.

Is the Worker reporting a route syntax issue?

Confirm that the second route section sits directly below the first. Make sure export default app remains at the bottom of the file.

Ask for help assembling the planning route.

Add local secret bindings and restart the Worker

The Worker reads its Gemini key plus demo access token from environment bindings. Wrangler can also require those bindings before deployment.

  • Switch back to ai-action-server/wrangler.jsonc in the editor.
  • Replace its contents with this configuration:
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ai-action-server",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-17",
  "secrets": {
    "required": ["GEMINI_API_KEY", "APP_TOKEN"]
  }
}

What does the secret declaration do?

The secrets.required list names the bindings that the Worker expects. Missing values can now fail deployment with a clear configuration problem.

  • Save ai-action-server/wrangler.jsonc.
  • Create a file named .dev.vars inside the ai-action-server folder using the editor file sidebar.
  • Type GEMINI_API_KEY followed by an equals sign plus your existing Gemini API key on the first line.
  • Type APP_TOKEN followed by an equals sign plus a long unique demo token on the second line.
  • Save ai-action-server/.dev.vars.

Your existing .gitignore already excludes .dev.vars*. The local credential file therefore stays outside version control.

Keep this test synthetic

Gemini Free tier input plus output are free of charge. Free tier content can be used to improve Google products.

Use only the synthetic contact_42 record in this project. A paid Gemini project can incur token charges.

  • Return to the terminal running the local Worker.
  • Stop the current development process with Ctrl+C.
  • Restart Wrangler so it loads .dev.vars by running:
npx wrangler dev

What does the restart change?

Wrangler reloads the Worker with both local secret bindings. Keep this terminal running while you send the planning request from another terminal.

Did Wrangler fail to restart?

Confirm that .dev.vars sits directly inside ai-action-server. Check that each binding occupies its own line.

Ask for help loading the Worker secrets.

Before you check the Worker, do you expect the public health route to remain available after secrets load?

  • Switch to your second terminal.
  • Verify the restarted Worker by running:
curl http://localhost:8787/health

What should the restarted Worker return?

You should see {"ok":true}. Your Worker is running with the local secret file loaded.

That is the protected planning service assembled: the public health route still responds while the action route now has its required bindings.

The Worker source plus configuration should now match this checkpoint.

✔️ Awesome, I've got everything!

Your Worker compiles with the planning route. Your local secret file remains uncommitted.

ⓧ I'd like to double check the full code

  • Compare your complete ai-action-server/src/index.ts file with this reference:
import { Hono } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'

type Bindings = {
  GEMINI_API_KEY: string
  APP_TOKEN: string
}

type JsonMap = Record<string, unknown>

type ToolDeclaration = {
  type: 'function'
  name: string
  description: string
  parameters: JsonMap
}

type PlanRequest = {
  message: string
  tools: ToolDeclaration[]
}

const geminiEndpoint =
  'https://generativelanguage.googleapis.com/v1beta/interactions'
const model = 'gemini-3.8-flash'

const app = new Hono<{ Bindings: Bindings }>()

const isJsonMap = (value: unknown): value is JsonMap =>
  typeof value === 'object' && value !== null && !Array.isArray(value)

const modelText = (steps: JsonMap[]): string => {
  for (const step of steps) {
    if (step.type !== 'model_output' || !Array.isArray(step.content)) continue
    for (const item of step.content) {
      if (isJsonMap(item) && item.type === 'text' && typeof item.text === 'string') {
        return item.text
      }
    }
  }
  return ''
}

const createInteraction = async (
  apiKey: string,
  payload: JsonMap,
): Promise<JsonMap> => {
  const response = await fetch(geminiEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-goog-api-key': apiKey,
    },
    body: JSON.stringify(payload),
  })

  if (!response.ok) {
    throw new Error(`Gemini API ${response.status}: ${await response.text()}`)
  }

  const data: unknown = await response.json()
  if (!isJsonMap(data)) throw new Error('Gemini returned a non-object response')
  return data
}

app.get('/health', (c) => c.json({ ok: true }))

app.use('/v1/*', async (c, next) => {
  const auth = bearerAuth({ token: c.env.APP_TOKEN })
  return auth(c, next)
})

app.post('/v1/plan', async (c) => {
  const body = await c.req.json<PlanRequest>()
  if (!body.message?.trim() || !Array.isArray(body.tools) || body.tools.length === 0) {
    return c.json({ error: 'message and at least one tool are required' }, 400)
  }

  const userStep: JsonMap = {
    type: 'user_input',
    content: [{ type: 'text', text: body.message.trim() }],
  }

  const interaction = await createInteraction(c.env.GEMINI_API_KEY, {
    model,
    store: false,
    input: [userStep],
    tools: body.tools,
    system_instruction:
      'For CRM change requests, call exactly one available function. Do not claim an action happened before receiving its function_result.',
  })

  const steps = Array.isArray(interaction.steps)
    ? interaction.steps.filter(isJsonMap)
    : []
  const call = steps.find((step) => step.type === 'function_call')

  if (!call) {
    return c.json({
      kind: 'message',
      message: modelText(steps) || 'Gemini did not propose an action.',
    })
  }

  if (
    typeof call.id !== 'string' ||
    typeof call.name !== 'string' ||
    !isJsonMap(call.arguments)
  ) {
    return c.json({ error: 'Gemini returned an invalid function call' }, 502)
  }

  return c.json({
    kind: 'tool_call',
    history: [userStep, ...steps],
    call: {
      id: call.id,
      name: call.name,
      arguments: call.arguments,
    },
  })
})

export default app

How should I use this reference?

Compare the import order plus each complete helper plus the route order. Keep export default app as the final line.

  • Compare your complete ai-action-server/wrangler.jsonc file with this reference:
{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "ai-action-server",
  "main": "src/index.ts",
  "compatibility_date": "2026-10-17",
  "secrets": {
    "required": ["GEMINI_API_KEY", "APP_TOKEN"]
  }
}

What should the configuration contain?

The configuration keeps the existing Worker entry point plus compatibility date. It now requires both project-defined secret bindings.

Submit a CRM planning request

The request sends the same schema that the Flutter app registered for update_contact_status. Gemini can select that function plus produce structured arguments without executing the local callback.

  • Prepare an authenticated planning request in your second terminal by copying this command:
curl -X POST http://localhost:8787/v1/plan \
  -H "Authorization: Bearer your-worker-access-token" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Mark contact_42 as qualified",
    "tools": [
      {
        "type": "function",
        "name": "update_contact_status",
        "description": "Update the lifecycle status of one CRM contact.",
        "parameters": {
          "type": "object",
          "properties": {
            "contact_id": {"type": "string"},
            "status": {
              "type": "string",
              "enum": ["lead", "qualified", "customer"]
            }
          },
          "required": ["contact_id", "status"]
        }
      }
    ]
  }'

What does this request send?

  • The authorization header carries the same demo token stored under APP_TOKEN.
  • The message asks Gemini to change the synthetic contact to qualified.
  • The tool declaration limits the proposed status to lead plus qualified plus customer.
  • The Worker forwards the schema with store: false so the response carries the history needed for the next turn.

Before you send the request, which registered function do you expect Gemini to select?

  • Replace your-worker-access-token with the APP_TOKEN value stored in .dev.vars.
  • Run the edited command.

You should see a JSON response containing "kind":"tool_call" plus "name":"update_contact_status". Its arguments should identify contact_42 plus the qualified status.

  • Inspect the response for a call ID plus stateless history.
  • Leave the Flutter preview unchanged because this step only proposes the action.

Did the planning request fail?

Confirm that the pasted bearer value matches APP_TOKEN in .dev.vars. Restart Wrangler after correcting either local secret.

Check that your Gemini API key is active. Keep the JSON body unchanged so its schema matches the registered CRM action.

Ask for help with the local planning request.

  • Scroll the terminal until the command containing your token is outside the visible area.

You have turned a natural-language CRM request into a validated function proposal while keeping the contact unchanged. Next up, you will return the local result to Gemini before deploying the stateless loop.

Complete and Deploy the Action Loop

Your local Cloudflare Worker now turns a CRM request into a structured call from Gemini. A proposed call leaves the interaction unfinished because Gemini has not received the local result.

In this step, you will complete the stateless interaction with a function result. You will also deploy the protected Worker so the Flutter app can reach it later.

In this step, get ready to:
  • Complete the stateless function result route.
  • Deploy the Worker with its required secret bindings.
  • Verify the deployed health route plus both protected routes.
Complete the stateless interaction

A stateless function call carries its history between requests. The completion route receives that history plus the local result so Gemini can produce the final response.

  • In ai-action-server/src/index.ts, find the existing PlanRequest type.
  • Add the completion request contract below it by copying this code:
type CompleteRequest = {
  history: JsonMap[]
  tools: ToolDeclaration[]
  call: {
    id: string
    name: string
  }
  result: JsonMap
}

What does this contract describe?

  • The history field carries the original user input plus every model-generated step.
  • The call field identifies the function call that produced the local result.
  • The result field carries the structured outcome returned by the Flutter callback.
  • Save ai-action-server/src/index.ts.
  • Return to the local Wrangler terminal from earlier to confirm it rebuilds without a TypeScript error.

Seeing a TypeScript error?

Check that CompleteRequest sits after the closing brace of PlanRequest. Confirm every field matches the copied contract.

Ask for help with my CompleteRequest type error.

The new Hono route first guards its input. This gives the deployed endpoint a safe response when a client omits required interaction data.

  • In ai-action-server/src/index.ts, find the closing line of the existing /v1/plan route.
  • Add the initial completion route below it by copying this code:
app.post('/v1/complete', async (c) => {
  const body = await c.req.json<CompleteRequest>()
  if (
    !Array.isArray(body.history) ||
    !Array.isArray(body.tools) ||
    typeof body.call?.id !== 'string' ||
    typeof body.call?.name !== 'string' ||
    !isJsonMap(body.result)
  ) {
    return c.json({ error: 'history, tools, call, and result are required' }, 400)
  }
})

What does this validation protect?

The route rejects requests that lack stateless history or registered tools. It also requires a valid call identity plus an object-shaped result.

  • Save ai-action-server/src/index.ts.

Before you test this route, do you expect an empty request to pass its validation?

  • Replace your-worker-access-token with the existing APP_TOKEN value from ai-action-server/.dev.vars.
  • Send an empty authenticated request from the request terminal by running this command:
curl -i -X POST http://localhost:8787/v1/complete -H "Authorization: Bearer your-worker-access-token" -H "Content-Type: application/json" -d '{}'

What does this request check?

The bearer header lets the request reach /v1/complete. The empty JSON object tests the route's required-field guard.

You should see status 400 with {"error":"history, tools, call, and result are required"}. This proves the new route rejects incomplete interaction data.

Getting an authentication rejection?

Confirm the command contains the same APP_TOKEN value as ai-action-server/.dev.vars. Keep the Bearer prefix before the token.

Ask for help with my local completion route authentication.

The validated request can now become a Gemini-compatible function result. The route preserves the original history because stored interaction state remains disabled.

  • Inside the /v1/complete route, find the closing brace of the validation block.
  • Add the completion logic before the route's final }) line by copying this code:
  const functionResult: JsonMap = {
    type: 'function_result',
    name: body.call.name,
    call_id: body.call.id,
    result: [{ type: 'text', text: JSON.stringify(body.result) }],
  }

  const interaction = await createInteraction(c.env.GEMINI_API_KEY, {
    model,
    store: false,
    input: [...body.history, functionResult],
    tools: body.tools,
  })

  const steps = Array.isArray(interaction.steps)
    ? interaction.steps.filter(isJsonMap)
    : []

  return c.json({
    message: modelText(steps) || 'The local action completed successfully.',
  })

How does the action loop finish?

  • The functionResult object links the local result to Gemini's original call ID.
  • The input array combines the supplied stateless history with that result.
  • The route extracts text from the returned model_output steps. It uses a successful fallback message when Gemini returns no text.
  • Save ai-action-server/src/index.ts.
  • Return to the local Wrangler terminal to confirm the Worker rebuilds without a TypeScript error.

Worker failing after the route update?

Check that the completion logic remains inside app.post('/v1/complete'. Confirm it appears after the validation block.

Ask for help with my stateless function result route.

A shared error handler keeps unexpected Worker failures from exposing upstream details. Clients receive one stable JSON response while the full error remains in the Worker logs.

  • Add the error handler below the completed /v1/complete route by copying this code:
app.onError((error, c) => {
  console.error(error)
  return c.json({ error: 'The action request failed' }, 500)
})

What does the error handler do?

The handler records the original error for diagnostics. It returns a fixed status 500 response to the caller.

  • Save ai-action-server/src/index.ts.
  • Run the empty local completion request again to confirm the validation response still appears:
curl -i -X POST http://localhost:8787/v1/complete -H "Authorization: Bearer your-worker-access-token" -H "Content-Type: application/json" -d '{}'

What does this repeat prove?

The repeated 400 response proves the route still handles expected validation failures before the shared error handler is needed.

Seeing a server error now?

Check that app.onError appears after the completion route's closing line. Confirm export default app remains at the bottom of the file.

Ask for help with my Hono error handler placement.

✔️ Awesome, I've got everything!

Great. Save ai-action-server/src/index.ts before you deploy it.

ⓧ I'd like to double check the full code

import { Hono } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'

type Bindings = {
  GEMINI_API_KEY: string
  APP_TOKEN: string
}

type JsonMap = Record<string, unknown>

type ToolDeclaration = {
  type: 'function'
  name: string
  description: string
  parameters: JsonMap
}

type PlanRequest = {
  message: string
  tools: ToolDeclaration[]
}

type CompleteRequest = {
  history: JsonMap[]
  tools: ToolDeclaration[]
  call: {
    id: string
    name: string
  }
  result: JsonMap
}

const geminiEndpoint =
  'https://generativelanguage.googleapis.com/v1beta/interactions'
const model = 'gemini-3.8-flash'

const app = new Hono<{ Bindings: Bindings }>()

const isJsonMap = (value: unknown): value is JsonMap =>
  typeof value === 'object' && value !== null && !Array.isArray(value)

const modelText = (steps: JsonMap[]): string => {
  for (const step of steps) {
    if (step.type !== 'model_output' || !Array.isArray(step.content)) continue
    for (const item of step.content) {
      if (isJsonMap(item) && item.type === 'text' && typeof item.text === 'string') {
        return item.text
      }
    }
  }
  return ''
}

const createInteraction = async (
  apiKey: string,
  payload: JsonMap,
): Promise<JsonMap> => {
  const response = await fetch(geminiEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-goog-api-key': apiKey,
    },
    body: JSON.stringify(payload),
  })

  if (!response.ok) {
    throw new Error(`Gemini API ${response.status}: ${await response.text()}`)
  }

  const data: unknown = await response.json()
  if (!isJsonMap(data)) throw new Error('Gemini returned a non-object response')
  return data
}

app.get('/health', (c) => c.json({ ok: true }))

app.use('/v1/*', async (c, next) => {
  const auth = bearerAuth({ token: c.env.APP_TOKEN })
  return auth(c, next)
})

app.post('/v1/plan', async (c) => {
  const body = await c.req.json<PlanRequest>()
  if (!body.message?.trim() || !Array.isArray(body.tools) || body.tools.length === 0) {
    return c.json({ error: 'message and at least one tool are required' }, 400)
  }

  const userStep: JsonMap = {
    type: 'user_input',
    content: [{ type: 'text', text: body.message.trim() }],
  }

  const interaction = await createInteraction(c.env.GEMINI_API_KEY, {
    model,
    store: false,
    input: [userStep],
    tools: body.tools,
    system_instruction:
      'For CRM change requests, call exactly one available function. Do not claim an action happened before receiving its function_result.',
  })

  const steps = Array.isArray(interaction.steps)
    ? interaction.steps.filter(isJsonMap)
    : []
  const call = steps.find((step) => step.type === 'function_call')

  if (!call) {
    return c.json({
      kind: 'message',
      message: modelText(steps) || 'Gemini did not propose an action.',
    })
  }

  if (
    typeof call.id !== 'string' ||
    typeof call.name !== 'string' ||
    !isJsonMap(call.arguments)
  ) {
    return c.json({ error: 'Gemini returned an invalid function call' }, 502)
  }

  return c.json({
    kind: 'tool_call',
    history: [userStep, ...steps],
    call: {
      id: call.id,
      name: call.name,
      arguments: call.arguments,
    },
  })
})

app.post('/v1/complete', async (c) => {
  const body = await c.req.json<CompleteRequest>()
  if (
    !Array.isArray(body.history) ||
    !Array.isArray(body.tools) ||
    typeof body.call?.id !== 'string' ||
    typeof body.call?.name !== 'string' ||
    !isJsonMap(body.result)
  ) {
    return c.json({ error: 'history, tools, call, and result are required' }, 400)
  }

  const functionResult: JsonMap = {
    type: 'function_result',
    name: body.call.name,
    call_id: body.call.id,
    result: [{ type: 'text', text: JSON.stringify(body.result) }],
  }

  const interaction = await createInteraction(c.env.GEMINI_API_KEY, {
    model,
    store: false,
    input: [...body.history, functionResult],
    tools: body.tools,
  })

  const steps = Array.isArray(interaction.steps)
    ? interaction.steps.filter(isJsonMap)
    : []

  return c.json({
    message: modelText(steps) || 'The local action completed successfully.',
  })
})

app.onError((error, c) => {
  console.error(error)
  return c.json({ error: 'The action request failed' }, 500)
})

export default app
Deploy the protected Worker

The deployed Worker needs the same secret names as local development. Wrangler uploads those values as Cloudflare secret bindings while it publishes the Worker.

Keep deployment secrets local

The production secrets file contains your live Gemini key plus the demo bearer token. Your existing .gitignore excludes .env*, so the new file stays out of version control.

You will delete the local production file immediately after deployment. Cloudflare keeps the uploaded bindings available to the Worker.

  • In your editor's file explorer, copy ai-action-server/.dev.vars.
  • Paste the copy beside the original file with the name .env.production.
  • Deploy the Worker from the ai-action-server terminal by running this command:
npx wrangler deploy --secrets-file .env.production

What does deployment do?

Wrangler uploads the Worker code plus the two secret values from .env.production. The command prints the deployed workers.dev URL when publishing succeeds.

Your Worker is online with its Gemini key hidden behind a Cloudflare binding. The bearer token now protects both action routes.

  • Record the printed deployment URL here: https://your-worker.your-subdomain.workers.dev.
  • Record your existing demo bearer token in your password manager.
  • Delete ai-action-server/.env.production with your editor's file explorer.
  • Confirm .env.production no longer appears inside ai-action-server.

Deployment not completing?

Confirm .env.production contains both existing key-value pairs from .dev.vars. Check that the command runs inside ai-action-server.

Ask for help with my Wrangler deployment. You can also ask the Cloudflare Community.

Verify the deployed action routes

The public health route proves the Worker is reachable. The protected routes prove bearer authentication remains active after deployment.

Before you run the first check, which route do you expect to work without a bearer token?

  • Request the deployed health route by running this command:
curl -i [[DEPLOYED_WORKER_URL="https://your-worker.your-subdomain.workers.dev"]]/health

What does the health check prove?

This request reaches the public /health route without authentication. The response confirms the deployed Worker can handle requests.

You should see a successful response containing {"ok":true}.

Before you test the planning route, do you expect an unauthenticated request to reach Gemini?

  • Send an unauthenticated planning request by running this command:
curl -i -X POST [[DEPLOYED_WORKER_URL="https://your-worker.your-subdomain.workers.dev"]]/v1/plan -H "Content-Type: application/json" -d '{}'

Why is this request rejected?

The bearer middleware runs before the planning route. A request without an authorization header stops before any Gemini call can begin.

You should see an authentication rejection. This proves /v1/plan remains protected online.

Before you run the final check, will authentication alone make an empty completion body valid?

  • Replace your-worker-access-token with the demo bearer token you stored.
  • Send an authenticated empty completion request by running this command:
curl -i -X POST [[DEPLOYED_WORKER_URL="https://your-worker.your-subdomain.workers.dev"]]/v1/complete -H "Authorization: Bearer your-worker-access-token" -H "Content-Type: application/json" -d '{}'

What does the final check prove?

Authentication lets the request enter /v1/complete. The request then reaches the new validation guard.

You should see status 400 with {"error":"history, tools, call, and result are required"}. This proves the completion route exists behind bearer authentication.

That closes the server side of the action loop. Your deployed Worker can plan an action plus accept its eventual local result.

Deployed checks not matching?

Confirm every command uses the recorded workers.dev URL. Remove any trailing slash before the route path.

If both protected requests fail authentication, confirm the command uses the same demo token that was stored in .env.production during deployment.

Ask for help with my deployed Worker verification.

Your protected Worker is live. Next, you will connect the Flutter package so a confirmed CRM action can run locally before Gemini receives the result.

Confirm and Execute the Local Callback

Your protected Cloudflare Worker can plan an action and complete the stateless conversation. The final boundary belongs in the Flutter package because the server must never mutate the local CRM state.

You will connect the package to the Worker. The package will match each proposed function to a registered callback and require approval before anything changes.

In this step, get ready to:
  • Add a bearer-authenticated client for the planning and completion routes.
  • Require confirmation before dispatching a proposed function to a local callback.
  • Run the Android CRM example and update Avery Morgan from lead to qualified.
Connect the package to the Worker

The client owns the remote part of the action loop. It sends registered tool declarations to the planning route and returns the approved local result through the completion route.

  • Create `ai_action_client.dart` inside the `ai_action_bridge/lib/src` folder from the Explorer sidebar in your editor.
  • Build the client by copying the following three code sections into `ai_action_client.dart` in order.
import 'dart:convert';

import 'package:http/http.dart' as http;

import 'ai_tool.dart';

class AiActionException implements Exception {
  const AiActionException(this.message);

  final String message;

  @override
  String toString() => message;
}

class AiActionClient {
  AiActionClient({
    required String endpoint,
    required this.token,
    http.Client? httpClient,
  })  : endpoint = endpoint.replaceFirst(RegExp(r'/$'), ''),
        _httpClient = httpClient ?? http.Client();

  final String endpoint;
  final String token;
  final http.Client _httpClient;

What does this section do?

`AiActionException` gives Worker failures a package-specific error type. `AiActionClient` normalizes the endpoint and keeps one reusable HTTP client for the action loop.

  Future<AiPlan> plan(String prompt, List<AiTool> tools) async {
    final json = await _post('/v1/plan', {
      'message': prompt,
      'tools': tools.map((tool) => tool.declaration).toList(),
    });
    return AiPlan.fromJson(json);
  }

  Future<String> complete(
    AiPlan plan,
    List<AiTool> tools,
    Map<String, dynamic> result,
  ) async {
    final call = plan.call;
    if (call == null) throw const AiActionException('No tool call to complete');

    final json = await _post('/v1/complete', {
      'history': plan.history,
      'tools': tools.map((tool) => tool.declaration).toList(),
      'call': {'id': call.id, 'name': call.name},
      'result': result,
    });
    return json['message'] as String;
  }

How does the stateless loop continue?

`plan()` sends the prompt with declarations generated from the registered tools. `complete()` returns the preserved history and approved result so Gemini can write its final response.

  Future<Map<String, dynamic>> _post(
    String path,
    Map<String, dynamic> body,
  ) async {
    final response = await _httpClient.post(
      Uri.parse('$endpoint$path'),
      headers: {
        'Authorization': 'Bearer $token',
        'Content-Type': 'application/json',
      },
      body: jsonEncode(body),
    );

    final decoded = jsonDecode(utf8.decode(response.bodyBytes));
    if (response.statusCode < 200 || response.statusCode >= 300) {
      final message = decoded is Map ? decoded['error'] : response.reasonPhrase;
      throw AiActionException('Worker ${response.statusCode}: $message');
    }
    if (decoded is! Map<String, dynamic>) {
      throw const AiActionException('Worker returned an invalid response');
    }
    return decoded;
  }

  void close() => _httpClient.close();
}

How are requests protected?

`_post()` encodes each request as JSON and sends the demo token through the authorization header. It also converts unsuccessful or malformed responses into errors the action panel can display.

  • Save `ai_action_client.dart`.
  • Switch back to `ai_action_bridge.dart` in the `ai_action_bridge/lib` folder.
  • Replace its temporary exports with the public package exports below.
library;

export 'src/ai_action_client.dart';
export 'src/ai_action_panel.dart';
export 'src/ai_tool.dart';

Why export the client?

The package entry file exposes the client alongside the panel and tool contracts. A consuming app can now import the complete action API from one library.

  • Save `ai_action_bridge.dart`.
  • Return to the terminal in the `ai_action_bridge/example` folder.
  • Run the current preview to confirm the package compiles with the new client:
flutter run

What does this check prove?

The terminal should complete the Flutter build and launch the existing CRM preview. This proves the new network client compiles before the temporary panel is replaced.

Does the client fail to compile?

Confirm `ai_action_client.dart` sits inside `ai_action_bridge/lib/src`. Check that all three sections appear in the same file in the order shown.

If the HTTP import is unresolved, return to the package folder and confirm `pubspec.yaml` still lists version `1.6.0` for `http`.

Ask for help with the client error.

Add confirmation and local dispatch

The panel is the safety boundary between a model proposal and app state. It resolves the proposed name locally and pauses at a confirmation dialog before calling the registered executor.

  • Switch back to `ai_action_panel.dart` in the `ai_action_bridge/lib/src` folder.
  • Replace the temporary panel by copying the following code sections into the file in order.
import 'dart:convert';

import 'package:flutter/material.dart';

import 'ai_action_client.dart';
import 'ai_tool.dart';

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

  final AiActionClient client;
  final List<AiTool> tools;

  @override
  State<AiActionPanel> createState() => _AiActionPanelState();
}

class _AiActionPanelState extends State<AiActionPanel> {

What does the panel receive?

The reusable widget receives its Worker client and the tools registered by the consuming app. The package core stays independent of CRM-specific actions.

  final _controller = TextEditingController();
  bool _busy = false;
  String _message = 'Describe a CRM update to begin.';

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

  AiTool? _findTool(String name) {
    for (final tool in widget.tools) {
      if (tool.name == name) return tool;
    }
    return null;
  }

  Future<void> _submit() async {
    final prompt = _controller.text.trim();
    if (prompt.isEmpty || _busy) return;

    setState(() {
      _busy = true;
      _message = 'Planning the action...';
    });

How does local lookup work?

`_findTool()` searches only the tools supplied by the app. `_submit()` also blocks empty prompts and duplicate submissions while a request is active.

    try {
      final plan = await widget.client.plan(prompt, widget.tools);
      if (!mounted) return;
      if (plan.call == null) {
        setState(() => _message = plan.message ?? 'No action proposed.');
        return;
      }

      final call = plan.call!;
      final tool = _findTool(call.name);
      if (tool == null) {
        throw AiActionException('No local tool is registered as ${call.name}');
      }

      final confirmed = await showDialog<bool>(
        context: context,
        builder: (dialogContext) => AlertDialog(

Why reject an unknown name?

A proposed function can proceed only when its name matches a local `AiTool`. An unmatched proposal becomes a visible error instead of reaching arbitrary app code.

          title: Text('Confirm ${tool.name}'),
          content: tool.confirmationBuilder?.call(
                dialogContext,
                call.arguments,
              ) ??
              SelectableText(
                const JsonEncoder.withIndent('  ').convert(call.arguments),
              ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(dialogContext, false),
              child: const Text('Cancel'),
            ),
            FilledButton(
              onPressed: () => Navigator.pop(dialogContext, true),
              child: const Text('Run action'),
            ),
          ],
        ),
      );

How does confirmation stay reusable?

Each tool can supply its own confirmation widget. The panel falls back to formatted arguments when a custom builder is absent.

      if (confirmed != true) {
        setState(() => _message = 'Action canceled. Nothing changed.');
        return;
      }

      final result = await tool.execute(call.arguments);
      final finalMessage = await widget.client.complete(
        plan,
        widget.tools,
        result,
      );
      if (mounted) setState(() => _message = finalMessage);
    } catch (error) {
      if (mounted) setState(() => _message = 'Error: $error');
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),

Where does execution happen?

`tool.execute()` runs only after the dialog returns approval. Its result goes back to the Worker while the CRM mutation remains inside the app.

        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            TextField(
              controller: _controller,
              enabled: !_busy,
              decoration: const InputDecoration(
                labelText: 'Ask the app to perform an action',
                hintText: 'Mark contact_42 as qualified',
                border: OutlineInputBorder(),
              ),
              minLines: 2,
              maxLines: 4,
            ),
            const SizedBox(height: 12),
            FilledButton.icon(
              onPressed: _busy ? null : _submit,
              icon: _busy
                  ? const SizedBox.square(
                      dimension: 18,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    )
                  : const Icon(Icons.auto_awesome),

How does the loading state help?

The prompt and button are disabled while the action loop is active. The progress indicator makes the remote planning and completion wait visible.

              label: Text(_busy ? 'Working...' : 'Plan action'),
            ),
            const SizedBox(height: 12),
            Text(_message),
          ],
        ),
      ),
    );
  }
}

What does the finished panel show?

The final message area shows planning progress, cancellation, errors, or Gemini's response after local execution. One surface now covers the complete action lifecycle.

  • Save `ai_action_panel.dart`.

Seeing syntax errors in the panel?

Confirm the code sections appear in the same order with no repeated braces. Check that both `ai_action_client.dart` and `ai_tool.dart` are imported near the top.

Ask for help comparing the panel sections.

Register and run the CRM action

The example app owns the real business rule. Its registered callback validates the contact and allowed status before changing the local `ValueNotifier`.

  • Switch back to `main.dart` in the `ai_action_bridge/example/lib` folder.
  • Replace the temporary CRM example by copying the following code sections into `main.dart` in order.
import 'package:ai_action_bridge/ai_action_bridge.dart';
import 'package:flutter/material.dart';

const workerUrl = String.fromEnvironment('WORKER_URL');
const workerToken = String.fromEnvironment('WORKER_TOKEN');

void main() => runApp(const DemoApp());

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AI Action Bridge CRM',
      theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
      home: workerUrl.isEmpty || workerToken.isEmpty
          ? const _MissingConfigPage()
          : const CrmDemoPage(),
    );
  }
}

How does the app receive configuration?

The deployed URL and demo token come from compile-time environment declarations. The app selects a configuration message whenever either value is absent.

class _MissingConfigPage extends StatelessWidget {
  const _MissingConfigPage();

  @override
  Widget build(BuildContext context) {
    return const Scaffold(
      body: Center(
        child: Padding(
          padding: EdgeInsets.all(24),
          child: Text(
            'Run the example with WORKER_URL and WORKER_TOKEN supplied through --dart-define.',
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

class CrmDemoPage extends StatefulWidget {
  const CrmDemoPage({super.key});

  @override
  State<CrmDemoPage> createState() => _CrmDemoPageState();
}

Why include a missing-config page?

A clear page replaces a failed network request when the required declarations are missing. It gives the learner an immediate path back to the correct launch command.

class _CrmDemoPageState extends State<CrmDemoPage> {
  final _contact = ValueNotifier<Map<String, String>>({
    'id': 'contact_42',
    'name': 'Avery Morgan',
    'status': 'lead',
  });

  late final AiActionClient _client;
  late final List<AiTool> _tools;

  @override
  void initState() {
    super.initState();
    _client = AiActionClient(endpoint: workerUrl, token: workerToken);
    _tools = [
      AiTool(
        name: 'update_contact_status',
        description: 'Update the lifecycle status of one CRM contact.',
        parameters: const {
          'type': 'object',
          'properties': {
            'contact_id': {
              'type': 'string',
              'description': 'The CRM contact identifier.',
            },

What state stays local?

The contact record starts with Avery Morgan at `lead`. The Worker client receives configuration while the CRM data remains in the app's notifier.

            'status': {
              'type': 'string',
              'enum': ['lead', 'qualified', 'customer'],
            },
          },
          'required': ['contact_id', 'status'],
        },
        confirmationBuilder: (context, arguments) => Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Contact: ${arguments['contact_id']}'),
            Text('New status: ${arguments['status']}'),
          ],
        ),
        execute: (arguments) async {
          final contactId = arguments['contact_id'] as String?;
          final status = arguments['status'] as String?;
          if (contactId != _contact.value['id']) {
            throw StateError('Unknown contact: $contactId');
          }
          if (!const ['lead', 'qualified', 'customer'].contains(status)) {
            throw StateError('Unsupported status: $status');
          }
          _contact.value = {..._contact.value, 'status': status!};
          return {'contact_id': contactId, 'status': status};
        },
      ),
    ];
  }

What does the registered tool control?

The declaration limits Gemini to the supported status values. The confirmation builder previews the proposed change while the executor independently checks the contact and status before mutation.

  @override
  void dispose() {
    _client.close();
    _contact.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('AI Action Bridge CRM')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          ValueListenableBuilder<Map<String, String>>(
            valueListenable: _contact,
            builder: (context, contact, child) => Card(
              child: ListTile(
                leading: const CircleAvatar(child: Icon(Icons.person)),
                title: Text(contact['name']!),
                subtitle: Text('${contact['id']}  |  ${contact['status']}'),
              ),
            ),
          ),
          const SizedBox(height: 12),
          AiActionPanel(client: _client, tools: _tools),
        ],
      ),
    );
  }
}

How does the card update?

`ValueListenableBuilder` rebuilds the contact card after the approved executor changes the notifier. The same registered tool list is passed to the action panel for schema submission and local dispatch.

  • Save `main.dart`.
  • Stop the previous Flutter process with **Ctrl+C** if it is still running.
  • Return to the terminal in the `ai_action_bridge/example` folder.

Before you run the finished app, do you expect the contact status to change before or after you approve the proposed action?

  • Launch the Android example with your recorded Worker URL and demo bearer token by replacing the two placeholders in this command:
flutter run --dart-define=WORKER_URL=https://your-worker.your-subdomain.workers.dev --dart-define=WORKER_TOKEN=your-worker-access-token

What does this command supply?

The first declaration points the app at the deployed Worker. The second declaration supplies the demo bearer token used by the protected action routes.

  • Type `Mark contact_42 as qualified` into the prompt field.
  • Tap **Plan action**.

You should see a confirmation dialog naming `update_contact_status`. The dialog should show `contact_42` with the proposed status `qualified`.

  • Tap **Run action**.

You should see Avery Morgan's card change from `lead` to `qualified`. Gemini's final message should appear below the button after the local result returns to the Worker.

That closes the loop. Natural language now becomes a confirmed local CRM mutation without giving Gemini direct access to app state.

Does the action fail or stay unchanged?

If the app shows the missing-configuration page, check that both declarations are present in the launch command. Replace the URL placeholder with the workers.dev URL you recorded earlier.

If the panel reports an authorization failure, rerun the app with the same demo bearer token stored in the Worker's `APP_TOKEN` secret binding. Avoid sharing or capturing that token.

Help me debug the local action loop.

Your package now plans remotely and executes locally behind an approval gate. You can register more app actions without changing the package core.

Secret mission

Register a Second CRM Action

Extend the CRM example with an `add_contact_note` action that previews each proposed note before local execution. Approved notes appear in the app without changing the reusable package core or deployed Worker.

Clean Up Your Resources

Clean Up Your Resources

Your local Flutter files have no ongoing cost. Decide whether to keep the deployed Cloudflare Worker available for more tests, remove the deployment while preserving your code, or delete the complete workspace.

Cost warning

Requests from the deployed Worker can call Gemini when you test the app.

Gemini Free tier input and output are free of charge. A paid Gemini project can incur token charges.

  • Choose the Pause or Delete option if you do not plan to keep testing.

Resources you used:

  • One deployed ai-action-server Cloudflare Worker.
  • The GEMINI_API_KEY secret binding plus the APP_TOKEN secret binding.
  • The local ai-action-server folder containing the Worker source and configuration.
  • The local ai_action_bridge folder containing the reusable package and CRM example app.

Keep everything running

No action is needed. Choose this if you want to keep testing the protected Worker with synthetic CRM data.

  • Leave ai-action-server deployed for continued testing.
  • Keep the local ai-action-server folder.
  • Keep the local ai_action_bridge folder.
  • Use synthetic CRM data for future Gemini Free tier tests because Free tier content is used to improve Google products.

Pause - I'll come back to this later

Remove the deployed Worker while preserving your local source code. Its public routes remain unavailable until you deploy it again.

  • Switch back to the terminal from earlier inside ai-action-server.
  • Delete the deployment by running this command:
npx wrangler delete

What Does This Command Do?

  • Wrangler removes the deployed Worker and its associated Cloudflare developer platform resources.
  • The local ai-action-server folder remains available.
  • The local ai_action_bridge folder remains available.
  • Confirm the terminal reports that Worker deletion completed.
  • Restore GEMINI_API_KEY when you deploy the Worker again.
  • Restore APP_TOKEN when you deploy the Worker again.

Delete - I don't want to use this again

Remove the deployed Worker and both local project folders. Copy any files you want to preserve before continuing.

macOS

  • Switch back to the terminal from earlier inside ai-action-server.
  • Delete the deployment and both local folders by running these commands:
npx wrangler delete
cd ..
rm -rf ai-action-server ai_action_bridge
ls
What Gets Deleted?
  • Wrangler removes the deployed Worker and its associated Cloudflare developer platform resources.
  • The folder removal clears the local Worker source and the local Flutter package.
  • The folder removal also clears any remaining .dev.vars or .env.production files inside ai-action-server.
  • Confirm the terminal reports that Worker deletion completed.
  • Check the final folder listing to confirm that ai-action-server is absent.
  • Check the final folder listing to confirm that ai_action_bridge is absent.

Windows

  • Switch back to the Command Prompt window from earlier inside ai-action-server.
  • Delete the deployment and both local folders by running these commands:
npx wrangler delete
cd ..
rmdir /s /q ai-action-server
rmdir /s /q ai_action_bridge
dir
What Gets Deleted?
  • Wrangler removes the deployed Worker and its associated Cloudflare developer platform resources.
  • The folder removal clears the local Worker source and the local Flutter package.
  • The folder removal also clears any remaining .dev.vars or .env.production files inside ai-action-server.
  • Confirm the terminal reports that Worker deletion completed.
  • Check the final folder listing to confirm that ai-action-server is absent.
  • Check the final folder listing to confirm that ai_action_bridge is absent.

Linux

  • Switch back to the terminal from earlier inside ai-action-server.
  • Delete the deployment and both local folders by running these commands:
npx wrangler delete
cd ..
rm -rf ai-action-server ai_action_bridge
ls
What Gets Deleted?
  • Wrangler removes the deployed Worker and its associated Cloudflare developer platform resources.
  • The folder removal clears the local Worker source and the local Flutter package.
  • The folder removal also clears any remaining .dev.vars or .env.production files inside ai-action-server.
  • Confirm the terminal reports that Worker deletion completed.
  • Check the final folder listing to confirm that ai-action-server is absent.
  • Check the final folder listing to confirm that ai_action_bridge is absent.

Nice Work!

Nice Work!

Mission complete! Your reusable Flutter package now turns CRM requests into approved local updates while a protected Cloudflare Worker handles Gemini planning.

You've learned how to:

  • Build a reusable action registry for Flutter. Each AiTool contributes a JSON Schema. It supplies a local callback. Its optional builder controls the confirmation UI.
  • Deploy a protected stateless Worker with Hono. Cloudflare secret bindings protect credentials. Bearer authentication restricts action routes. The Gemini Interactions REST API preserves the full function history with store:false.
  • Keep CRM mutation inside the Flutter app through confirmation-gated local execution. The matching callback runs only after approval. Gemini receives only the local result.
  • Secret Mission: Register add_contact_note beside update_contact_status. The second tool adds confirmed notes to the visible Notes list without changing lib/src.

Ready to quiz yourself?