glosings0n / codelabs / Full-Stack Agentic Dart: Genkit Dart & Shelf
45 min left
Step 1 of 8 | Context, Architecture & Prerequisites Last updated:

Full-Stack Agentic Dart: Building End-to-End Typed AI Agents with Genkit Dart & Shelf

1. Context & The "Why": Why Client-Embedded Cloud LLM Calls Fail in Production

When mobile teams prototype their first cloud-powered AI feature in Flutter, the most common shortcut is calling the Gemini REST SDK directly from the mobile binary. While convenient for a hackathon, shipping direct cloud LLM calls inside a production APK or IPA creates three critical engineering failures:

  1. Credential Extraction & Billing Abuse: Any API key compiled into a mobile binary or passed via --dart-define can be extracted via static decompilation (`strings`, `jadx`) or TLS proxy inspection (`mitmproxy`), exposing your cloud billing quota to unlimited abuse.
  2. Prompt Tampering & Bypassed Business Rules: If the mobile app constructs the system prompt or executes tool callbacks locally, a modified client can spoof tool outputs, bypass regional order quotas, or inject unauthorized discounts.
  3. Schema Drift Between Backend & Mobile: Historically, moving AI orchestration to the backend forced Flutter teams to maintain a Python or Node.js microservice, duplicating JSON serialization logic and breaking compile-time type safety across the wire.

Genkit Dart eliminates this trade-off. You write your AI flows, server-side tools, human-in-the-loop guardrails, and HTTP endpoints in 100% pure Dart on the server, and share the exact same schemantic data contracts with your Flutter client.

1. Contexte & Pourquoi : L'échec des appels LLM Cloud embarqués côté client

Lorsqu'une équipe mobile prototype sa première fonctionnalité IA Cloud dans Flutter, le raccourci le plus fréquent consiste à appeler l'API Gemini directement depuis le binaire mobile. Bien que pratique pour une démonstration rapide, embarquer des appels LLM Cloud directement dans un APK ou IPA de production entraîne trois failles majeures :

  1. Extraction de clés API & Abus de facturation : Toute clé API compilée dans un binaire mobile ou injectée via --dart-define peut être extraite par décompilation statique (`strings`, `jadx`) ou interception réseau (`mitmproxy`), exposant votre quota Cloud à des abus illimités.
  2. Altération des Prompts & Contournement des règles métier : Si l'application mobile construit le prompt système ou exécute les outils localement, un client modifié peut falsifier les réponses d'outils, contourner les quotas régionaux ou s'attribuer des remises non autorisées.
  3. Divergence de schémas entre Backend et Mobile : Historiquement, déporter l'orchestration IA côté serveur obligeait les équipes Flutter à maintenir un microservice Python ou Node.js, dupliquant la sérialisation JSON et brisant le typage fort de bout en bout.

Genkit Dart résout définitivement ce dilemme. Vous développez vos flows IA, vos outils serveur, vos garde-fous avec validation humaine et vos endpoints HTTP en 100% pur Dart côté serveur, tout en partageant exactement les mêmes contrats schemantic avec votre client Flutter.

One Language, Shared Schemas, Zero Drift

With schemantic + genkit + genkit_shelf on the backend and package:genkit/client.dart in Flutter, a single @Schema() definition validates LLM structured outputs on the server and deserializes streaming responses on the phone.

Un seul langage, des schémas partagés, zéro divergence

Avec schemantic + genkit + genkit_shelf côté serveur et package:genkit/client.dart dans Flutter, une seule définition @Schema() valide la sortie structurée du LLM sur le serveur et désérialise les flux en temps réel sur le téléphone.

2. Learning Goals & What You Will Build

In this 45-minute engineering codelab, you will build a complete B2B supply-chain compliance & order audit workspace (agentic_commerce_workspace) organized as a pure Dart & Flutter monorepo:

  • Shared Typed Contracts (packages/shared_contracts): Define compile-time and runtime JSON schemas using schemantic ($OrderAuditInput, $OrderAuditResult, $InventoryLookupInput, $PartnerDiscountInput) with field boundary constraints.
  • Genkit Dart Server & Structured Output Flows (packages/agent_server): Configure Genkit(plugins: [googleAI()]) with gemini-2.5-flash and enforce strict JSON schema conformance via outputSchema.
  • Authoritative Server-Side Tools: Register verifyInventoryAndQuota and applyPartnerDiscount via ai.defineTool so prices, stock levels, and regional quotas are resolved strictly on the server.
  • Human-in-the-Loop Guardrails (genkit_middleware): Intercept sensitive state-mutating tool calls with ToolApprovalPlugin(), handle FinishReason.interrupted, and resume generation safely via interruptRestart.
  • Real-Time Streaming & Shelf HTTP Server (genkit_shelf): Stream live audit progress chunks via context.sendChunk() while returning a final strongly typed OrderAuditResult, mounted onto a custom shelf_router pipeline.
  • Flutter Streaming Client (apps/flutter_client): Consume both unary and streaming remote flows using defineRemoteAction from package:genkit/client.dart.

2. Objectifs & Ce que vous allez construire

Au cours de ce codelab technique de 45 minutes, vous allez construire un système complet d'audit et de conformité de commandes B2B (agentic_commerce_workspace) structuré en monorepo Dart & Flutter :

  • Contrats Typés Partagés (packages/shared_contracts) : Définir des schémas JSON validés à la compilation et à l'exécution avec schemantic ($OrderAuditInput, $OrderAuditResult, $InventoryLookupInput, $PartnerDiscountInput).
  • Serveur Genkit Dart & Flows à Sortie Structurée (packages/agent_server) : Initialiser Genkit(plugins: [googleAI()]) avec gemini-2.5-flash et imposer un schéma de sortie strict via outputSchema.
  • Outils Serveur Autoritatifs : Enregistrer verifyInventoryAndQuota et applyPartnerDiscount avec ai.defineTool afin que les prix, les stocks et les quotas régionaux soient vérifiés exclusivement côté serveur.
  • Garde-fous Human-in-the-Loop (genkit_middleware) : Intercepter les appels d'outils sensibles avec ToolApprovalPlugin(), gérer l'état FinishReason.interrupted et reprendre l'exécution après approbation via interruptRestart.
  • Streaming Temps Réel & Serveur HTTP Shelf (genkit_shelf) : Diffuser la narration d'audit en direct via context.sendChunk() tout en retournant un objet final typé OrderAuditResult exposé sur shelf_router.
  • Client Flutter Streaming (apps/flutter_client) : Consommer les flows distants en mode unitaire et streaming avec defineRemoteAction de package:genkit/client.dart.
Architectural Layer Client-Direct LLM Anti-Pattern Full-Stack Genkit Dart + Shelf
API Key Custody Exposed inside APK/IPA binary 100% isolated in server environment
Contract Type Safety Unverified raw JSON maps Shared Schemantic classes (Server + Flutter)
Tool Execution Trust Untrusted client-side callbacks Authoritative server tools + HITL middleware
Observability & Tracing Blind console logs on user devices Full step-by-step spans in Genkit Dev UI

3. Prerequisites & System Requirements

Ensure your workstation meets the following requirements before starting Step 2:

  • Dart SDK: >=3.12.0 (or Flutter SDK >=3.29.0 bundling modern Dart).
  • Genkit CLI: Installed globally to launch the local reflection & trace inspector UI on port 4000.
  • Google AI Gemini API Key: Exported as the GEMINI_API_KEY environment variable on your server machine (never hardcoded in source files).

Run the pre-flight verification commands below in your terminal:

3. Prérequis & Configuration Système

Vérifiez que votre poste de travail respecte les prérequis suivants avant de passer à l'étape 2 :

  • SDK Dart : >=3.12.0 (ou SDK Flutter >=3.29.0 incluant Dart moderne).
  • Genkit CLI : Installé globalement pour lancer l'interface locale d'inspection des traces sur le port 4000.
  • Clé API Google AI Gemini : Définie dans la variable d'environnement GEMINI_API_KEY côté serveur (jamais écrite en dur dans le code source).

Exécutez les commandes de vérification pré-vol ci-dessous dans votre terminal :

Terminal | Pre-Flight Verification (macOS / Linux / Git Bash & Windows PowerShell) SHELL
# 1. Verify Dart and Flutter SDK versions
dart --version
flutter --version

# 2. Install the Genkit CLI globally (choose cURL or npm)
curl -sL cli.genkit.dev | bash
# OR via npm:
npm install -g genkit-cli

# 3. Verify Genkit CLI installation
genkit --version
Step 2 of 8 | Monorepo Architecture & Schemantic

Workspace Setup & Shared Typed Contracts with Schemantic

We will organize our project as a clean 3-package workspace (agentic_commerce_workspace) so that our Shelf backend and our Flutter application import the exact same schema definitions:

  • packages/shared_contracts: Pure Dart package containing our @Schema() definitions and generated .g.dart runtime JSON Schemas.
  • packages/agent_server: Dart backend powered by genkit, genkit_google_genai, genkit_middleware, and genkit_shelf.
  • apps/flutter_client: Cross-platform Flutter app consuming our server flows via package:genkit/client.dart.

1. Create the Monorepo Directory Structure

Run the commands below for your operating system to scaffold the workspace directories:

Nous allons organiser notre projet sous forme d'un workspace propre à 3 paquets (agentic_commerce_workspace) afin que notre backend Shelf et notre application Flutter importent exactement les mêmes définitions de schémas :

  • packages/shared_contracts : Paquet pur Dart contenant nos définitions @Schema() et les schémas JSON .g.dart générés.
  • packages/agent_server : Backend Dart propulsé par genkit, genkit_google_genai, genkit_middleware et genkit_shelf.
  • apps/flutter_client : Application Flutter multiplateforme consommant nos flows serveur via package:genkit/client.dart.

1. Créer l'arborescence du Monorepo

Exécutez les commandes ci-dessous selon votre système d'exploitation pour initialiser les dossiers du workspace :

Terminal | Create Workspace Folders (macOS / Linux / Git Bash & PowerShell) SHELL
# macOS / Linux / Git Bash
mkdir -p agentic_commerce_workspace/packages/shared_contracts/lib
mkdir -p agentic_commerce_workspace/packages/agent_server/lib/flows
mkdir -p agentic_commerce_workspace/packages/agent_server/lib/tools
mkdir -p agentic_commerce_workspace/packages/agent_server/bin
cd agentic_commerce_workspace

# Windows PowerShell
New-Item -ItemType Directory -Force -Path "agentic_commerce_workspace\packages\shared_contracts\lib"
New-Item -ItemType Directory -Force -Path "agentic_commerce_workspace\packages\agent_server\lib\flows"
New-Item -ItemType Directory -Force -Path "agentic_commerce_workspace\packages\agent_server\lib\tools"
New-Item -ItemType Directory -Force -Path "agentic_commerce_workspace\packages\agent_server\bin"
Set-Location agentic_commerce_workspace

2. Complete File: packages/shared_contracts/pubspec.yaml

The schemantic library binds strongly typed Dart classes to runtime JSON Schemas. In dev_dependencies, we include schemantic_builder and build_runner to generate the concrete implementation classes and static $schema descriptors:

2. Fichier complet : packages/shared_contracts/pubspec.yaml

La bibliothèque schemantic relie des classes Dart fortement typées à des schémas JSON d'exécution. Dans dev_dependencies, nous incluons schemantic_builder et build_runner pour générer les classes concrètes ainsi que les descripteurs statiques $schema :

packages/shared_contracts/pubspec.yaml YAML
name: shared_contracts
description: Shared Schemantic data contracts for Genkit server and Flutter client.
version: 1.0.0
publish_to: 'none'

environment:
  sdk: ^3.12.0

dependencies:
  schemantic: ^1.0.0

dev_dependencies:
  build_runner: ^2.16.2
  lints: ^6.1.0
  schemantic_builder: ^1.0.0

3. Complete File: packages/shared_contracts/lib/agent_contracts.dart

In schemantic, you annotate abstract schema blueprints with @Schema() and prefix the abstract class name with $ (for example, $OrderAuditInput). Field annotations like @IntegerField and @StringField embed validation boundaries (`minimum`, `maximum`, `enumValues`, `description`) directly into the JSON Schema sent to Gemini:

3. Fichier complet : packages/shared_contracts/lib/agent_contracts.dart

Avec schemantic, vous annotez vos classes abstraites avec @Schema() en préfixant leur nom par $ (par exemple $OrderAuditInput). Les annotations de champs telles que @IntegerField et @StringField intègrent directement les contraintes de validation (`minimum`, `maximum`, `enumValues`, `description`) dans le schéma JSON transmis à Gemini :

packages/shared_contracts/lib/agent_contracts.dart DART
import 'package:schemantic/schemantic.dart';

part 'agent_contracts.g.dart';

@Schema()
abstract class $OrderAuditInput {
  String get orderId;
  String get sku;

  @IntegerField(
    minimum: 1,
    description: 'Requested unit count for the wholesale order',
  )
  int get quantity;

  @StringField(
    minLength: 2,
    enumValues: ['EAST_AFRICA', 'CENTRAL_AFRICA', 'WEST_AFRICA'],
    description: 'Target fulfillment corridor',
  )
  String get region;

  String? get partnerCode;
}

@Schema()
abstract class $OrderAuditResult {
  String get orderId;
  bool get approved;

  @StringField(
    enumValues: ['APPROVED', 'HOLD_QUOTA_EXCEEDED', 'REJECTED_OUT_OF_STOCK'],
  )
  String get status;

  @IntegerField(
    minimum: 0,
    description: 'Verified units allocated from regional warehouse',
  )
  int get allocatedQuantity;

  double get finalTotalUsd;
  String get reasoningSummary;
}

@Schema()
abstract class $InventoryLookupInput {
  String get sku;
  String get region;

  @IntegerField(minimum: 1)
  int get requestedUnits;
}

@Schema()
abstract class $PartnerDiscountInput {
  String get orderId;
  String get partnerCode;

  @IntegerField(
    minimum: 1,
    maximum: 50,
    description: 'Requested partner discount percentage (1 to 50)',
  )
  int get requestedDiscountPct;
}

4. Generate the Concrete Classes & Runtime Schemas

Run build_runner inside packages/shared_contracts to generate agent_contracts.g.dart. This produces concrete classes (OrderAuditInput, OrderAuditResult, InventoryLookupInput, PartnerDiscountInput) with .fromJson(), .toJson(), and static .$schema getters:

4. Générer les classes concrètes & les schémas d'exécution

Exécutez build_runner dans packages/shared_contracts pour générer agent_contracts.g.dart. Cela produit les classes concrètes (OrderAuditInput, OrderAuditResult, InventoryLookupInput, PartnerDiscountInput) dotées de .fromJson(), .toJson() et du descripteur statique .$schema :

Terminal | Generate Schemantic .g.dart Files BASH
cd packages/shared_contracts
dart pub get
dart run build_runner build
dart analyze
cd ../..
Step 3 of 8 | Genkit Core & Constrained Generation

Initializing the Genkit Dart Server & Structured Output Flows

Instead of writing fragile regular expressions to strip Markdown fences from LLM responses, Genkit Dart's ai.defineFlow and ai.generate accept an outputSchema parameter backed by schemantic. Genkit passes the JSON Schema directly to Gemini's constrained decoding engine and parses the output into a strongly typed Dart instance via response.output.

1. Complete File: packages/agent_server/pubspec.yaml

Create packages/agent_server/pubspec.yaml referencing our local shared_contracts package alongside genkit, genkit_google_genai, genkit_middleware, and genkit_shelf:

Au lieu d'écrire des expressions régulières fragiles pour nettoyer les blocs Markdown des réponses d'un LLM, ai.defineFlow et ai.generate de Genkit Dart acceptent un paramètre outputSchema propulsé par schemantic. Genkit transmet le schéma JSON directement au moteur de décodage contraint de Gemini et convertit la réponse en une instance Dart fortement typée accessible via response.output.

1. Fichier complet : packages/agent_server/pubspec.yaml

Créez le fichier packages/agent_server/pubspec.yaml référençant notre paquet local shared_contracts ainsi que genkit, genkit_google_genai, genkit_middleware et genkit_shelf :

packages/agent_server/pubspec.yaml YAML
name: agent_server
description: Genkit Dart + Shelf Agentic Backend Server.
version: 1.0.0
publish_to: 'none'

environment:
  sdk: ^3.12.0

dependencies:
  genkit: ^1.0.1
  genkit_google_genai: ^1.0.1
  genkit_middleware: ^1.0.1
  genkit_shelf: ^1.0.1
  schemantic: ^1.0.0
  shared_contracts:
    path: ../shared_contracts
  shelf: ^1.4.2
  shelf_router: ^1.1.4

dev_dependencies:
  lints: ^6.1.0

2. Complete File: packages/agent_server/lib/flows/audit_flow.dart

Create packages/agent_server/lib/flows/audit_flow.dart. Notice how both the flow boundary (defineFlow) and the model invocation (ai.generate) use OrderAuditInput.$schema and OrderAuditResult.$schema:

2. Fichier complet : packages/agent_server/lib/flows/audit_flow.dart

Créez packages/agent_server/lib/flows/audit_flow.dart. Remarquez comment la déclaration du flow (defineFlow) et l'appel au modèle (ai.generate) utilisent tous deux OrderAuditInput.$schema et OrderAuditResult.$schema :

packages/agent_server/lib/flows/audit_flow.dart DART
import 'package:genkit/genkit.dart';
import 'package:genkit_google_genai/genkit_google_genai.dart';
import 'package:shared_contracts/agent_contracts.dart';

/// Defines a strictly typed order audit flow returning [OrderAuditResult].
Flow<OrderAuditInput, OrderAuditResult, void, void> defineAuditOrderFlow(
  Genkit ai,
) {
  return ai.defineFlow(
    name: 'auditOrder',
    inputSchema: OrderAuditInput.$schema,
    outputSchema: OrderAuditResult.$schema,
    fn: (OrderAuditInput input, _) async {
      final response = await ai.generate(
        model: googleAI.gemini('gemini-2.5-flash'),
        prompt: '''
You are a principal wholesale order compliance auditor.
Audit the following order request using the authoritative server tool `verifyInventoryAndQuota`:
- Order ID: ${input.orderId}
- SKU: ${input.sku}
- Requested Quantity: ${input.quantity}
- Target Corridor: ${input.region}
- Partner Code: ${input.partnerCode ?? 'NONE'}

Rules:
1. Always call `verifyInventoryAndQuota` first to obtain the authoritative stock, quota, and unit price.
2. If the SKU is out of stock (availableUnits < requestedUnits), set approved=false, status='REJECTED_OUT_OF_STOCK', allocatedQuantity=0, finalTotalUsd=0.
3. If requested quantity exceeds maxOrderQuota, set approved=false, status='HOLD_QUOTA_EXCEEDED', allocatedQuantity=maxOrderQuota, and compute finalTotalUsd for the capped allocation.
4. Otherwise set approved=true, status='APPROVED', allocatedQuantity=quantity, and compute finalTotalUsd = quantity * unitPriceUsd.
''',
        toolNames: ['verifyInventoryAndQuota'],
        outputSchema: OrderAuditResult.$schema,
      );

      final structuredOutput = response.output;
      if (structuredOutput == null) {
        throw StateError(
          'Model failed to produce a valid OrderAuditResult schema payload.',
        );
      }
      return structuredOutput;
    },
  );
}
Why Wrap Logic in ai.defineFlow?

Every Genkit Flow automatically registers with the local reflection server for interactive testing in the Genkit Developer UI, emits OpenTelemetry trace spans, validates incoming JSON against inputSchema, and exposes a clean handler for genkit_shelf.

Pourquoi encapsuler la logique dans ai.defineFlow ?

Chaque Flow Genkit s'enregistre automatiquement auprès du serveur de réflexion local pour être testé dans Genkit Developer UI, émet des traces télémétriques, valide le JSON entrant via inputSchema et s'expose directement sur HTTP via genkit_shelf.

Step 4 of 8 | Authoritative Tool Calling

Server-Side Tool Calling & Strict Security Invariants

When designing production AI agents for commerce, fintech, or logistics, you must enforce one non-negotiable architectural rule:

Lors de la conception d'agents IA de production pour le commerce, la fintech ou la logistique, vous devez appliquer une règle architecturale absolue :

The Server-Side Security Invariant

Never trust the client for prices, quotas, or verification statuses. Notice that OrderAuditInput only accepts orderId, sku, quantity, and region. The mobile client cannot pass a unit price or override a regional quota limit; our server-side Genkit tool resolves those figures directly from the authoritative server ledger before returning context to the model via ToolResult.response(...).

L'Invariant de Sécurité Côté Serveur

Ne faites jamais confiance au client pour les prix, les quotas ou les statuts de vérification. Remarquez que OrderAuditInput n'accepte que orderId, sku, quantity et region. Le client mobile ne peut ni imposer un prix unitaire ni contourner un quota régional ; notre outil Genkit côté serveur interroge directement le registre serveur autoritatif avant de renvoyer le résultat au modèle via ToolResult.response(...).

Complete File: packages/agent_server/lib/tools/inventory_tools.dart

In Genkit Dart, tools are registered with ai.defineTool and return a ToolResult (such as ToolResult.response(...) for normal execution or ToolResult.interrupt(...) to pause generation). Copy this complete file into packages/agent_server/lib/tools/inventory_tools.dart:

Fichier complet : packages/agent_server/lib/tools/inventory_tools.dart

Dans Genkit Dart, les outils sont déclarés via ai.defineTool et retournent un ToolResult (tel que ToolResult.response(...) pour une réponse normale ou ToolResult.interrupt(...) pour interrompre la boucle de génération). Copiez ce fichier complet dans packages/agent_server/lib/tools/inventory_tools.dart :

packages/agent_server/lib/tools/inventory_tools.dart DART
import 'package:genkit/genkit.dart';
import 'package:shared_contracts/agent_contracts.dart';

/// Authoritative server-side catalog and regional quota ledger.
/// The mobile client never sends prices or quota limits; the server resolves them.
const Map<String, Map<String, Object>> _serverInventoryCatalog = {
  'SOLAR-INV-3KW': {
    'unitPriceUsd': 420.0,
    'stockByRegion': {
      'EAST_AFRICA': 45,
      'CENTRAL_AFRICA': 18,
      'WEST_AFRICA': 30,
    },
    'maxOrderQuota': 20,
  },
  'LFP-BATT-5KWH': {
    'unitPriceUsd': 680.0,
    'stockByRegion': {
      'EAST_AFRICA': 12,
      'CENTRAL_AFRICA': 6,
      'WEST_AFRICA': 15,
    },
    'maxOrderQuota': 10,
  },
};

/// Registers authoritative inventory and partner discount tools on [ai].
void registerInventoryTools(Genkit ai) {
  ai.defineTool(
    name: 'verifyInventoryAndQuota',
    description:
        'Checks authoritative regional warehouse stock, unit price in USD, '
        'and maximum per-order quota for a given SKU and region.',
    inputSchema: InventoryLookupInput.$schema,
    fn: (InventoryLookupInput input, _) async {
      final skuRecord = _serverInventoryCatalog[input.sku.toUpperCase()];
      if (skuRecord == null) {
        return ToolResult.response({
          'found': false,
          'sku': input.sku,
          'error': 'SKU not found in authoritative catalog.',
        });
      }

      final stockMap = skuRecord['stockByRegion']! as Map<String, int>;
      final availableUnits = stockMap[input.region] ?? 0;
      final maxQuota = skuRecord['maxOrderQuota']! as int;
      final unitPriceUsd = skuRecord['unitPriceUsd']! as double;

      return ToolResult.response({
        'found': true,
        'sku': input.sku.toUpperCase(),
        'region': input.region,
        'availableUnits': availableUnits,
        'maxOrderQuota': maxQuota,
        'unitPriceUsd': unitPriceUsd,
        'withinQuota': input.requestedUnits <= maxQuota,
        'sufficientStock': input.requestedUnits <= availableUnits,
      });
    },
  );

  ai.defineTool(
    name: 'applyPartnerDiscount',
    description:
        'Applies a privileged partner discount percentage to an order. '
        'Sensitive state-mutating tool protected by human-in-the-loop approval.',
    inputSchema: PartnerDiscountInput.$schema,
    fn: (PartnerDiscountInput input, _) async {
      final cappedPct = input.requestedDiscountPct.clamp(1, 25);
      return ToolResult.response(
        'Partner discount of $cappedPct% authorized for order '
        '${input.orderId} under partner code ${input.partnerCode}.',
      );
    },
  );
}
Step 5 of 8 | Genkit Middleware & Interruptions

Agent Guardrails & Human-in-the-Loop toolApproval Middleware

Autonomous agents frequently mix read-only tools (like verifyInventoryAndQuota) with high-stakes state-mutating tools (like applyPartnerDiscount, issuing refunds, or modifying files). The official genkit_middleware package provides three composable plugins that you register on your Genkit instance:

  • FilesystemPlugin() + filesystem(rootDirectory: '...'): Sandboxes file reading, writing, and search-and-replace strictly inside an allowed workspace directory.
  • SkillsPlugin() + skills(skillPaths: ['...']): Dynamically loads specialized SKILL.md domain instructions on demand.
  • ToolApprovalPlugin() + toolApproval(approved: [...]): Intercepts any tool call NOT listed in approved, halts the LLM generation loop before the tool body runs, and returns FinishReason.interrupted.

Complete File: packages/agent_server/lib/flows/guarded_action_flow.dart

When response.finishReason == FinishReason.interrupted, you inspect response.interrupts.first, request supervisor confirmation, and resume the conversation by passing messages: response.messages and setting 'tool-approved': true inside interruptRestart:

Les agents autonomes combinent souvent des outils en lecture seule (comme verifyInventoryAndQuota) avec des outils critiques modifiant l'état (comme applyPartnerDiscount, le déclenchement d'un remboursement ou l'écriture de fichiers). Le paquet officiel genkit_middleware fournit trois plugins composables à enregistrer dans votre instance Genkit :

  • FilesystemPlugin() + filesystem(rootDirectory: '...') : Restreint la lecture, l'écriture et la recherche de fichiers à un répertoire racine sécurisé (sandbox).
  • SkillsPlugin() + skills(skillPaths: ['...']) : Injecte dynamiquement des instructions métier spécialisées depuis des fichiers SKILL.md.
  • ToolApprovalPlugin() + toolApproval(approved: [...]) : Intercepte tout appel d'outil non listé dans approved, met en pause la boucle du modèle avant l'exécution de l'outil et retourne FinishReason.interrupted.

Fichier complet : packages/agent_server/lib/flows/guarded_action_flow.dart

Lorsque response.finishReason == FinishReason.interrupted, vous inspectez response.interrupts.first, sollicitez la validation d'un superviseur, puis reprenez la génération en passant messages: response.messages et 'tool-approved': true dans interruptRestart :

packages/agent_server/lib/flows/guarded_action_flow.dart DART
import 'package:genkit/genkit.dart';
import 'package:genkit_google_genai/genkit_google_genai.dart';
import 'package:genkit_middleware/genkit_middleware.dart';
import 'package:schemantic/schemantic.dart';
import 'package:shared_contracts/agent_contracts.dart';

/// Defines a flow guarded by `toolApproval` middleware.
/// Read-only `verifyInventoryAndQuota` runs automatically, while
/// `applyPartnerDiscount` triggers `FinishReason.interrupted` for supervisor review.
Flow<OrderAuditInput, String, void, void> defineGuardedDiscountFlow(
  Genkit ai, {
  required Future<bool> Function(ToolRequestPart interrupt) onSupervisorReview,
}) {
  return ai.defineFlow(
    name: 'guardedDiscountAudit',
    inputSchema: OrderAuditInput.$schema,
    outputSchema: SchemanticType.string(),
    fn: (OrderAuditInput input, _) async {
      final response = await ai.generate(
        model: googleAI.gemini('gemini-2.5-flash'),
        prompt: '''
Verify inventory for Order ${input.orderId} (SKU: ${input.sku}, qty: ${input.quantity}, region: ${input.region}).
If partnerCode (${input.partnerCode ?? 'NONE'}) is present, call `applyPartnerDiscount` with a 15% partner discount.
''',
        toolNames: ['verifyInventoryAndQuota', 'applyPartnerDiscount'],
        use: [
          // Read-only lookup is pre-approved; state-mutating discount requires approval
          toolApproval(approved: ['verifyInventoryAndQuota']),
        ],
      );

      if (response.finishReason == FinishReason.interrupted) {
        final interrupt = response.interrupts.first;
        final isApproved = await onSupervisorReview(interrupt);

        if (!isApproved) {
          return 'Order ${input.orderId}: Partner discount tool call '
              '(${interrupt.toolRequest.name}) rejected by supervisor.';
        }

        final resumedResponse = await ai.generate(
          model: googleAI.gemini('gemini-2.5-flash'),
          messages: response.messages,
          toolChoice: ToolChoice.none,
          interruptRestart: [
            ToolRequestPart(
              toolRequest: interrupt.toolRequest,
              metadata: {
                ...?interrupt.metadata,
                'tool-approved': true,
              },
            ),
          ],
        );

        return resumedResponse.text;
      }

      return response.text;
    },
  );
}
Step 6 of 8 | Real-Time SSE Streaming & Shelf

Streaming Flows & HTTP Server with genkit_shelf

A great mobile UX gives users immediate visual feedback while an agent executes multi-step tool calls, and still delivers a strongly typed result at the end. Genkit Dart flows support dual-channel execution:

  • streamSchema: SchemanticType.string(): Defines the type of incremental chunks pushed to the client in real time via context.sendChunk(chunk.text).
  • outputSchema: OrderAuditResult.$schema: Defines the final strongly typed return value resolved when the flow finishes.

To expose flows over HTTP, genkit_shelf provides two patterns:

  1. Custom Shelf Router (shelfHandler(flow)): Mount flows onto specific routes alongside your existing authentication middleware, health checks, or REST endpoints.
  2. Standalone Server (startFlowServer): Boot a dedicated HTTP flow server in one function call.

Complete File: packages/agent_server/bin/server.dart

Copy this complete, runnable server entrypoint into packages/agent_server/bin/server.dart:

Une expérience mobile fluide offre à l'utilisateur un retour visuel immédiat pendant que l'agent exécute ses appels d'outils, tout en livrant un résultat fortement typé à la fin. Les flows Genkit Dart prennent en charge ce double canal nativement :

  • streamSchema: SchemanticType.string() : Définit le type des fragments envoyés en temps réel au client via context.sendChunk(chunk.text).
  • outputSchema: OrderAuditResult.$schema : Définit la valeur finale fortement typée retournée à la clôture du flow.

Pour exposer vos flows sur HTTP, genkit_shelf propose deux approches :

  1. Routeur Shelf personnalisé (shelfHandler(flow)) : Montez vos flows sur des routes dédiées aux côtés de vos middlewares d'authentification ou de vos sondes de santé (health checks).
  2. Serveur autonome (startFlowServer) : Démarrez un serveur HTTP dédié aux flows en un seul appel de fonction.

Fichier complet : packages/agent_server/bin/server.dart

Copiez ce point d'entrée serveur complet dans packages/agent_server/bin/server.dart :

packages/agent_server/bin/server.dart DART
import 'dart:io';
import 'package:genkit/genkit.dart';
import 'package:genkit_google_genai/genkit_google_genai.dart';
import 'package:genkit_middleware/genkit_middleware.dart';
import 'package:genkit_shelf/genkit_shelf.dart';
import 'package:schemantic/schemantic.dart';
import 'package:shared_contracts/agent_contracts.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as io;
import 'package:shelf_router/shelf_router.dart';

import 'package:agent_server/flows/audit_flow.dart';
import 'package:agent_server/flows/guarded_action_flow.dart';
import 'package:agent_server/tools/inventory_tools.dart';

Future<void> main() async {
  final apiKey = Platform.environment['GEMINI_API_KEY'];
  if (apiKey == null || apiKey.isEmpty) {
    stderr.writeln('ERROR: GEMINI_API_KEY environment variable is not set.');
    exit(1);
  }

  // 1. Initialize Genkit with Google AI and Middleware plugins
  final ai = Genkit(
    plugins: [
      googleAI(),
      FilesystemPlugin(),
      SkillsPlugin(),
      ToolApprovalPlugin(),
    ],
  );

  // 2. Register authoritative server-side tools
  registerInventoryTools(ai);

  // 3. Register typed and guarded flows
  final auditOrderFlow = defineAuditOrderFlow(ai);
  final guardedDiscountFlow = defineGuardedDiscountFlow(
    ai,
    onSupervisorReview: (interrupt) async {
      stdout.writeln(
        '[HITL Supervisor] Intercepted tool: ${interrupt.toolRequest.name} '
        'with input: ${interrupt.toolRequest.input}',
      );
      return true;
    },
  );

  // 4. Define a real-time streaming flow with chunked narration + final typed output
  final streamOrderAuditFlow = ai.defineFlow(
    name: 'streamOrderAudit',
    inputSchema: OrderAuditInput.$schema,
    outputSchema: OrderAuditResult.$schema,
    streamSchema: SchemanticType.string(),
    fn: (OrderAuditInput input, context) async {
      final stream = ai.generateStream(
        model: googleAI.gemini('gemini-2.5-flash'),
        prompt: '''
Stream a concise, real-time compliance log while auditing Order ${input.orderId}
(SKU: ${input.sku}, quantity: ${input.quantity}, corridor: ${input.region}).
Call `verifyInventoryAndQuota` to inspect stock and quota constraints.
''',
        toolNames: ['verifyInventoryAndQuota'],
      );

      await for (final chunk in stream) {
        if (chunk.text.isNotEmpty) {
          context.sendChunk(chunk.text);
        }
      }

      return auditOrderFlow(input);
    },
  );

  // 5. Mount Genkit flows onto a custom Shelf Router alongside health probes
  // (Alternatively, call `await startFlowServer(flows: [...], port: 3400);`)
  final router = Router()
    ..get('/healthz', (Request req) => Response.ok('{"status":"ok"}'))
    ..post('/auditOrder', shelfHandler(auditOrderFlow))
    ..post('/streamOrderAudit', shelfHandler(streamOrderAuditFlow))
    ..post('/guardedDiscountAudit', shelfHandler(guardedDiscountFlow));

  final handler = const Pipeline()
      .addMiddleware(logRequests())
      .addHandler(router.call);

  final port = int.parse(Platform.environment['PORT'] ?? '3400');
  final server = await io.serve(handler, InternetAddress.anyIPv4, port);
  stdout.writeln(
    'Genkit Shelf Agent Server listening on http://${server.address.host}:${server.port}',
  );
}
Step 7 of 8 | Flutter Client Integration

Consuming Remote Typed & Streaming Flows in Flutter

On the Flutter side, you do NOT import server plugins or bundle API keys. Instead, you import lightweight client utilities from package:genkit/client.dart and your shared package:shared_contracts/agent_contracts.dart.

With defineRemoteAction, calling a remote Genkit flow over HTTP feels identical to calling a local Dart function:

  • Unary Invocation: final result = await auditOrderAction(input: request);
  • Streaming Invocation: final stream = streamOrderAuditAction.stream(input: request); iterates over real-time String chunks as they arrive, and await stream.onResult resolves the final typed OrderAuditResult!

1. Complete File: apps/flutter_client/pubspec.yaml

Create the Flutter client package configuration in apps/flutter_client/pubspec.yaml:

Côté Flutter, vous n'importez AUCUN plugin serveur et n'embarquez aucune clé API. Vous importez uniquement le client léger package:genkit/client.dart ainsi que votre paquet partagé package:shared_contracts/agent_contracts.dart.

Grâce à defineRemoteAction, l'appel d'un flow Genkit distant sur HTTP s'écrit exactement comme un appel de fonction Dart locale :

  • Appel Unitaire : final result = await auditOrderAction(input: request);
  • Appel Streaming : final stream = streamOrderAuditAction.stream(input: request); écoute les fragments String en temps réel, puis await stream.onResult récupère l'objet final typé OrderAuditResult !

1. Fichier complet : apps/flutter_client/pubspec.yaml

Créez le fichier de configuration Flutter dans apps/flutter_client/pubspec.yaml :

apps/flutter_client/pubspec.yaml YAML
name: flutter_client
description: Flutter streaming client for Genkit Dart Shelf server.
version: 1.0.0
publish_to: 'none'

environment:
  sdk: ^3.12.0

dependencies:
  flutter:
    sdk: flutter
  genkit: ^1.0.1
  schemantic: ^1.0.0
  shared_contracts:
    path: ../../packages/shared_contracts

dev_dependencies:
  flutter_lints: ^5.0.0
  flutter_test:
    sdk: flutter

2. Complete File: apps/flutter_client/lib/main.dart

Copy this complete, self-contained Material 3 Flutter client into apps/flutter_client/lib/main.dart. Note that on an Android emulator, localhost on the host machine is reachable at 10.0.2.2:3400 (or use adb reverse tcp:3400 tcp:3400 to use localhost:3400 directly on physical USB devices):

2. Fichier complet : apps/flutter_client/lib/main.dart

Copiez ce client Flutter Material 3 complet et autonome dans apps/flutter_client/lib/main.dart. Notez que sur un émulateur Android, le localhost de la machine hôte est accessible via 10.0.2.2:3400 (ou exécutez adb reverse tcp:3400 tcp:3400 pour utiliser localhost:3400 directement sur un téléphone physique en USB) :

apps/flutter_client/lib/main.dart DART / FLUTTER
import 'package:flutter/material.dart';
import 'package:genkit/client.dart';
import 'package:schemantic/schemantic.dart';
import 'package:shared_contracts/agent_contracts.dart';

void main() {
  runApp(const AgenticAuditApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Genkit Dart Agentic Auditor',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF0284C7),
        useMaterial3: true,
      ),
      home: const OrderAuditScreen(),
    );
  }
}

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

  @override
  State<OrderAuditScreen> createState() => _OrderAuditScreenState();
}

class _OrderAuditScreenState extends State<OrderAuditScreen> {
  static const String _serverBaseUrl = String.fromEnvironment(
    'GENKIT_SERVER_URL',
    defaultValue: 'http://localhost:3400',
  );

  late final _streamOrderAuditAction = defineRemoteAction(
    url: '$_serverBaseUrl/streamOrderAudit',
    inputSchema: OrderAuditInput.$schema,
    outputSchema: OrderAuditResult.$schema,
    streamSchema: SchemanticType.string(),
  );

  String _selectedSku = 'SOLAR-INV-3KW';
  String _selectedRegion = 'EAST_AFRICA';
  int _quantity = 12;
  bool _isStreaming = false;
  String _liveNarration = '';
  OrderAuditResult? _finalResult;
  String? _errorMessage;

  Future<void> _runStreamingAudit() async {
    setState(() {
      _isStreaming = true;
      _liveNarration = '';
      _finalResult = null;
      _errorMessage = null;
    });

    final requestInput = OrderAuditInput(
      orderId: 'ORD-${DateTime.now().millisecondsSinceEpoch % 100000}',
      sku: _selectedSku,
      quantity: _quantity,
      region: _selectedRegion,
      partnerCode: 'SOLAR-PARTNER-UG',
    );

    try {
      final stream = _streamOrderAuditAction.stream(input: requestInput);

      await for (final chunk in stream) {
        if (!mounted) return;
        setState(() {
          _liveNarration += chunk;
        });
      }

      final typedResult = await stream.onResult;
      if (!mounted) return;
      setState(() {
        _finalResult = typedResult;
      });
    } catch (e) {
      if (!mounted) return;
      setState(() {
        _errorMessage = e.toString();
      });
    } finally {
      if (mounted) {
        setState(() {
          _isStreaming = false;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final result = _finalResult;
    return Scaffold(
      appBar: AppBar(
        title: const Text('Genkit Dart | Full-Stack Order Auditor'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Row(
              children: [
                Expanded(
                  child: DropdownButtonFormField<String>(
                    initialValue: _selectedSku,
                    decoration: const InputDecoration(labelText: 'Catalog SKU'),
                    items: const [
                      DropdownMenuItem(
                        value: 'SOLAR-INV-3KW',
                        child: Text('SOLAR-INV-3KW (3kW Inverter)'),
                      ),
                      DropdownMenuItem(
                        value: 'LFP-BATT-5KWH',
                        child: Text('LFP-BATT-5KWH (5kWh Battery)'),
                      ),
                    ],
                    onChanged: _isStreaming
                        ? null
                        : (v) => setState(() => _selectedSku = v!),
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: DropdownButtonFormField<String>(
                    initialValue: _selectedRegion,
                    decoration: const InputDecoration(labelText: 'Corridor'),
                    items: const [
                      DropdownMenuItem(
                        value: 'EAST_AFRICA',
                        child: Text('EAST_AFRICA'),
                      ),
                      DropdownMenuItem(
                        value: 'CENTRAL_AFRICA',
                        child: Text('CENTRAL_AFRICA'),
                      ),
                      DropdownMenuItem(
                        value: 'WEST_AFRICA',
                        child: Text('WEST_AFRICA'),
                      ),
                    ],
                    onChanged: _isStreaming
                        ? null
                        : (v) => setState(() => _selectedRegion = v!),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Row(
              children: [
                Expanded(
                  child: Slider(
                    value: _quantity.toDouble(),
                    min: 1,
                    max: 30,
                    divisions: 29,
                    label: '$_quantity units',
                    onChanged: _isStreaming
                        ? null
                        : (v) => setState(() => _quantity = v.round()),
                  ),
                ),
                Text(
                  'Qty: $_quantity',
                  style: const TextStyle(fontWeight: FontWeight.w700),
                ),
              ],
            ),
            const SizedBox(height: 8),
            FilledButton(
              onPressed: _isStreaming ? null : _runStreamingAudit,
              child: Text(
                _isStreaming
                    ? 'Streaming Server Audit...'
                    : 'Run Typed Genkit Audit Flow',
              ),
            ),
            const SizedBox(height: 16),
            if (_errorMessage != null)
              Container(
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: const Color(0xFFFEE2E2),
                  border: Border.all(color: const Color(0xFFDC2626)),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(
                  _errorMessage!,
                  style: const TextStyle(color: Color(0xFF991B1B)),
                ),
              ),
            Expanded(
              child: SingleChildScrollView(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    if (_liveNarration.isNotEmpty) ...[
                      const Text(
                        'Live Stream Chunks (streamSchema: String):',
                        style: TextStyle(fontWeight: FontWeight.bold),
                      ),
                      const SizedBox(height: 6),
                      SelectableText(_liveNarration),
                      const Divider(height: 28),
                    ],
                    if (result != null)
                      Container(
                        padding: const EdgeInsets.all(14),
                        decoration: BoxDecoration(
                          color: const Color(0xFFE0F2FE),
                          border: Border.all(color: const Color(0xFF0284C7)),
                          borderRadius: BorderRadius.circular(10),
                        ),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              'Typed OrderAuditResult (${result.status})',
                              style: const TextStyle(
                                fontWeight: FontWeight.w700,
                                fontSize: 16,
                              ),
                            ),
                            const SizedBox(height: 6),
                            Text('Order ID: ${result.orderId}'),
                            Text('Approved: ${result.approved}'),
                            Text('Allocated Units: ${result.allocatedQuantity}'),
                            Text(
                              'Authoritative Total: \$${result.finalTotalUsd.toStringAsFixed(2)} USD',
                            ),
                            const SizedBox(height: 6),
                            Text(result.reasoningSummary),
                          ],
                        ),
                      ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
Step 8 of 8 | Observability, Verification & Resources

Genkit Developer UI Tracing, Verification Checklist & Official Resources

1. End-to-End Verification Checklist & Genkit Developer UI

When you launch your Dart server through genkit start, the Genkit CLI sets GENKIT_ENV=dev, connects to the reflection server built into Genkit(), and serves the interactive Genkit Developer UI at http://localhost:4000. From there, you can trigger auditOrder, streamOrderAudit, and guardedDiscountAudit with sample JSON payloads and inspect every prompt, tool call, and latency span.

Run the verification sequence below in your terminal to confirm zero static analysis warnings and launch the server:

1. Checklist de vérification finale & Genkit Developer UI

Lorsque vous démarrez votre serveur Dart via genkit start, le CLI Genkit active GENKIT_ENV=dev, se connecte au serveur de réflexion intégré à Genkit() et démarre l'interface interactive Genkit Developer UI sur http://localhost:4000. Vous pouvez y tester auditOrder, streamOrderAudit et guardedDiscountAudit et inspecter chaque prompt, appel d'outil et temps de latence.

Exécutez la séquence de vérification ci-dessous dans votre terminal pour valider l'analyse statique et lancer le serveur :

Terminal | Static Analysis & Launch with Genkit Developer UI SHELL
# 1. Verify shared contracts package
cd packages/shared_contracts
dart analyze

# 2. Verify Genkit Shelf server package
cd ../agent_server
dart pub get
dart analyze

# 3. Export your Gemini API key (macOS / Linux / Git Bash)
export GEMINI_API_KEY="YOUR_GEMINI_API_KEY"
# Or in Windows PowerShell:
# $env:GEMINI_API_KEY="YOUR_GEMINI_API_KEY"

# 4. Start the Dart server attached to the Genkit Developer UI (http://localhost:4000)
genkit start -- dart run bin/server.dart

# 5. In a second terminal, verify and run the Flutter client
cd ../../apps/flutter_client
flutter pub get
flutter analyze
flutter run

2. Key Engineering Takeaways

  1. Single-Language Full-Stack Velocity: Sharing a pure Dart schemantic package across your Shelf server and Flutter client eliminates manual DTO synchronization and runtime casting errors.
  2. Authoritative Server-Side Tools: Keep API keys, pricing catalogs, and quota ledgers exclusively inside server-side ai.defineTool handlers returning ToolResult.response(...).
  3. Deterministic Guardrails with toolApproval: Never let an LLM execute state-mutating tools unchecked; use ToolApprovalPlugin() to trigger FinishReason.interrupted and require explicit supervisor sign-off before resuming with interruptRestart.
  4. Dual-Channel Streaming UX: Combine streamSchema + context.sendChunk() for sub-second streaming progress with outputSchema + stream.onResult for a guaranteed structured final payload.

2. Points clés à retenir

  1. Vélocité Full-Stack en un seul langage : Partager un paquet pur Dart schemantic entre votre serveur Shelf et votre client Flutter élimine toute désynchronisation de DTO et les erreurs de typage à l'exécution.
  2. Outils Serveur Autoritatifs : Conservez les clés API, les catalogues de prix et les quotas exclusivement dans des gestionnaires ai.defineTool côté serveur retournant ToolResult.response(...).
  3. Garde-fous déterministes avec toolApproval : Ne laissez jamais un LLM exécuter des outils modifiant l'état sans contrôle ; utilisez ToolApprovalPlugin() pour déclencher FinishReason.interrupted et exiger une validation explicite avant reprise via interruptRestart.
  4. Expérience Streaming à double canal : Combinez streamSchema + context.sendChunk() pour l'affichage en direct et outputSchema + stream.onResult pour garantir un résultat final structuré.

3. Official Documentation & Further Resources

3. Ressources officielles & Pour aller plus loin

Built for the Dart & Flutter Engineering Community

Authored by Georges Byona (@glosings0n), Founder @ LosingTek & Founder of the FlutterFire Summer Camp.

Conçu pour la communauté d'ingénierie Dart & Flutter

Rédigé par Georges Byona (@glosings0n), Fondateur @ LosingTek & Fondateur du FlutterFire Summer Camp.