<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Esra Kadah</title>
    <description>The latest articles on DEV Community by Esra Kadah (@esratech).</description>
    <link>https://dev.to/esratech</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4083780%2Ffc71c670-6e69-4d12-9bf6-ca288c135ddd.jpg</url>
      <title>DEV Community: Esra Kadah</title>
      <link>https://dev.to/esratech</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/esratech"/>
    <language>en</language>
    <item>
      <title>I approved the order. Then my Genkit Dart agent forgot its own tool.</title>
      <dc:creator>Esra Kadah</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:54:50 +0000</pubDate>
      <link>https://dev.to/gde/i-approved-the-order-then-my-genkit-dart-agent-forgot-its-own-tool-3mjp</link>
      <guid>https://dev.to/gde/i-approved-the-order-then-my-genkit-dart-agent-forgot-its-own-tool-3mjp</guid>
      <description>&lt;p&gt;My agent asked me "can I order this part?". I said yes. And the answer came back as a failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;finishReason: FinishReason.failed
finishMessage: Tool order_part not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool was right there. It had been found one step earlier, when the agent stopped to ask for approval. 🫠&lt;/p&gt;

&lt;p&gt;I was building a dispatch assistant in pure Dart for a small, made-up field service company (&lt;a href="https://dev.to/gde/human-in-the-loop-agents-in-pure-dart-genkit-mcp-and-an-approval-gate-on-cloud-run-4gmc"&gt;full write-up here&lt;/a&gt;). Looking up a work order is free, booking a technician is free, but &lt;code&gt;order_part&lt;/code&gt; costs money, so it waits for a person. It took me an evening to find out why the "yes" broke it, and the fix turned into my first PR to Genkit Dart. 🥳&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup: MCP tools behind an approval
&lt;/h2&gt;

&lt;p&gt;The agent gets its tools from an MCP server and puts &lt;code&gt;toolApproval&lt;/code&gt; middleware in front of them. Simplified from my agent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;mcpTools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;mcpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getActiveTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// includes `work-orders/order_part`&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;model:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;prompt:&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;tools:&lt;/span&gt; &lt;span class="n"&gt;mcpTools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;use:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;toolApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;approved:&lt;/span&gt; &lt;span class="n"&gt;runWithoutApproval&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;finishReason&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;FinishReason&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interrupted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Hold the interrupt, show the dispatcher an approve / decline card.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;runWithoutApproval&lt;/code&gt; lists every tool except &lt;code&gt;order_part&lt;/code&gt;, so lookups and bookings run straight away.&lt;/p&gt;

&lt;p&gt;When the dispatcher approves, I restart the held tool call (&lt;code&gt;held&lt;/code&gt; is what I stored when the card was shown):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;resumed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;model:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;messages:&lt;/span&gt; &lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;tools:&lt;/span&gt; &lt;span class="n"&gt;mcpTools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;use:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;toolApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;approved:&lt;/span&gt; &lt;span class="n"&gt;runWithoutApproval&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="nl"&gt;interruptRestart:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interrupt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;'tool-approved'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;})],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second call is the one that failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the tool vanished only on restart
&lt;/h2&gt;

&lt;p&gt;MCP tools are registered under a namespaced name: &lt;code&gt;work-orders/order_part&lt;/code&gt;. But that is not the name the model sees. When Genkit sends tool definitions to the model, &lt;code&gt;toToolDefinition&lt;/code&gt; shortens the name to its last segment, &lt;code&gt;order_part&lt;/code&gt;, and keeps the full name in &lt;code&gt;metadata.originalName&lt;/code&gt;. So the model calls &lt;code&gt;order_part&lt;/code&gt;, the name it was given.&lt;/p&gt;

&lt;p&gt;Genkit handles that with a second lookup. &lt;code&gt;_resolveTools&lt;/code&gt; builds a &lt;code&gt;toolMap&lt;/code&gt; keyed by the short name, and &lt;code&gt;_executeTools&lt;/code&gt; checks it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;toolMap['order_part']  →  work-orders/order_part  ✅
registry['order_part'] →  nothing                 ❌
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The normal tool loop passes &lt;code&gt;toolMap&lt;/code&gt;. The restart path in &lt;code&gt;package:genkit&lt;/code&gt; 1.0.1 did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/genkit/lib/src/ai/generate.dart, coreGenerate, excerpt (1.0.1)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resumeRestart&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_assertResumable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;execution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_executeTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;generateRegistry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resumeRestart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ToolRequestPart&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;cancel:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;middleware:&lt;/span&gt; &lt;span class="n"&gt;resolvedMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;toolMap&lt;/code&gt;. So &lt;code&gt;_executeTools&lt;/code&gt; fell back to a plain registry lookup for &lt;code&gt;order_part&lt;/code&gt;, and the registry only knows &lt;code&gt;work-orders/order_part&lt;/code&gt;. That is the whole bug.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7sf7wsboibupysi8k0e.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq7sf7wsboibupysi8k0e.png" alt="Same tool call, two lookup paths" width="800" height="490"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In practice: a namespaced MCP tool works fine until you put an approval in front of it and restart it. The dispatcher says yes, and nothing gets ordered.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Resolve the tools on the restart path too, and pass the map along:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// coreGenerate, excerpt (PR #658)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resumeRestart&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_assertResumable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;restartTools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_resolveTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;generateRegistry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resolvedMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;execution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_executeTools&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;restartTools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;resumeRestart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cast&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ToolRequestPart&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toList&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;cancel:&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;middleware:&lt;/span&gt; &lt;span class="n"&gt;resolvedMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;toolMap:&lt;/span&gt; &lt;span class="n"&gt;restartTools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toolMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While I was in there I noticed a second, smaller problem in the same block. When a restarted tool's result went back into the history, only &lt;code&gt;output&lt;/code&gt; was copied. A tool that returned &lt;code&gt;content&lt;/code&gt; (text or image parts) or &lt;code&gt;metadata&lt;/code&gt; lost it on restart. The PR now copies both. That part has no test of its own yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test
&lt;/h2&gt;

&lt;p&gt;The regression test needs no API key. A fake model asks for the short name, the namespaced tool interrupts once, and the restart has to find it. A trimmed excerpt (the fake model stores the tool output it receives in &lt;code&gt;observedToolOutput&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;genkit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defineTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'work-orders/order_part'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;description:&lt;/span&gt; &lt;span class="s"&gt;'Orders a spare part'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;inputSchema:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dynamicSchema&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="nl"&gt;fn:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;toolCallCount&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toolCallCount&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interrupt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'CONFIRM_ORDER'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'OrderedPart'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;genkit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;model:&lt;/span&gt; &lt;span class="n"&gt;modelRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// fake model, calls `order_part`&lt;/span&gt;
  &lt;span class="nl"&gt;prompt:&lt;/span&gt; &lt;span class="s"&gt;'order part'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;toolNames:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'work-orders/order_part'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;finishReason&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FinishReason&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interrupted&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="n"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;role:&lt;/span&gt; &lt;span class="n"&gt;Role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;content:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;TextPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;text:&lt;/span&gt; &lt;span class="s"&gt;'order part'&lt;/span&gt;&lt;span class="p"&gt;)]),&lt;/span&gt;
  &lt;span class="n"&gt;response1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;genkit&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;model:&lt;/span&gt; &lt;span class="n"&gt;modelRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modelName&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nl"&gt;messages:&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;toolNames:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;'work-orders/order_part'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nl"&gt;interruptRestart:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;response1&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="o"&gt;!.&lt;/span&gt;&lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toolRequestPart&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;toolCallCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;observedToolOutput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'OrderedPart'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before the fix, the restart came back as &lt;code&gt;FinishReason.failed&lt;/code&gt; with "Tool order_part not found" (the repro in #657). After it, the tool runs a second time and the model gets &lt;code&gt;OrderedPart&lt;/code&gt; back. The full test is in &lt;code&gt;packages/genkit/test/ai/interrupt_test.dart&lt;/code&gt; in the PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  Until it ships
&lt;/h2&gt;

&lt;p&gt;The PR is open: &lt;a href="https://github.com/genkit-ai/genkit-dart/pull/658" rel="noopener noreferrer"&gt;genkit-ai/genkit-dart#658&lt;/a&gt;, with the repro in &lt;a href="https://github.com/genkit-ai/genkit-dart/issues/657" rel="noopener noreferrer"&gt;#657&lt;/a&gt;. The Google CLA check passes; the repo's Dart and Flutter CI are waiting for a maintainer to approve their first run, which GitHub requires for first-time contributors.&lt;/p&gt;

&lt;p&gt;Until then, my workaround is a local tool under the bare name that forwards to the MCP one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;mcpOrderPart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mcpTools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;singleWhere&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'/order_part'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;orderPart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defineTool&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PartOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'order_part'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;description:&lt;/span&gt; &lt;span class="n"&gt;mcpOrderPart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'Orders a spare part for one work order.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;inputSchema:&lt;/span&gt; &lt;span class="n"&gt;PartOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;fn:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${(await mcpOrderPart.call(input.toJson())).output}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mcpTools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;mcpOrderPart&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;orderPart&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP server still owns the logic, and the adapter goes away once the fix is released.&lt;/p&gt;

&lt;p&gt;The lesson I am keeping: &lt;strong&gt;test the resume path, not only the happy path.&lt;/strong&gt; My agent passed every first run. It only broke after somebody said yes. 🙈&lt;/p&gt;

&lt;p&gt;Are you using interrupts or approvals in your agents yet? Does your test suite actually cover what happens after the human clicks approve? 👇&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Human-in-the-Loop Agents in Pure Dart: Genkit, MCP and an Approval Gate on Cloud Run</title>
      <dc:creator>Esra Kadah</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:53:26 +0000</pubDate>
      <link>https://dev.to/gde/human-in-the-loop-agents-in-pure-dart-genkit-mcp-and-an-approval-gate-on-cloud-run-4gmc</link>
      <guid>https://dev.to/gde/human-in-the-loop-agents-in-pure-dart-genkit-mcp-and-an-approval-gate-on-cloud-run-4gmc</guid>
      <description>&lt;h2&gt;
  
  
  1. The problem: an agent that can spend money
&lt;/h2&gt;

&lt;p&gt;Most agent demos stop at "the model called a tool and it worked". Real back offices have tools you do not want a model to run on its own.&lt;/p&gt;

&lt;p&gt;I built a dispatch assistant for a small, made-up field service company. A dispatcher types &lt;em&gt;"What is the status of WO-1043?"&lt;/em&gt; or &lt;em&gt;"Order a 3 kW heating element for WO-1044."&lt;/em&gt; and the agent answers. It has three tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;lookup_work_order&lt;/code&gt; reads the site, asset, issue, technician and slot. ✅ Runs on its own.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;schedule_visit&lt;/code&gt; books a technician for a window. ✅ Runs on its own.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;order_part&lt;/code&gt; orders a spare part, and that &lt;strong&gt;costs money&lt;/strong&gt;. ❌ Waits for a person.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And I wanted the whole thing in Dart: the agent server, the tool server and the Flutter client, sharing one set of types. No Python sidecar, no Node service in the middle.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Architecture: the agent only knows the back office through MCP
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mmlmlghltd4dc4k3naq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3mmlmlghltd4dc4k3naq.png" alt="Architecture: HTTP into the dispatchAgent Genkit flow, which calls Gemini; the toolApproval middleware holds order_part for a person; tools come through the MCP client from the work-orders MCP server (lookup_work_order and schedule_visit run on their own, order_part costs money)" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four pieces, all Dart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The back office as an MCP server&lt;/strong&gt; (&lt;code&gt;genkit_mcp&lt;/code&gt;, Streamable HTTP on loopback). Any MCP client can use it: this agent, an IDE, another agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent as one Genkit flow&lt;/strong&gt; (&lt;code&gt;genkit&lt;/code&gt;, &lt;code&gt;genkit_google_genai&lt;/code&gt;) that connects to that MCP server and gets its tools from it. It never imports the back office directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An approval gate&lt;/strong&gt; (&lt;code&gt;genkit_middleware&lt;/code&gt;'s &lt;code&gt;toolApproval&lt;/code&gt;) in front of every tool that costs money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The HTTP surface&lt;/strong&gt; (&lt;code&gt;genkit_shelf&lt;/code&gt;): one &lt;code&gt;POST /dispatchAgent&lt;/code&gt; plus a health check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why MCP for a back office that lives in the same process? Because the boundary is the point. The day the work orders move to a real system, only the MCP server changes. The agent, its prompt and its tests do not.&lt;/p&gt;

&lt;p&gt;Exposing the back office is a few lines per tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defineTool&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PartOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="n"&gt;orderPartToolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;description:&lt;/span&gt;
      &lt;span class="s"&gt;'Orders a spare part for one work order, which costs money, and marks the order waiting for parts. '&lt;/span&gt;
      &lt;span class="s"&gt;'Returns the updated work order as JSON.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;inputSchema:&lt;/span&gt; &lt;span class="n"&gt;PartOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;fn:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;describeWorkOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workOrders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderPart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kn"&gt;part&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;part&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;createMcpServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;McpServerOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'work-orders'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;version:&lt;/span&gt; &lt;span class="s"&gt;'0.2.0'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;StreamableHttpServerTransport&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;address:&lt;/span&gt; &lt;span class="n"&gt;InternetAddress&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;loopbackIPv4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;port:&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the tool description says &lt;em&gt;"which costs money"&lt;/em&gt;. The model reads that too. 😉&lt;/p&gt;

&lt;h2&gt;
  
  
  3. One schema, three runtimes
&lt;/h2&gt;

&lt;p&gt;The types are &lt;code&gt;schemantic&lt;/code&gt; classes in one file with no &lt;code&gt;dart:io&lt;/code&gt; import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Schema&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;abstract&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="nc"&gt;DispatchAnswer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;/// The work order ids the agent looked up to answer, so the client can link them.&lt;/span&gt;
  &lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;orderIds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;/// Set when the agent stopped before a tool that needs a person's OK; send it back with `approved`.&lt;/span&gt;
  &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="kd"&gt;get&lt;/span&gt; &lt;span class="n"&gt;approvalId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;DispatchAnswer&lt;/code&gt; is the model's structured output schema, the flow's HTTP response, and the type the Flutter client deserialises through &lt;code&gt;package:genkit/client.dart&lt;/code&gt;. Because the file avoids &lt;code&gt;dart:io&lt;/code&gt;, the client builds for web, iOS and macOS from the same import. Change a field once, &lt;code&gt;build_runner&lt;/code&gt; regenerates, and the compiler tells you everywhere it matters. That is the full-stack Dart pitch in one file. 💙&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The approval gate: interrupt, hold, restart
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;toolApproval&lt;/code&gt; takes the list of tools that may run without asking. Everything else interrupts generation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The MCP client lists `work-orders/&amp;lt;name&amp;gt;`, but the model calls the bare `&amp;lt;name&amp;gt;`; allow both spellings.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;runWithoutApproval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;orderPart&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;...[&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bareToolName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;model:&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;messages:&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;tools:&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;maxTurns:&lt;/span&gt; &lt;span class="n"&gt;maxAgentTurns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;outputSchema:&lt;/span&gt; &lt;span class="n"&gt;DispatchAnswer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;use:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;toolApproval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;approved:&lt;/span&gt; &lt;span class="n"&gt;runWithoutApproval&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
  &lt;span class="nl"&gt;interruptRestart:&lt;/span&gt; &lt;span class="n"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;interruptRespond:&lt;/span&gt; &lt;span class="n"&gt;respond&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the model asks for &lt;code&gt;order_part&lt;/code&gt;, the response comes back with &lt;code&gt;finishReason == FinishReason.interrupted&lt;/code&gt;. The flow stores the conversation so far plus the interrupted tool request under an &lt;code&gt;approvalId&lt;/code&gt; and answers the client right away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Ordering &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;Heating element 3 kW&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; for WO-1044 needs your approval."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"orderIds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"WO-1044"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"approvalId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"approval-1"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Flutter client shows &lt;strong&gt;Approve&lt;/strong&gt; and &lt;strong&gt;Decline&lt;/strong&gt;. The next call sends the &lt;code&gt;approvalId&lt;/code&gt; back with &lt;code&gt;approved&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
    &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nl"&gt;messages:&lt;/span&gt; &lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;restart:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interrupt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="s"&gt;'tool-approved'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;})],&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;messages:&lt;/span&gt; &lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;respond:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;InterruptResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;held&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;interrupt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;declinedToolOutput&lt;/span&gt;&lt;span class="p"&gt;)]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Approve&lt;/strong&gt; restarts the held call: the tool runs, the model answers from its real result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decline&lt;/strong&gt; never runs the tool. The model gets &lt;em&gt;"Declined by the dispatcher. Nothing was ordered."&lt;/em&gt; as the tool output and explains that to the user in its own words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Each &lt;code&gt;approvalId&lt;/code&gt; answers once.&lt;/strong&gt; It is removed from the pending map on first use, so a double tap cannot order two heating elements. 🙈&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Held approvals live in memory. That is a deliberate choice for a demo on Cloud Run with &lt;code&gt;--max-instances 1&lt;/code&gt;: one instance, one map. A restart forgets them, and anything beyond a demo wants them in Firestore or a database.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The bug: approve, restart… "Tool order_part not found" 🫠
&lt;/h2&gt;

&lt;p&gt;This is where it got interesting. Lookups worked. Bookings worked. The approval prompt appeared. I approved, and the restart failed with &lt;code&gt;Tool order_part not found&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause, in Genkit Dart 1.0.1 (&lt;code&gt;packages/genkit/lib/src/ai/generate.dart&lt;/code&gt;):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;MCP tools are registered under a namespaced name: &lt;code&gt;work-orders/order_part&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The model is shown the &lt;strong&gt;short&lt;/strong&gt; name, &lt;code&gt;order_part&lt;/code&gt;, and echoes it back in its tool request.&lt;/li&gt;
&lt;li&gt;The normal tool loop resolves that through a short-name map (&lt;code&gt;toolMap&lt;/code&gt;), so it finds the tool.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;resume&lt;/strong&gt; path (&lt;code&gt;interruptRestart&lt;/code&gt;) calls the tool executor &lt;strong&gt;without&lt;/strong&gt; that map, falls back to a registry lookup for &lt;code&gt;order_part&lt;/code&gt;, misses, and throws.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So any namespaced tool (every MCP tool) works until you put an approval in front of it. I reproduced it with a fake model and two tests, one with a plain tool name and one with &lt;code&gt;work-orders/order_part&lt;/code&gt;. Plain passes, namespaced fails. Then I patched a local copy of genkit 1.0.1 through &lt;code&gt;dependency_overrides&lt;/code&gt; to pass the map on the restart path, and both tests went green.&lt;/p&gt;

&lt;p&gt;It is filed upstream with the repro and the root cause as &lt;a href="https://github.com/genkit-ai/genkit-dart/issues/657" rel="noopener noreferrer"&gt;genkit-ai/genkit-dart#657&lt;/a&gt;, and the fix is open as &lt;a href="https://github.com/genkit-ai/genkit-dart/pull/658" rel="noopener noreferrer"&gt;genkit-ai/genkit-dart#658&lt;/a&gt;: the restart path now resolves tools the same way the loop does, with a regression test for a namespaced tool. My first open source PR to Genkit Dart. 🥳&lt;/p&gt;

&lt;p&gt;Until it is merged and released, the workaround is a local tool under the bare name that forwards to the MCP one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Genkit 1.0.1 restarts an approved call by the bare name it sent the model and skips the short-name map&lt;/span&gt;
&lt;span class="c1"&gt;// the normal tool loop uses, so `work-orders/order_part` is never found. A local `order_part` that forwards&lt;/span&gt;
&lt;span class="c1"&gt;// to the MCP tool is.&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;orderPart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;defineTool&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;PartOrder&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="n"&gt;orderPartToolName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;description:&lt;/span&gt; &lt;span class="n"&gt;mcpOrderPart&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;description&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="s"&gt;'Orders a spare part for one work order.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;inputSchema:&lt;/span&gt; &lt;span class="n"&gt;PartOrder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;$schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;fn:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="si"&gt;${(await mcpOrderPart.call(input.toJson())).output}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[..&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;mcpTools&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;mcpOrderPart&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;orderPart&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The MCP server stays the single owner of the logic; the adapter is a name and a forward. When #658 ships in a release, it is one deletion.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Testing an agent without a key
&lt;/h2&gt;

&lt;p&gt;The agent tests drive the &lt;strong&gt;full&lt;/strong&gt; loop with a fake model defined through &lt;code&gt;ai.defineModel&lt;/code&gt;: the fake asks for an MCP tool, the tool answers over real HTTP from the loopback MCP server, and the flow returns the structured answer. They cover a part order that is held, approved, declined, and answered twice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dart test
00:00 +14: All tests passed!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;14 tests, no Gemini call, no API key, no network beyond loopback. That is what makes the approval logic safe to refactor: the expensive, non-deterministic part (the model) is the only thing faked.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Shipping: a 21 MB native binary on &lt;code&gt;scratch&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Dart compiles the server to one native executable, so the runtime image needs no Dart SDK at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;dart:3.12&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; pubspec.yaml pubspec.lock ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dart pub get
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dart pub get &lt;span class="nt"&gt;--offline&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; dart compile exe bin/server.dart &lt;span class="nt"&gt;-o&lt;/span&gt; /app/server

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; scratch&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /runtime/ /&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/server /app/server&lt;/span&gt;
&lt;span class="k"&gt;EXPOSE&lt;/span&gt;&lt;span class="s"&gt; 8080&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["/app/server"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result is &lt;strong&gt;20.7 MB on disk&lt;/strong&gt; (6 MB compressed content), for an agent server, an MCP server and an HTTP router in one process.&lt;/p&gt;

&lt;p&gt;Deploy is one command, with the key from Secret Manager and the service private:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gcloud run deploy genkit-dispatch-agent &lt;span class="nt"&gt;--source&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--region&lt;/span&gt; europe-west3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-allow-unauthenticated&lt;/span&gt; &lt;span class="nt"&gt;--max-instances&lt;/span&gt; 1 &lt;span class="nt"&gt;--set-secrets&lt;/span&gt; &lt;span class="nv"&gt;GEMINI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gemini-api-key:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-allow-unauthenticated&lt;/code&gt; means only callers with &lt;code&gt;roles/run.invoker&lt;/code&gt; get through; everyone else gets a 403. For local development against the private service, &lt;code&gt;gcloud run services proxy&lt;/code&gt; serves it on localhost with your own credentials.&lt;/p&gt;

&lt;p&gt;One Cloud Run gotcha I had to fix after the first deploy: &lt;strong&gt;don't name your health check &lt;code&gt;/healthz&lt;/code&gt;.&lt;/strong&gt; Cloud Run's front end reserves paths ending in &lt;code&gt;z&lt;/code&gt; and never forwards them to your container. &lt;code&gt;/health&lt;/code&gt; works. 🤌&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Key findings
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A full agent stack in one language works today.&lt;/strong&gt; Genkit Dart 1.0.1, &lt;code&gt;genkit_mcp&lt;/code&gt;, &lt;code&gt;genkit_middleware&lt;/code&gt; and &lt;code&gt;genkit_shelf&lt;/code&gt; cover flow, tools, MCP client and server, approval and HTTP. The Flutter client shares the types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the approval in the middleware, not in the prompt.&lt;/strong&gt; A prompt can be talked out of "always ask first". A &lt;code&gt;toolApproval&lt;/code&gt; interrupt cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approvals need ids that answer once.&lt;/strong&gt; Otherwise a retry or a double tap runs a paid action twice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP plus interrupts is new territory.&lt;/strong&gt; The first approval I wired up hit a framework bug, a sign that few people have combined them yet. Worth testing the resume path, not only the happy path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native Dart on &lt;code&gt;scratch&lt;/code&gt; makes Cloud Run cheap and fast to ship&lt;/strong&gt;: about 21 MB, no runtime to patch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  9. Next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Persist pending approvals (Firestore) so they survive instance restarts and allow more than one instance.&lt;/li&gt;
&lt;li&gt;Send an identity token from the Flutter client so it can call the private service directly, without the proxy.&lt;/li&gt;
&lt;li&gt;Delete the adapter once &lt;a href="https://github.com/genkit-ai/genkit-dart/pull/658" rel="noopener noreferrer"&gt;#658&lt;/a&gt; ships in a Genkit Dart release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Are you building agents in Dart yet, or still reaching for Python on the server? I would love to hear what your approval story looks like. 👇&lt;/p&gt;

</description>
      <category>dart</category>
      <category>flutter</category>
      <category>ai</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>Shipping Flutter Web to Firebase When Your GitHub Actions Budget Hits Zero</title>
      <dc:creator>Esra Kadah</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:06:59 +0000</pubDate>
      <link>https://dev.to/gde/shipping-flutter-web-to-firebase-when-your-github-actions-budget-hits-zero-2mi5</link>
      <guid>https://dev.to/gde/shipping-flutter-web-to-firebase-when-your-github-actions-budget-hits-zero-2mi5</guid>
      <description>&lt;p&gt;On October 18, right in the middle of shipping a multi-feature audit and a supplement tracker redesign on my personal &lt;code&gt;life_os_app&lt;/code&gt; (my daily Flutter Web + Cloud Firestore operating board), GitHub Actions stopped scheduling jobs. The billing meter for my personal account's hosted runner minutes had hit 100%.&lt;/p&gt;

&lt;p&gt;Look at what my &lt;code&gt;.github/workflows/ci.yml&lt;/code&gt; and &lt;code&gt;deploy-live.yml&lt;/code&gt; were doing on every push and pull request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spinning up an &lt;code&gt;ubuntu-latest&lt;/code&gt; VM.&lt;/li&gt;
&lt;li&gt;Pulling the Flutter SDK and warming the Pub cache (~90s).&lt;/li&gt;
&lt;li&gt;Installing Python 3 and running 68 CLI sync unit tests (~15s).&lt;/li&gt;
&lt;li&gt;Running &lt;code&gt;flutter analyze&lt;/code&gt; and 466 widget and accessibility tests (~110s).&lt;/li&gt;
&lt;li&gt;Compiling &lt;code&gt;flutter build web --release --no-web-resources-cdn&lt;/code&gt; (~140s).&lt;/li&gt;
&lt;li&gt;Authenticating via Workload Identity Federation (&lt;code&gt;google-github-actions/auth@v2&lt;/code&gt;) and deploying to Firebase Hosting target &lt;code&gt;board&lt;/code&gt; (~35s).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is &lt;strong&gt;6.5 minutes per PR push&lt;/strong&gt; and another &lt;strong&gt;4 minutes on merge to &lt;code&gt;main&lt;/code&gt;&lt;/strong&gt;. Across 70+ PRs in October, a single-developer personal project burned through thousands of CI minutes doing work my local Apple Silicon MacBook finishes in &lt;strong&gt;under 35 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When your CI budget hits zero on a personal repo, you have two choices: pay for extra runner minutes to compile code your laptop already compiled, or build a deterministic local verification and deployment pipeline that is faster and safer than the YAML workflow it replaces.&lt;/p&gt;

&lt;p&gt;Here is the exact architecture I use to keep &lt;code&gt;life_os_app&lt;/code&gt; shipping to Firebase Hosting and Cloud Firestore with zero GitHub Actions spend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture: The Three-Layer Local Verification Gate
&lt;/h2&gt;

&lt;p&gt;Moving deploys to your local machine only works if you never deploy unverified state. In &lt;code&gt;life_os_app&lt;/code&gt;, the application state is split across two runtimes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.14 CLI Sync Tools (&lt;code&gt;tool/board_*.py&lt;/code&gt;)&lt;/strong&gt;: Morning and evening scripts that pull from Apple Reminders, Google Calendar, Harvest, Notion, and GCP Billing, then write structured JSON documents into Cloud Firestore over REST.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flutter Web Client (&lt;code&gt;lib/&lt;/code&gt;)&lt;/strong&gt;: A Riverpod 3 + Material 3 multi-skin web app served from Firebase Hosting with a custom offline Service Worker (&lt;code&gt;web/offline-sw.js&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Layer 1: Firestore Schema Validation Before Every REST Write
&lt;/h3&gt;

&lt;p&gt;One of the biggest risks of running CLI sync tools outside a container is accidentally clobbering fields that the Flutter Web client writes directly from the browser. For example, on the &lt;code&gt;Events&lt;/code&gt; and &lt;code&gt;Admin&lt;/code&gt; tabs, I tick tasks directly in the UI (&lt;code&gt;config/events.taskTicks&lt;/code&gt; and &lt;code&gt;config/admin.done&lt;/code&gt;), while &lt;code&gt;tool/board_events.py&lt;/code&gt; and &lt;code&gt;tool/board_admin.py&lt;/code&gt; sync metadata from Notion and GCP Billing.&lt;/p&gt;

&lt;p&gt;To prevent a CLI run from wiping browser taps, &lt;code&gt;tool/board_firestore.py&lt;/code&gt; enforces two rules before any HTTP &lt;code&gt;PATCH&lt;/code&gt; leaves the machine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Explicit schema validation&lt;/strong&gt; on every collection (&lt;code&gt;dayPlans&lt;/code&gt;, &lt;code&gt;stats&lt;/code&gt;, &lt;code&gt;config&lt;/code&gt;, &lt;code&gt;finance&lt;/code&gt;, &lt;code&gt;plan&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field-level update masks (&lt;code&gt;?updateMask.fieldPaths=...&lt;/code&gt;)&lt;/strong&gt; plus hard blocks against overwriting client-owned map keys:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tool/board_firestore.py
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config/events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taskTicks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refusing to overwrite config/events.taskTicks without --fields; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pass --fields updated,source,events&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;config/admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;done&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refusing to overwrite config/admin.done without --fields&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In addition, caching the &lt;code&gt;gcloud auth print-access-token&lt;/code&gt; call with &lt;code&gt;@functools.lru_cache(maxsize=1)&lt;/code&gt; and enforcing a 15-second socket timeout cut multi-document Firestore sync latency by 80% and eliminated hanging background processes when switching Wi-Fi networks.&lt;/p&gt;




&lt;h3&gt;
  
  
  Layer 2: Per-Document Decode Isolation in Flutter Streams
&lt;/h3&gt;

&lt;p&gt;In Cloud Firestore, a single malformed field inside one historical document in a collection query (&lt;code&gt;collection('dayPlans').snapshots()&lt;/code&gt;) will normally throw a &lt;code&gt;FormatException&lt;/code&gt; or &lt;code&gt;TypeError&lt;/code&gt; inside &lt;code&gt;.map()&lt;/code&gt;, killing the entire &lt;code&gt;StreamProvider&lt;/code&gt; and replacing your UI with an error state.&lt;/p&gt;

&lt;p&gt;During my audit pass, I hardened every repository to isolate decoding per document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/features/day_board/data/board_repository.dart&lt;/span&gt;
&lt;span class="kt"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_decodeEach&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
  &lt;span class="n"&gt;QuerySnapshot&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;JsonMap&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="kt"&gt;Function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JsonMap&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;String&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="kd"&gt;on&lt;/span&gt; &lt;span class="kt"&gt;Object&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stackTrace&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="n"&gt;developer&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s"&gt;'Skipping malformed document &lt;/span&gt;&lt;span class="si"&gt;${doc.reference.path}&lt;/span&gt;&lt;span class="s"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s"&gt;'day_board.repository'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;error:&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;stackTrace:&lt;/span&gt; &lt;span class="n"&gt;stackTrace&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When saving multi-day edits (such as editing 7 days of focus blocks and habit notes in the &lt;code&gt;This Week&lt;/code&gt; sheet), replacing 14 sequential &lt;code&gt;await set()&lt;/code&gt; calls with a single atomic &lt;code&gt;WriteBatch&lt;/code&gt; (&lt;code&gt;saveWeekEditsBatch&lt;/code&gt;) eliminated partial-save race conditions on flaky mobile connections.&lt;/p&gt;




&lt;h3&gt;
  
  
  Layer 3: Self-Contained Web Bundles (&lt;code&gt;--no-web-resources-cdn&lt;/code&gt;) &amp;amp; Service Worker Cleanup
&lt;/h3&gt;

&lt;p&gt;When deploying Flutter Web to Firebase Hosting for daily use on iPhone, iPad, and desktop, CDN font/CanvasKit fetches can stall on airplane or train Wi-Fi. Building with &lt;code&gt;--no-web-resources-cdn&lt;/code&gt; bundles CanvasKit and font assets directly into &lt;code&gt;build/web/&lt;/code&gt;, paired with a custom &lt;code&gt;web/offline-sw.js&lt;/code&gt; that races network against a 3.5-second timeout before falling back to cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// web/offline-sw.js&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;networkFirst&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;timeoutMs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CACHE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;.finally(() =&amp;gt; clearTimeout(timer))&lt;/code&gt; cleanup: without it, every fast network response leaves an orphan timer running until &lt;code&gt;timeoutMs&lt;/code&gt; expires.&lt;/p&gt;




&lt;h2&gt;
  
  
  Benchmarking: Cloud CI vs. Local Verification Gate
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;GitHub Actions (&lt;code&gt;ubuntu-latest&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;Local Apple Silicon Gate&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Python CLI Unit Tests (&lt;code&gt;68 tests&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;14.2s (with setup)&lt;/td&gt;
&lt;td&gt;0.02s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;710x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dart Format + Analyze + &lt;code&gt;466 Flutter tests&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;118.0s&lt;/td&gt;
&lt;td&gt;14.5s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8.1x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;flutter build web --release --no-web-resources-cdn&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;142.0s&lt;/td&gt;
&lt;td&gt;14.8s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9.6x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;firebase deploy --only hosting:board&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;34.0s&lt;/td&gt;
&lt;td&gt;3.1s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;11.0x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total Wall-Clock Time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~5m 08s (plus SDK cache)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~32.4s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~9.5x faster, $0 CI cost&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Table totals exclude the Flutter SDK download and Pub cache warm-up (~90s), which is what brings a full PR run to ~6.5 minutes.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways for Flutter &amp;amp; Firebase Engineers
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep CI YAML for PRs when budget allows, but make your local gate identical.&lt;/strong&gt; A single local command (&lt;code&gt;dart format -l 120 lib/ test/ &amp;amp;&amp;amp; flutter analyze &amp;amp;&amp;amp; python3 -m unittest discover -s tool -p '*_test.py' &amp;amp;&amp;amp; flutter test&lt;/code&gt;) guarantees parity before &lt;code&gt;firebase deploy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Firebase Hosting multi-site targets (&lt;code&gt;hosting:board&lt;/code&gt;, &lt;code&gt;hosting:board-staging&lt;/code&gt;).&lt;/strong&gt; Mapping targets in &lt;code&gt;.firebaserc&lt;/code&gt; lets you preview on staging or promote to live with &lt;code&gt;--only hosting:board&lt;/code&gt; without touching sibling sites on the same GCP project.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let one bad Firestore document take down a collection stream.&lt;/strong&gt; Wrap per-document &lt;code&gt;.fromJson()&lt;/code&gt; calls inside a helper that logs to &lt;code&gt;dart:developer&lt;/code&gt; and skips the corrupted record.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test accessibility contrast and 44pt tap targets in &lt;code&gt;flutter test&lt;/code&gt;, not in production.&lt;/strong&gt; Running &lt;code&gt;textContrastGuideline&lt;/code&gt; and &lt;code&gt;androidTapTargetGuideline&lt;/code&gt; with &lt;code&gt;GoogleFonts.config.allowRuntimeFetching = false&lt;/code&gt; catches neon text shadow contrast drops and 32pt icons in milliseconds.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The complete local release gate in one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dart format &lt;span class="nt"&gt;-l&lt;/span&gt; 120 lib/ &lt;span class="nb"&gt;test&lt;/span&gt;/ &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  flutter analyze &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  python3 &lt;span class="nt"&gt;-m&lt;/span&gt; unittest discover &lt;span class="nt"&gt;-s&lt;/span&gt; tool &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s1"&gt;'*_test.py'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  flutter &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  flutter build web &lt;span class="nt"&gt;--release&lt;/span&gt; &lt;span class="nt"&gt;--no-web-resources-cdn&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  firebase deploy &lt;span class="nt"&gt;--only&lt;/span&gt; hosting:board &lt;span class="nt"&gt;--project&lt;/span&gt; esra-life-os
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



</description>
      <category>flutter</category>
      <category>firebase</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
