<?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: Fabrice</title>
    <description>The latest articles on DEV Community by Fabrice (@fab-hita).</description>
    <link>https://dev.to/fab-hita</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%2F4092502%2F2f249acc-7d33-4658-8a9b-e991f1dcd13f.JPG</url>
      <title>DEV Community: Fabrice</title>
      <link>https://dev.to/fab-hita</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fab-hita"/>
    <language>en</language>
    <item>
      <title>I gave a small business a CFO that doesn't wait to be asked</title>
      <dc:creator>Fabrice</dc:creator>
      <pubDate>Mon, 24 Aug 2026 14:52:25 +0000</pubDate>
      <link>https://dev.to/fab-hita/i-gave-a-small-business-a-cfo-that-doesnt-wait-to-be-asked-5901</link>
      <guid>https://dev.to/fab-hita/i-gave-a-small-business-a-cfo-that-doesnt-wait-to-be-asked-5901</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;I built this project and wrote this post for the All things agentic hackathon, organized by Google.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The idea in one sentence
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What if a small business could have the operating rhythm of a Fortune 500 finance team without hiring one?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the project: an autonomous AI CFO for SMBs. Not a dashboard that waits for a question, and not a chatbot that performs a clever demo. A system that watches the books, surfaces what needs attention, follows a workflow through to completion, and brings a human in exactly where judgement and authorisation belong.&lt;/p&gt;

&lt;p&gt;The owner should not need to become a data analyst to understand the business. They should be able to ask one person — the CFO — while the underlying finance team does the repetitive work in the background.&lt;/p&gt;

&lt;p&gt;The brief asked for a complete workflow rather than a chatbot — something that&lt;br&gt;
takes action, sends the right information to the right places, and does the&lt;br&gt;
heavy lifting. That framing is a trap, and I nearly fell into it twice.&lt;/p&gt;

&lt;p&gt;The trap is that a chatbot with tools &lt;em&gt;looks&lt;/em&gt; like a workflow. You wire up some&lt;br&gt;
functions, the model calls them, things happen. It demos well for ninety&lt;br&gt;
seconds. But nothing happens unless someone is typing, and the moment you close&lt;br&gt;
the tab the system stops existing.&lt;/p&gt;

&lt;p&gt;So I set myself a harder test: &lt;strong&gt;the thing has to do useful work on a morning&lt;br&gt;
when nobody opens it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What I ended up with is a finance department for a business too small to have&lt;br&gt;
one. An owner talks to a CFO. Behind the CFO, three specialists — a Treasurer, an&lt;br&gt;
Accountant and an Analyst — review the books every morning, close the month when&lt;br&gt;
it ends, and write the management accounts. The owner never talks to the&lt;br&gt;
specialists. That constraint turned out to drive most of the architecture.&lt;/p&gt;

&lt;p&gt;The important boundary is this: &lt;strong&gt;autonomy does not mean unlimited authority&lt;/strong&gt;. The&lt;br&gt;
system can inspect, calculate, monitor, prepare and recommend on its own. When an&lt;br&gt;
action changes the books, it stops for explicit human approval. That separation&lt;br&gt;
between &lt;em&gt;work the agent can do&lt;/em&gt; and &lt;em&gt;decisions the business must own&lt;/em&gt; became one of&lt;br&gt;
the central design rules of the project.&lt;/p&gt;

&lt;p&gt;At the time of writing it has run &lt;strong&gt;40 unattended reviews across 8&lt;br&gt;
consecutive days with zero failures&lt;/strong&gt;, against a real QuickBooks sandbox.&lt;/p&gt;


&lt;h2&gt;
  
  
  Picking the right ADK pillar for each job
&lt;/h2&gt;

&lt;p&gt;Google's Agent Development Kit gives you three orchestration models, and the&lt;br&gt;
interesting design work was deciding which belonged where.&lt;/p&gt;
&lt;h3&gt;
  
  
  The daily review is a graph, not a chat
&lt;/h3&gt;

&lt;p&gt;My first version had each agent call tools to fetch its evidence. The prompt had&lt;br&gt;
to &lt;em&gt;ask&lt;/em&gt; for the data and hope. When a specialist skipped a tool call, it&lt;br&gt;
reviewed nothing — and reported that everything looked fine.&lt;/p&gt;

&lt;p&gt;That is a horrible failure mode. It is indistinguishable from success.&lt;/p&gt;

&lt;p&gt;So the evidence gathering became plain Python functions wired into the graph:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;review_workflow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Workflow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cfo_daily_review&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;edges&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;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_performance&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_trend&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_cash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_receivables&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_ledger_quality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fetch_open_findings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;treasurer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;judged&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;judged&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;analyst&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="n"&gt;judged&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;judged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;store_findings&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;Six fetches, zero model calls, and they run because they are wired to &lt;code&gt;START&lt;/code&gt; —&lt;br&gt;
not because a model chose to. The specialists only start once the facts are on&lt;br&gt;
the table.&lt;/p&gt;

&lt;p&gt;Note there are &lt;strong&gt;two&lt;/strong&gt; joins. &lt;code&gt;gather&lt;/code&gt; bundles the evidence so every specialist&lt;br&gt;
sees the whole picture; &lt;code&gt;judged&lt;/code&gt; waits for all three verdicts so nothing gets&lt;br&gt;
persisted against a partial view. I learned that one the hard way when a&lt;br&gt;
timed-out specialist caused findings it owned to be marked resolved.&lt;/p&gt;

&lt;p&gt;The other quiet win: &lt;code&gt;Runner(node=workflow)&lt;/code&gt; runs with &lt;code&gt;new_message=None&lt;/code&gt;. There&lt;br&gt;
is no fake "please run your review now" prompt anywhere in the system. The&lt;br&gt;
workflow just runs.&lt;/p&gt;
&lt;h3&gt;
  
  
  The chat is &lt;code&gt;mode="single_turn"&lt;/code&gt;, and that flag is the product
&lt;/h3&gt;

&lt;p&gt;ADK sub-agents default to &lt;code&gt;chat&lt;/code&gt; mode. In that mode the coordinator only gets&lt;br&gt;
&lt;code&gt;transfer_to_agent&lt;/code&gt; — a serial handoff of the entire conversation to one&lt;br&gt;
specialist, which never comes back.&lt;/p&gt;

&lt;p&gt;Think about what that means here. The owner asks their CFO a question about&lt;br&gt;
cash, and a Treasurer they have never met answers and keeps the conversation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;cfo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cfo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sub_agents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chat_treasurer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chat_accountant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chat_analyst&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;chat_treasurer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;treasurer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;single_turn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# &amp;lt;- the whole design, in one flag
&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="n"&gt;get_cash_position&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_receivables&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;get_open_findings&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;single_turn&lt;/code&gt;, ADK gives the CFO one delegation tool per specialist, runs&lt;br&gt;
the relevant subset &lt;strong&gt;in parallel&lt;/strong&gt;, returns each result, and the CFO answers in&lt;br&gt;
its own voice. The specialists never address the user.&lt;/p&gt;

&lt;p&gt;I like this because the product decision — &lt;em&gt;the owner hired a CFO, not a&lt;br&gt;
committee&lt;/em&gt; — is enforced by the framework rather than by a prompt asking nicely.&lt;/p&gt;


&lt;h2&gt;
  
  
  The part I actually care about: a pause that is real
&lt;/h2&gt;

&lt;p&gt;Month-end close is the flagship workflow, and its defining feature is that it&lt;br&gt;
&lt;strong&gt;stops and waits for a human, potentially for days.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Plenty of systems fake this. A status flag, a poll loop, a job that wakes up and&lt;br&gt;
asks "approved yet?". ADK 2 ships a real primitive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nd"&gt;@node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rerun_on_resume&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;await_approval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;node_input&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_load_case&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resume_inputs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interrupt_id&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;answer&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# ... save proposals, compose the approval email ...
&lt;/span&gt;        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nc"&gt;RequestInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;interrupt_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;interrupt_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Approve the &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;case&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;period_label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; closing adjustments?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;response_schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&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="c1"&gt;# the run genuinely stops here
&lt;/span&gt;
    &lt;span class="n"&gt;approved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;yes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;node_input&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before building anything on top of this I ran a spike as a hard gate, in two&lt;br&gt;
genuinely separate processes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;process 1   [prepare] scanning the period (expensive work)
            [ask_approval] pausing on 'close-approval'
            ⏸ PAUSED

process 2   --- RESUME (2 prior events) ---
            [ask_approval] resumed with answer: 'approve'
            [act] DECIDED -&amp;gt; 'approve'
            ✓ resumed from a cold process and completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important line is the one that &lt;strong&gt;did not print&lt;/strong&gt;. &lt;code&gt;prepare&lt;/code&gt; never re-ran.&lt;br&gt;
The workflow resumed at the node it stopped on, knowing only a session id and an&lt;br&gt;
interrupt id — exactly the position an emailed approval link is in two days&lt;br&gt;
later.&lt;/p&gt;

&lt;p&gt;Two things I would tell anyone building this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use a stable &lt;code&gt;interrupt_id&lt;/code&gt;.&lt;/strong&gt; Derive it from your case (&lt;code&gt;f"{case_id}-approval"&lt;/code&gt;),&lt;br&gt;
never from a generator. The process that resumes has never met the process that&lt;br&gt;
paused; it has to be able to name the interrupt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep your own record alongside.&lt;/strong&gt; ADK owns execution, but I store a&lt;br&gt;
&lt;code&gt;CloseCase&lt;/code&gt; in Firestore too — an index, an audit trail, and the UI's read&lt;br&gt;
model. It also means a lost session is &lt;em&gt;detectable&lt;/em&gt; rather than silent. If the&lt;br&gt;
session store ever drops a paused run, the case is still sitting there saying it&lt;br&gt;
was waiting.&lt;/p&gt;
&lt;h3&gt;
  
  
  The bug that made me respect one-way resume
&lt;/h3&gt;

&lt;p&gt;While filming a demo I clicked the &lt;strong&gt;"Not yet"&lt;/strong&gt; button on the approval page,&lt;br&gt;
expecting to come back later. The close carried on and marked the month closed.&lt;/p&gt;

&lt;p&gt;Nothing reached QuickBooks — that guard held. But the month was signed off, and&lt;br&gt;
the Analyst wrote management accounts for books that had never been corrected.&lt;/p&gt;

&lt;p&gt;The cause is that &lt;strong&gt;resuming is one-way&lt;/strong&gt;. Any answer restarts the run, and the&lt;br&gt;
graph then proceeds to the end. "Decline" wasn't a way to stay paused; it was a&lt;br&gt;
way to run the whole workflow with a flag set.&lt;/p&gt;

&lt;p&gt;The fix was to stop treating it as a decision:&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;// "Not yet" means come back later, so it must NOT resume the workflow.&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;decision&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approve&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;signedIn&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/inside&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&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;And the endpoint now refuses anything else, so no client can sign off a month&lt;br&gt;
without approving it. There are two options, not three, because the framework&lt;br&gt;
only supports two.&lt;/p&gt;


&lt;h2&gt;
  
  
  Real books bite back
&lt;/h2&gt;

&lt;p&gt;I could have mocked the accounting system. I'm glad I didn't, because almost&lt;br&gt;
everything I learned came from the API disagreeing with me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The account name collision.&lt;/strong&gt; In this chart of accounts,&lt;br&gt;
&lt;code&gt;Sprinklers and Drip Systems&lt;/code&gt; exists twice — once as Income, once as Expense.&lt;br&gt;
Matching on name alone posted a bill line to the income account and booked&lt;br&gt;
&lt;strong&gt;negative revenue&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I did not find this. The agent did, in its morning review: &lt;em&gt;"Negative revenue of&lt;br&gt;
−$288.00."&lt;/em&gt; Which was a strange and quite good moment. The fix is an&lt;br&gt;
&lt;code&gt;AccountType&lt;/code&gt; filter on every cost posting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backslashes, not doubled quotes.&lt;/strong&gt; QuickBooks' query language looks like SQL&lt;br&gt;
and is not. &lt;code&gt;'Amy''s'&lt;/code&gt; returns a 400; &lt;code&gt;'Amy\'s'&lt;/code&gt; matches. The nasty part is that&lt;br&gt;
a failed lookup doesn't raise — so the vendor lookup silently found nothing and&lt;br&gt;
created a duplicate vendor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query parameters must not live in the path.&lt;/strong&gt; I wrote&lt;br&gt;
&lt;code&gt;client.post(f"/bill?operation=delete", ...)&lt;/code&gt;. httpx &lt;em&gt;replaces&lt;/em&gt; a URL's query&lt;br&gt;
string with its own &lt;code&gt;params&lt;/code&gt;, so &lt;code&gt;operation=delete&lt;/code&gt; was stripped and every&lt;br&gt;
delete became a silent no-op returning 200. Deletes now verify&lt;br&gt;
&lt;code&gt;status == "Deleted"&lt;/code&gt; in the response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Closing entries are dated at period end, never today.&lt;/strong&gt; Dated today, July's&lt;br&gt;
adjustments corrected nothing in July and silently added $2,000 to August.&lt;/p&gt;

&lt;p&gt;None of these were findable by reasoning. All of them were findable by running&lt;br&gt;
the thing against real data every day.&lt;/p&gt;


&lt;h2&gt;
  
  
  Making the model's job small
&lt;/h2&gt;

&lt;p&gt;The uncomfortable question for any agent system is: how do you know it isn't&lt;br&gt;
making things up?&lt;/p&gt;

&lt;p&gt;My answer was to shrink the job until confabulation has nowhere to live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every number is computed in code.&lt;/strong&gt; Concentration percentages, ageing buckets,&lt;br&gt;
duplicate-bill detection, the materiality floor — all arithmetic, all in Python,&lt;br&gt;
all handed to the specialist. The model decides whether something &lt;em&gt;matters&lt;/em&gt;. It&lt;br&gt;
is never asked whether something is &lt;em&gt;true&lt;/em&gt;, because it is never asked to produce&lt;br&gt;
a figure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resolution is declared, never inferred.&lt;/strong&gt; My first version treated a&lt;br&gt;
specialist's silence about a finding as "fixed". That closed three live overdue&lt;br&gt;
invoices that were still very much unpaid. Now a specialist has to name what it&lt;br&gt;
believes is resolved, and silence is recorded as an observation instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A failed specialist closes nothing.&lt;/strong&gt; Each owns a set of finding kinds; only&lt;br&gt;
kinds whose owner completed successfully are eligible for closure.&lt;/p&gt;

&lt;p&gt;Findings are deduplicated on &lt;code&gt;sha256(kind + ":" + subject)&lt;/code&gt;, which is why the&lt;br&gt;
counters mean anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;overdue_invoice/1024      seen 43×   open 5 days
revenue_gap/net_income    seen 38×   open 5 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A dashboard can be built in an afternoon. A record showing the same invoice&lt;br&gt;
observed forty-three times across seven consecutive days cannot be assembled&lt;br&gt;
after the fact.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the model &lt;em&gt;was&lt;/em&gt; wrong
&lt;/h3&gt;

&lt;p&gt;Three times, and all three were caught by reading its output rather than by any&lt;br&gt;
harness:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The CFO quoted figures correctly and then asserted a cause the books did not
establish — &lt;em&gt;"we're buying materials and forgetting to invoice for them"&lt;/em&gt;. Its
rules now separate what it knows from what it is theorising, and it says which
is which.&lt;/li&gt;
&lt;li&gt;The Analyst wrote &lt;code&gt;£&lt;/code&gt; because it inferred a currency from a landscaping
business. I fixed the prompt. It did it again. I fixed the prompt harder. Then
I gave up and fixed it in code, because a &lt;code&gt;str.translate&lt;/code&gt; cannot fail and a
prompt rule that has already failed twice does not deserve a third chance.&lt;/li&gt;
&lt;li&gt;The Analyst once described &lt;em&gt;"a completely static month, an empty ledger"&lt;/em&gt; for a
month with $6,150 of income — because it was a chain node and had received
only a list of actions. It now receives the adjusted P&amp;amp;L.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the most instructive. It wasn't a model failure. It was me&lt;br&gt;
handing an agent nothing and being surprised when it filled the gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Two rooms, on purpose
&lt;/h2&gt;

&lt;p&gt;The UI is two deliberately different places.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;owner's office&lt;/strong&gt; is light, calm, three things: today's action, three&lt;br&gt;
numbers, and a box to ask the CFO. That's it.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;CFO's desk&lt;/strong&gt; is dark, dense, and shows the machinery — every finding with&lt;br&gt;
its age and sighting count, the review graph drawn out, the run log with&lt;br&gt;
durations, the month-end pipeline moving through &lt;em&gt;Preparing → Waiting on you →&lt;br&gt;
Posting → Writing up → Closed&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The contrast carries the argument without a sentence of copy: &lt;em&gt;left is the&lt;br&gt;
chatbot you expected, right is the machine that makes it true.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One small thing I'm oddly pleased with. The desk said "3 days without a gap",&lt;br&gt;
and it was wrong. The number was &lt;code&gt;round((now - watching_since) / 86400000)&lt;/code&gt; —&lt;br&gt;
elapsed time, not a day count. With a start time of 19:27 UTC it read one number&lt;br&gt;
all morning and silently gained a day each evening, whether or not anything had&lt;br&gt;
run.&lt;/p&gt;

&lt;p&gt;It now counts distinct calendar days that actually have a completed review, and&lt;br&gt;
&lt;code&gt;unbroken&lt;/code&gt; is a separate flag that &lt;strong&gt;can be false&lt;/strong&gt; — the caption falls back to&lt;br&gt;
"days reviewed" when a day is missed. A claim on a dashboard should be capable of&lt;br&gt;
being untrue, or it isn't a claim.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;I'd test the cold path sooner.&lt;/strong&gt; The emailed approval link — the whole point&lt;br&gt;
of the multi-day workflow — was broken for anyone without a session. The page&lt;br&gt;
loaded fine; every API call it made returned 401. I never noticed because I was&lt;br&gt;
always testing in a browser carrying an owner cookie. Private window, or the&lt;br&gt;
test proves nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd have written the ledger census script first.&lt;/strong&gt; I spent time reasoning about&lt;br&gt;
what was in the books when a thirty-line script could have told me. It later&lt;br&gt;
caught the month picker offering February — a month with &lt;em&gt;zero&lt;/em&gt; transactions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I'd trust range-based edits less.&lt;/strong&gt; Twice I replaced a block of code by&lt;br&gt;
character range and silently deleted a function that lived inside it. Both times&lt;br&gt;
the tests passed, because the tests didn't cover the UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why this is more than an AI chat interface
&lt;/h2&gt;

&lt;p&gt;The easiest version of this project would have been: connect a model to&lt;br&gt;
QuickBooks, give it a few tools, and let the user ask questions.&lt;/p&gt;

&lt;p&gt;I deliberately did not build that.&lt;/p&gt;

&lt;p&gt;The useful work happens on a clock, not on a prompt. Evidence is gathered&lt;br&gt;
deterministically. Specialists review it in parallel. Findings accumulate over&lt;br&gt;
time instead of disappearing at the end of a chat. A month-end workflow can pause&lt;br&gt;
for days and resume in a fresh process. And the human approval is a real boundary&lt;br&gt;
before anything is posted.&lt;/p&gt;

&lt;p&gt;That is the architecture I wanted to test for the hackathon: &lt;strong&gt;agents as a&lt;br&gt;
persistent operating system for a business process, not as a conversational skin&lt;br&gt;
over a set of APIs&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Google ADK 2.7.1 · Gemini 3.5 Flash on Vertex AI · QuickBooks Online (real&lt;br&gt;
sandbox, OAuth rotating in Firestore) · Cloud Run · Firestore · Cloud Scheduler&lt;br&gt;
· Vertex AI Agent Engine for suspended workflow state.&lt;/p&gt;

&lt;p&gt;Roughly 6,700 lines of Python and 2,400 of front end, with no build step — the&lt;br&gt;
UI is Tailwind's CDN over a hand-written token file, so the whole thing deploys&lt;br&gt;
as one Python container.&lt;/p&gt;

&lt;p&gt;Two scheduled jobs do the unattended work: seeding the ledger on weekdays at&lt;br&gt;
06:00, and the review at 07:00. They have not missed a day.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I think the real product is
&lt;/h2&gt;

&lt;p&gt;I started by calling this an AI finance team. I increasingly think that is only&lt;br&gt;
half right.&lt;/p&gt;

&lt;p&gt;The product is &lt;strong&gt;financial attention&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Small businesses already have accounting systems and historical data. What they&lt;br&gt;
often do not have is someone continuously looking across that information,&lt;br&gt;
remembering what was wrong yesterday, checking whether it is still wrong today,&lt;br&gt;
and pushing a process forward until it reaches a decision.&lt;/p&gt;

&lt;p&gt;That is the gap I wanted to close.&lt;/p&gt;

&lt;p&gt;The AI is not valuable because it can produce another summary of the books. It is&lt;br&gt;
valuable when it creates a reliable operating loop:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;observe → compute → judge → remember → ask → act → verify&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The more I built, the more that loop mattered. The model is only one part of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing thought
&lt;/h2&gt;

&lt;p&gt;The most useful discipline in this build wasn't a framework feature. It was&lt;br&gt;
insisting that the system had to be &lt;em&gt;checkable&lt;/em&gt;: every number traceable to code,&lt;br&gt;
every claim on the dashboard capable of being false, every run logged unedited&lt;br&gt;
including the failures.&lt;/p&gt;

&lt;p&gt;Agents are easy to make impressive and hard to make trustworthy. Most of my time&lt;br&gt;
went on the second one.&lt;/p&gt;




&lt;h3&gt;
  
  
  If you only remember one thing
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;A useful business agent should still be working when nobody is talking to it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That was my test for this project. Everything else — the multi-agent design, the&lt;br&gt;
suspended workflows, the approval boundary, the audit trail and the UI — follows&lt;br&gt;
from that requirement.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I created this project and this article for the purposes of entering the All things agentic hackathon (Taskmaster category).&lt;br&gt;
 The business is fictional and the ledger is authored, but&lt;br&gt;
the QuickBooks integration, the journal entries, the scheduled runs and the&lt;br&gt;
accumulated findings are all real.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googlecloud</category>
      <category>autonomousaiagents</category>
      <category>gemini</category>
      <category>python</category>
    </item>
  </channel>
</rss>
