{"slug": "i-gave-a-small-business-a-cfo-that-doesn-t-wait-to-be-asked", "title": "I gave a small business a CFO that doesn't wait to be asked", "summary": "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.", "body_md": "I built this project and wrote this post for the All things agentic hackathon, organized by Google.\n\n**What if a small business could have the operating rhythm of a Fortune 500 finance team without hiring one?**\n\nThat 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.\n\nThe 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.\n\nThe brief asked for a complete workflow rather than a chatbot — something that\n\ntakes action, sends the right information to the right places, and does the\n\nheavy lifting. That framing is a trap, and I nearly fell into it twice.\n\nThe trap is that a chatbot with tools *looks* like a workflow. You wire up some\n\nfunctions, the model calls them, things happen. It demos well for ninety\n\nseconds. But nothing happens unless someone is typing, and the moment you close\n\nthe tab the system stops existing.\n\nSo I set myself a harder test: **the thing has to do useful work on a morning\nwhen nobody opens it.**\n\nWhat I ended up with is a finance department for a business too small to have\n\none. An owner talks to a CFO. Behind the CFO, three specialists — a Treasurer, an\n\nAccountant and an Analyst — review the books every morning, close the month when\n\nit ends, and write the management accounts. The owner never talks to the\n\nspecialists. That constraint turned out to drive most of the architecture.\n\nThe important boundary is this: **autonomy does not mean unlimited authority**. The\n\nsystem can inspect, calculate, monitor, prepare and recommend on its own. When an\n\naction changes the books, it stops for explicit human approval. That separation\n\nbetween *work the agent can do* and *decisions the business must own* became one of\n\nthe central design rules of the project.\n\nAt the time of writing it has run **40 unattended reviews across 8\nconsecutive days with zero failures**, against a real QuickBooks sandbox.\n\nGoogle's Agent Development Kit gives you three orchestration models, and the\n\ninteresting design work was deciding which belonged where.\n\nMy first version had each agent call tools to fetch its evidence. The prompt had\n\nto *ask* for the data and hope. When a specialist skipped a tool call, it\n\nreviewed nothing — and reported that everything looked fine.\n\nThat is a horrible failure mode. It is indistinguishable from success.\n\nSo the evidence gathering became plain Python functions wired into the graph:\n\n```\nreview_workflow = Workflow(\n    name=\"cfo_daily_review\",\n    edges=[\n        (START, fetch_performance,    gather),\n        (START, fetch_trend,          gather),\n        (START, fetch_cash,           gather),\n        (START, fetch_receivables,    gather),\n        (START, fetch_ledger_quality, gather),\n        (START, fetch_open_findings,  gather),\n        (gather, treasurer,  judged),\n        (gather, accountant, judged),\n        (gather, analyst,    judged),\n        (judged, store_findings),\n    ],\n)\n```\n\nSix fetches, zero model calls, and they run because they are wired to `START`\n\n—\n\nnot because a model chose to. The specialists only start once the facts are on\n\nthe table.\n\nNote there are **two** joins. `gather`\n\nbundles the evidence so every specialist\n\nsees the whole picture; `judged`\n\nwaits for all three verdicts so nothing gets\n\npersisted against a partial view. I learned that one the hard way when a\n\ntimed-out specialist caused findings it owned to be marked resolved.\n\nThe other quiet win: `Runner(node=workflow)`\n\nruns with `new_message=None`\n\n. There\n\nis no fake \"please run your review now\" prompt anywhere in the system. The\n\nworkflow just runs.\n\n`mode=\"single_turn\"`\n\n, and that flag is the product\nADK sub-agents default to `chat`\n\nmode. In that mode the coordinator only gets\n\n`transfer_to_agent`\n\n— a serial handoff of the entire conversation to one\n\nspecialist, which never comes back.\n\nThink about what that means here. The owner asks their CFO a question about\n\ncash, and a Treasurer they have never met answers and keeps the conversation.\n\n```\ncfo = Agent(\n    name=\"cfo\",\n    sub_agents=[chat_treasurer, chat_accountant, chat_analyst],\n    ...\n)\n\nchat_treasurer = Agent(\n    name=\"treasurer\",\n    mode=\"single_turn\",          # <- the whole design, in one flag\n    tools=[get_cash_position, get_receivables, get_open_findings],\n    ...\n)\n```\n\nWith `single_turn`\n\n, ADK gives the CFO one delegation tool per specialist, runs\n\nthe relevant subset **in parallel**, returns each result, and the CFO answers in\n\nits own voice. The specialists never address the user.\n\nI like this because the product decision — *the owner hired a CFO, not a\ncommittee* — is enforced by the framework rather than by a prompt asking nicely.\n\nMonth-end close is the flagship workflow, and its defining feature is that it\n\n**stops and waits for a human, potentially for days.**\n\nPlenty of systems fake this. A status flag, a poll loop, a job that wakes up and\n\nasks \"approved yet?\". ADK 2 ships a real primitive:\n\n``` python\n@node(rerun_on_resume=True)\nasync def await_approval(ctx, node_input):\n    case = _load_case()\n    answer = ctx.resume_inputs.get(case.interrupt_id)\n\n    if answer is None:\n        # ... save proposals, compose the approval email ...\n        yield RequestInput(\n            interrupt_id=case.interrupt_id,\n            message=f\"Approve the {case.period_label} closing adjustments?\",\n            response_schema={\"type\": \"string\"},\n        )\n        return                     # the run genuinely stops here\n\n    approved = str(answer).strip().lower() in (\"approve\", \"approved\", \"yes\")\n    yield Event(output={\"approved\": approved, \"plan\": node_input})\n```\n\nBefore building anything on top of this I ran a spike as a hard gate, in two\n\ngenuinely separate processes:\n\n```\nprocess 1   [prepare] scanning the period (expensive work)\n            [ask_approval] pausing on 'close-approval'\n            ⏸ PAUSED\n\nprocess 2   --- RESUME (2 prior events) ---\n            [ask_approval] resumed with answer: 'approve'\n            [act] DECIDED -> 'approve'\n            ✓ resumed from a cold process and completed\n```\n\nThe important line is the one that **did not print**. `prepare`\n\nnever re-ran.\n\nThe workflow resumed at the node it stopped on, knowing only a session id and an\n\ninterrupt id — exactly the position an emailed approval link is in two days\n\nlater.\n\nTwo things I would tell anyone building this:\n\n**Use a stable interrupt_id.** Derive it from your case (\n\n`f\"{case_id}-approval\"`\n\n),**Keep your own record alongside.** ADK owns execution, but I store a\n\n`CloseCase`\n\nin Firestore too — an index, an audit trail, and the UI's read\n\nmodel. It also means a lost session is *detectable* rather than silent. If the\n\nsession store ever drops a paused run, the case is still sitting there saying it\n\nwas waiting.\n\nWhile filming a demo I clicked the **\"Not yet\"** button on the approval page,\n\nexpecting to come back later. The close carried on and marked the month closed.\n\nNothing reached QuickBooks — that guard held. But the month was signed off, and\n\nthe Analyst wrote management accounts for books that had never been corrected.\n\nThe cause is that **resuming is one-way**. Any answer restarts the run, and the\n\ngraph then proceeds to the end. \"Decline\" wasn't a way to stay paused; it was a\n\nway to run the whole workflow with a flag set.\n\nThe fix was to stop treating it as a decision:\n\n```\n// \"Not yet\" means come back later, so it must NOT resume the workflow.\nif (decision !== 'approve') {\n  location.href = signedIn ? '/inside' : '/login';\n  return;\n}\n```\n\nAnd the endpoint now refuses anything else, so no client can sign off a month\n\nwithout approving it. There are two options, not three, because the framework\n\nonly supports two.\n\nI could have mocked the accounting system. I'm glad I didn't, because almost\n\neverything I learned came from the API disagreeing with me.\n\n**The account name collision.** In this chart of accounts,\n\n`Sprinklers and Drip Systems`\n\nexists twice — once as Income, once as Expense.\n\nMatching on name alone posted a bill line to the income account and booked\n\n**negative revenue**.\n\nI did not find this. The agent did, in its morning review: *\"Negative revenue of\n−$288.00.\"* Which was a strange and quite good moment. The fix is an\n\n`AccountType`\n\nfilter on every cost posting.**Backslashes, not doubled quotes.** QuickBooks' query language looks like SQL\n\nand is not. `'Amy''s'`\n\nreturns a 400; `'Amy\\'s'`\n\nmatches. The nasty part is that\n\na failed lookup doesn't raise — so the vendor lookup silently found nothing and\n\ncreated a duplicate vendor.\n\n**Query parameters must not live in the path.** I wrote\n\n`client.post(f\"/bill?operation=delete\", ...)`\n\n. httpx *replaces* a URL's query\n\nstring with its own `params`\n\n, so `operation=delete`\n\nwas stripped and every\n\ndelete became a silent no-op returning 200. Deletes now verify\n\n`status == \"Deleted\"`\n\nin the response.\n\n**Closing entries are dated at period end, never today.** Dated today, July's\n\nadjustments corrected nothing in July and silently added $2,000 to August.\n\nNone of these were findable by reasoning. All of them were findable by running\n\nthe thing against real data every day.\n\nThe uncomfortable question for any agent system is: how do you know it isn't\n\nmaking things up?\n\nMy answer was to shrink the job until confabulation has nowhere to live.\n\n**Every number is computed in code.** Concentration percentages, ageing buckets,\n\nduplicate-bill detection, the materiality floor — all arithmetic, all in Python,\n\nall handed to the specialist. The model decides whether something *matters*. It\n\nis never asked whether something is *true*, because it is never asked to produce\n\na figure.\n\n**Resolution is declared, never inferred.** My first version treated a\n\nspecialist's silence about a finding as \"fixed\". That closed three live overdue\n\ninvoices that were still very much unpaid. Now a specialist has to name what it\n\nbelieves is resolved, and silence is recorded as an observation instead.\n\n**A failed specialist closes nothing.** Each owns a set of finding kinds; only\n\nkinds whose owner completed successfully are eligible for closure.\n\nFindings are deduplicated on `sha256(kind + \":\" + subject)`\n\n, which is why the\n\ncounters mean anything:\n\n```\noverdue_invoice/1024      seen 43×   open 5 days\nrevenue_gap/net_income    seen 38×   open 5 days\n```\n\nA dashboard can be built in an afternoon. A record showing the same invoice\n\nobserved forty-three times across seven consecutive days cannot be assembled\n\nafter the fact.\n\nThree times, and all three were caught by reading its output rather than by any\n\nharness:\n\n`£`\n\nbecause it inferred a currency from a landscaping\nbusiness. I fixed the prompt. It did it again. I fixed the prompt harder. Then\nI gave up and fixed it in code, because a `str.translate`\n\ncannot fail and a\nprompt 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\n\nhanding an agent nothing and being surprised when it filled the gap.\n\nThe UI is two deliberately different places.\n\nThe **owner's office** is light, calm, three things: today's action, three\n\nnumbers, and a box to ask the CFO. That's it.\n\nThe **CFO's desk** is dark, dense, and shows the machinery — every finding with\n\nits age and sighting count, the review graph drawn out, the run log with\n\ndurations, the month-end pipeline moving through *Preparing → Waiting on you →\nPosting → Writing up → Closed*.\n\nThe contrast carries the argument without a sentence of copy: *left is the\nchatbot you expected, right is the machine that makes it true.*\n\nOne small thing I'm oddly pleased with. The desk said \"3 days without a gap\",\n\nand it was wrong. The number was `round((now - watching_since) / 86400000)`\n\n—\n\nelapsed time, not a day count. With a start time of 19:27 UTC it read one number\n\nall morning and silently gained a day each evening, whether or not anything had\n\nrun.\n\nIt now counts distinct calendar days that actually have a completed review, and\n\n`unbroken`\n\nis a separate flag that **can be false** — the caption falls back to\n\n\"days reviewed\" when a day is missed. A claim on a dashboard should be capable of\n\nbeing untrue, or it isn't a claim.\n\n**I'd test the cold path sooner.** The emailed approval link — the whole point\n\nof the multi-day workflow — was broken for anyone without a session. The page\n\nloaded fine; every API call it made returned 401. I never noticed because I was\n\nalways testing in a browser carrying an owner cookie. Private window, or the\n\ntest proves nothing.\n\n**I'd have written the ledger census script first.** I spent time reasoning about\n\nwhat was in the books when a thirty-line script could have told me. It later\n\ncaught the month picker offering February — a month with *zero* transactions.\n\n**I'd trust range-based edits less.** Twice I replaced a block of code by\n\ncharacter range and silently deleted a function that lived inside it. Both times\n\nthe tests passed, because the tests didn't cover the UI.\n\nThe easiest version of this project would have been: connect a model to\n\nQuickBooks, give it a few tools, and let the user ask questions.\n\nI deliberately did not build that.\n\nThe useful work happens on a clock, not on a prompt. Evidence is gathered\n\ndeterministically. Specialists review it in parallel. Findings accumulate over\n\ntime instead of disappearing at the end of a chat. A month-end workflow can pause\n\nfor days and resume in a fresh process. And the human approval is a real boundary\n\nbefore anything is posted.\n\nThat is the architecture I wanted to test for the hackathon: **agents as a\npersistent operating system for a business process, not as a conversational skin\nover a set of APIs**.\n\nGoogle ADK 2.7.1 · Gemini 3.5 Flash on Vertex AI · QuickBooks Online (real\n\nsandbox, OAuth rotating in Firestore) · Cloud Run · Firestore · Cloud Scheduler\n\n· Vertex AI Agent Engine for suspended workflow state.\n\nRoughly 6,700 lines of Python and 2,400 of front end, with no build step — the\n\nUI is Tailwind's CDN over a hand-written token file, so the whole thing deploys\n\nas one Python container.\n\nTwo scheduled jobs do the unattended work: seeding the ledger on weekdays at\n\n06:00, and the review at 07:00. They have not missed a day.\n\nI started by calling this an AI finance team. I increasingly think that is only\n\nhalf right.\n\nThe product is **financial attention**.\n\nSmall businesses already have accounting systems and historical data. What they\n\noften do not have is someone continuously looking across that information,\n\nremembering what was wrong yesterday, checking whether it is still wrong today,\n\nand pushing a process forward until it reaches a decision.\n\nThat is the gap I wanted to close.\n\nThe AI is not valuable because it can produce another summary of the books. It is\n\nvaluable when it creates a reliable operating loop:\n\n**observe → compute → judge → remember → ask → act → verify**\n\nThe more I built, the more that loop mattered. The model is only one part of it.\n\nThe most useful discipline in this build wasn't a framework feature. It was\n\ninsisting that the system had to be *checkable*: every number traceable to code,\n\nevery claim on the dashboard capable of being false, every run logged unedited\n\nincluding the failures.\n\nAgents are easy to make impressive and hard to make trustworthy. Most of my time\n\nwent on the second one.\n\n**A useful business agent should still be working when nobody is talking to it.**\n\nThat was my test for this project. Everything else — the multi-agent design, the\n\nsuspended workflows, the approval boundary, the audit trail and the UI — follows\n\nfrom that requirement.\n\n*I created this project and this article for the purposes of entering the All things agentic hackathon (Taskmaster category).\nThe business is fictional and the ledger is authored, but\nthe QuickBooks integration, the journal entries, the scheduled runs and the\naccumulated findings are all real.*", "url": "https://wpnews.pro/news/i-gave-a-small-business-a-cfo-that-doesn-t-wait-to-be-asked", "canonical_source": "https://dev.to/fab-hita/i-gave-a-small-business-a-cfo-that-doesnt-wait-to-be-asked-5901", "published_at": "2026-08-24 14:52:25+00:00", "updated_at": "2026-08-24 15:14:16.547953+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-products", "developer-tools"], "entities": ["Google", "Agent Development Kit", "QuickBooks"], "alternates": {"html": "https://wpnews.pro/news/i-gave-a-small-business-a-cfo-that-doesn-t-wait-to-be-asked", "markdown": "https://wpnews.pro/news/i-gave-a-small-business-a-cfo-that-doesn-t-wait-to-be-asked.md", "text": "https://wpnews.pro/news/i-gave-a-small-business-a-cfo-that-doesn-t-wait-to-be-asked.txt", "jsonld": "https://wpnews.pro/news/i-gave-a-small-business-a-cfo-that-doesn-t-wait-to-be-asked.jsonld"}}