I built this project and wrote this post for the All things agentic hackathon, organized by Google.
What if a small business could have the operating rhythm of a Fortune 500 finance team without hiring one?
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.
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.
The brief asked for a complete workflow rather than a chatbot β something that
takes action, sends the right information to the right places, and does the
heavy lifting. That framing is a trap, and I nearly fell into it twice.
The trap is that a chatbot with tools looks like a workflow. You wire up some
functions, the model calls them, things happen. It demos well for ninety
seconds. But nothing happens unless someone is typing, and the moment you close
the tab the system stops existing.
So I set myself a harder test: the thing has to do useful work on a morning when nobody opens it.
What I ended up with is a finance department for a business too small to have
one. An owner talks to a CFO. Behind the CFO, three specialists β a Treasurer, an
Accountant and an Analyst β review the books every morning, close the month when
it ends, and write the management accounts. The owner never talks to the
specialists. That constraint turned out to drive most of the architecture.
The important boundary is this: autonomy does not mean unlimited authority. The
system can inspect, calculate, monitor, prepare and recommend on its own. When an
action changes the books, it stops for explicit human approval. That separation
between work the agent can do and decisions the business must own became one of
the central design rules of the project.
At the time of writing it has run 40 unattended reviews across 8 consecutive days with zero failures, against a real QuickBooks sandbox.
Google's Agent Development Kit gives you three orchestration models, and the
interesting design work was deciding which belonged where.
My first version had each agent call tools to fetch its evidence. The prompt had
to ask for the data and hope. When a specialist skipped a tool call, it
reviewed nothing β and reported that everything looked fine.
That is a horrible failure mode. It is indistinguishable from success.
So the evidence gathering became plain Python functions wired into the graph:
review_workflow = Workflow(
name="cfo_daily_review",
edges=[
(START, fetch_performance, gather),
(START, fetch_trend, gather),
(START, fetch_cash, gather),
(START, fetch_receivables, gather),
(START, fetch_ledger_quality, gather),
(START, fetch_open_findings, gather),
(gather, treasurer, judged),
(gather, accountant, judged),
(gather, analyst, judged),
(judged, store_findings),
],
)
Six fetches, zero model calls, and they run because they are wired to START
β
not because a model chose to. The specialists only start once the facts are on
the table.
Note there are two joins. gather
bundles the evidence so every specialist
sees the whole picture; judged
waits for all three verdicts so nothing gets
persisted against a partial view. I learned that one the hard way when a
timed-out specialist caused findings it owned to be marked resolved.
The other quiet win: Runner(node=workflow)
runs with new_message=None
. There
is no fake "please run your review now" prompt anywhere in the system. The
workflow just runs.
mode="single_turn"
, and that flag is the product
ADK sub-agents default to chat
mode. In that mode the coordinator only gets
transfer_to_agent
β a serial handoff of the entire conversation to one
specialist, which never comes back.
Think about what that means here. The owner asks their CFO a question about
cash, and a Treasurer they have never met answers and keeps the conversation.
cfo = Agent(
name="cfo",
sub_agents=[chat_treasurer, chat_accountant, chat_analyst],
...
)
chat_treasurer = Agent(
name="treasurer",
mode="single_turn", # <- the whole design, in one flag
tools=[get_cash_position, get_receivables, get_open_findings],
...
)
With single_turn
, ADK gives the CFO one delegation tool per specialist, runs
the relevant subset in parallel, returns each result, and the CFO answers in
its own voice. The specialists never address the user.
I like this because the product decision β the owner hired a CFO, not a committee β is enforced by the framework rather than by a prompt asking nicely.
Month-end close is the flagship workflow, and its defining feature is that it
stops and waits for a human, potentially for days.
Plenty of systems fake this. A status flag, a poll loop, a job that wakes up and
asks "approved yet?". ADK 2 ships a real primitive:
@node(rerun_on_resume=True)
async def await_approval(ctx, node_input):
case = _load_case()
answer = ctx.resume_inputs.get(case.interrupt_id)
if answer is None:
yield RequestInput(
interrupt_id=case.interrupt_id,
message=f"Approve the {case.period_label} closing adjustments?",
response_schema={"type": "string"},
)
return # the run genuinely stops here
approved = str(answer).strip().lower() in ("approve", "approved", "yes")
yield Event(output={"approved": approved, "plan": node_input})
Before building anything on top of this I ran a spike as a hard gate, in two
genuinely separate processes:
process 1 [prepare] scanning the period (expensive work)
[ask_approval] pausing on 'close-approval'
βΈ D
process 2 --- RESUME (2 prior events) ---
[ask_approval] resumed with answer: 'approve'
[act] DECIDED -> 'approve'
β resumed from a cold process and completed
The important line is the one that did not print. prepare
never re-ran.
The workflow resumed at the node it stopped on, knowing only a session id and an
interrupt id β exactly the position an emailed approval link is in two days
later.
Two things I would tell anyone building this:
Use a stable interrupt_id. Derive it from your case (
f"{case_id}-approval"
),Keep your own record alongside. ADK owns execution, but I store a
CloseCase
in Firestore too β an index, an audit trail, and the UI's read
model. It also means a lost session is detectable rather than silent. If the
session store ever drops a d run, the case is still sitting there saying it
was waiting.
While filming a demo I clicked the "Not yet" button on the approval page,
expecting to come back later. The close carried on and marked the month closed.
Nothing reached QuickBooks β that guard held. But the month was signed off, and
the Analyst wrote management accounts for books that had never been corrected.
The cause is that resuming is one-way. Any answer restarts the run, and the
graph then proceeds to the end. "Decline" wasn't a way to stay d; it was a
way to run the whole workflow with a flag set.
The fix was to stop treating it as a decision:
// "Not yet" means come back later, so it must NOT resume the workflow.
if (decision !== 'approve') {
location.href = signedIn ? '/inside' : '/login';
return;
}
And the endpoint now refuses anything else, so no client can sign off a month
without approving it. There are two options, not three, because the framework
only supports two.
I could have mocked the accounting system. I'm glad I didn't, because almost
everything I learned came from the API disagreeing with me.
The account name collision. In this chart of accounts,
Sprinklers and Drip Systems
exists twice β once as Income, once as Expense.
Matching on name alone posted a bill line to the income account and booked
negative revenue.
I did not find this. The agent did, in its morning review: "Negative revenue of β$288.00." Which was a strange and quite good moment. The fix is an
AccountType
filter on every cost posting.Backslashes, not doubled quotes. QuickBooks' query language looks like SQL
and is not. 'Amy''s'
returns a 400; 'Amy\'s'
matches. The nasty part is that
a failed lookup doesn't raise β so the vendor lookup silently found nothing and
created a duplicate vendor.
Query parameters must not live in the path. I wrote
client.post(f"/bill?operation=delete", ...)
. httpx replaces a URL's query
string with its own params
, so operation=delete
was stripped and every
delete became a silent no-op returning 200. Deletes now verify
status == "Deleted"
in the response.
Closing entries are dated at period end, never today. Dated today, July's
adjustments corrected nothing in July and silently added $2,000 to August.
None of these were findable by reasoning. All of them were findable by running
the thing against real data every day.
The uncomfortable question for any agent system is: how do you know it isn't
making things up?
My answer was to shrink the job until confabulation has nowhere to live.
Every number is computed in code. Concentration percentages, ageing buckets,
duplicate-bill detection, the materiality floor β all arithmetic, all in Python,
all handed to the specialist. The model decides whether something matters. It
is never asked whether something is true, because it is never asked to produce
a figure.
Resolution is declared, never inferred. My first version treated a
specialist's silence about a finding as "fixed". That closed three live overdue
invoices that were still very much unpaid. Now a specialist has to name what it
believes is resolved, and silence is recorded as an observation instead.
A failed specialist closes nothing. Each owns a set of finding kinds; only
kinds whose owner completed successfully are eligible for closure.
Findings are deduplicated on sha256(kind + ":" + subject)
, which is why the
counters mean anything:
overdue_invoice/1024 seen 43Γ open 5 days
revenue_gap/net_income seen 38Γ open 5 days
A dashboard can be built in an afternoon. A record showing the same invoice
observed forty-three times across seven consecutive days cannot be assembled
after the fact.
Three times, and all three were caught by reading its output rather than by any
harness:
Β£
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 str.translate
cannot fail and a prompt rule that has already failed twice does not deserve a third chance.That last one is the most instructive. It wasn't a model failure. It was me
handing an agent nothing and being surprised when it filled the gap.
The UI is two deliberately different places.
The owner's office is light, calm, three things: today's action, three
numbers, and a box to ask the CFO. That's it.
The CFO's desk is dark, dense, and shows the machinery β every finding with
its age and sighting count, the review graph drawn out, the run log with
durations, the month-end pipeline moving through Preparing β Waiting on you β Posting β Writing up β Closed.
The contrast carries the argument without a sentence of copy: left is the chatbot you expected, right is the machine that makes it true.
One small thing I'm oddly pleased with. The desk said "3 days without a gap",
and it was wrong. The number was round((now - watching_since) / 86400000)
β
elapsed time, not a day count. With a start time of 19:27 UTC it read one number
all morning and silently gained a day each evening, whether or not anything had
run.
It now counts distinct calendar days that actually have a completed review, and
unbroken
is a separate flag that can be false β the caption falls back to
"days reviewed" when a day is missed. A claim on a dashboard should be capable of
being untrue, or it isn't a claim.
I'd test the cold path sooner. The emailed approval link β the whole point
of the multi-day workflow β was broken for anyone without a session. The page
loaded fine; every API call it made returned 401. I never noticed because I was
always testing in a browser carrying an owner cookie. Private window, or the
test proves nothing.
I'd have written the ledger census script first. I spent time reasoning about
what was in the books when a thirty-line script could have told me. It later
caught the month picker offering February β a month with zero transactions.
I'd trust range-based edits less. Twice I replaced a block of code by
character range and silently deleted a function that lived inside it. Both times
the tests passed, because the tests didn't cover the UI.
The easiest version of this project would have been: connect a model to
QuickBooks, give it a few tools, and let the user ask questions.
I deliberately did not build that.
The useful work happens on a clock, not on a prompt. Evidence is gathered
deterministically. Specialists review it in parallel. Findings accumulate over
time instead of disappearing at the end of a chat. A month-end workflow can
for days and resume in a fresh process. And the human approval is a real boundary
before anything is posted.
That is the architecture I wanted to test for the hackathon: agents as a persistent operating system for a business process, not as a conversational skin over a set of APIs.
Google ADK 2.7.1 Β· Gemini 3.5 Flash on Vertex AI Β· QuickBooks Online (real
sandbox, OAuth rotating in Firestore) Β· Cloud Run Β· Firestore Β· Cloud Scheduler
Β· Vertex AI Agent Engine for suspended workflow state.
Roughly 6,700 lines of Python and 2,400 of front end, with no build step β the
UI is Tailwind's CDN over a hand-written token file, so the whole thing deploys
as one Python container.
Two scheduled jobs do the unattended work: seeding the ledger on weekdays at
06:00, and the review at 07:00. They have not missed a day.
I started by calling this an AI finance team. I increasingly think that is only
half right.
The product is financial attention.
Small businesses already have accounting systems and historical data. What they
often do not have is someone continuously looking across that information,
remembering what was wrong yesterday, checking whether it is still wrong today,
and pushing a process forward until it reaches a decision.
That is the gap I wanted to close.
The AI is not valuable because it can produce another summary of the books. It is
valuable when it creates a reliable operating loop:
observe β compute β judge β remember β ask β act β verify
The more I built, the more that loop mattered. The model is only one part of it.
The most useful discipline in this build wasn't a framework feature. It was
insisting that the system had to be checkable: every number traceable to code,
every claim on the dashboard capable of being false, every run logged unedited
including the failures.
Agents are easy to make impressive and hard to make trustworthy. Most of my time
went on the second one.
A useful business agent should still be working when nobody is talking to it.
That was my test for this project. Everything else β the multi-agent design, the
suspended workflows, the approval boundary, the audit trail and the UI β follows
from that requirement.
I created this project and this article for the purposes of entering the All things agentic hackathon (Taskmaster category). The business is fictional and the ledger is authored, but the QuickBooks integration, the journal entries, the scheduled runs and the accumulated findings are all real.