I gave a small business a CFO that doesn't wait to be asked An engineer built an autonomous AI CFO for small businesses, using Google's Agent Development Kit to create a workflow that runs unattended daily reviews of financial data. The system, which has completed 40 reviews over 8 days with zero failures, separates autonomous work from decisions requiring human approval, ensuring the owner retains control over actions that change the books. 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: python @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: ... save proposals, compose the approval email ... 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' ⏸ PAUSED 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 paused 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 paused; 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 pause 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.