< back to cookbook
RECIPE 01

AI-Operated Bookkeeping on Cloudflare

A step-by-step recipe for deploying double-entry accounting with an AI agent that can read receipts, enter transactions, and query your ledger.

Apr 17, 2026

What you're building

A personal double-entry bookkeeping system that runs entirely on Cloudflare's free/cheap tier. Your AI coding agent (Claude Code, Cursor, etc.) handles data entry — you describe a transaction or hand it a receipt photo, and it figures out the debits, credits, and account mapping. The API handles writes. The UI (if you build one) is read-only.

Total infrastructure cost: under $5/month. One Worker, one D1 database, one R2 bucket. No framework, no ORM, no build pipeline beyond what wrangler provides.

Why GnuCash's schema? GnuCash has been around since 1998. Its data model handles accounts, transactions, splits, budgets, and scheduled transactions. The SQL backend stores these in straightforward tables with GUID primary keys. We take that proven schema and deploy it to D1 with two modifications.

Prerequisites

  • A Cloudflare account (free tier works)
  • wrangler CLI installed and authenticated (npm install -g wrangler && wrangler login)
  • An AI coding agent — Claude Code, Cursor, Windsurf, or anything that can run shell commands
  • Basic understanding of double-entry bookkeeping (every transaction has two sides that balance)

Design the schema

Start from GnuCash's SQL backend. The core tables are defined in C++ files that map to SQL columns. Here's how they translate:

GnuCash tables you need

GnuCash tableYour tableWhat it stores
accountsaccountsChart of accounts (tree structure via parent_guid)
transactionstransactionsLedger entries (date, description, status)
splitssplitsThe actual money movement (account + amount per line)

GnuCash also has budgets, budget_amounts, and schedxactions tables. You can add these later if you need budgeting or recurring transactions — the schema maps directly. We skip them here to keep the initial deployment minimal.

GnuCash gives you the table structure. The business logic — balanced splits, the pending/posted/void lifecycle, account validation — you build in the Worker. These are standard double-entry accounting rules, not GnuCash-specific.

Two changes from GnuCash

1. Replace rational numbers with integer cents.

GnuCash stores amounts as a numerator/denominator pair (e.g. 1999/100 for $19.99). This supports any currency's decimal places. If you only work in USD, you don't need this — store integer cents in a single INTEGER column. $19.99 = 1999.

2. Add a business_guid column.

GnuCash assumes one file = one set of books. If you run multiple businesses (LLCs, side projects), add a business_guid foreign key to accounts, transactions, budgets, and schedxactions. One database serves all your businesses. API routes are prefixed with /:slug to scope everything.

The migration

sql-- 0001_initial.sql

CREATE TABLE accounts (
  guid            TEXT PRIMARY KEY,
  name            TEXT NOT NULL,
  type            TEXT NOT NULL CHECK(type IN ('ASSET','LIABILITY','EQUITY','INCOME','EXPENSE')),
  parent_guid     TEXT REFERENCES accounts(guid),
  code            TEXT,
  description     TEXT,
  is_hidden       INTEGER NOT NULL DEFAULT 0,
  is_placeholder  INTEGER NOT NULL DEFAULT 0,
  meta            TEXT
);

CREATE TABLE transactions (
  guid        TEXT PRIMARY KEY,
  posted_at   TEXT,
  entered_at  TEXT NOT NULL,
  description TEXT,
  num         TEXT,
  notes       TEXT,
  source_url  TEXT,
  status      TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','posted','void'))
);

CREATE TABLE splits (
  guid            TEXT PRIMARY KEY,
  tx_guid         TEXT NOT NULL REFERENCES transactions(guid),
  account_guid    TEXT NOT NULL REFERENCES accounts(guid),
  amount          INTEGER NOT NULL,  -- cents: positive = debit, negative = credit
  memo            TEXT,
  reconcile_state TEXT NOT NULL DEFAULT 'n' CHECK(reconcile_state IN ('n','c','y')),
  reconciled_at   TEXT
);

CREATE TABLE businesses (
  guid       TEXT PRIMARY KEY,
  name       TEXT NOT NULL,
  slug       TEXT NOT NULL UNIQUE,
  created_at TEXT NOT NULL
);

CREATE INDEX idx_splits_tx       ON splits(tx_guid);
CREATE INDEX idx_splits_account  ON splits(account_guid);
CREATE INDEX idx_tx_status       ON transactions(status);
CREATE INDEX idx_tx_posted_at    ON transactions(posted_at);
CREATE INDEX idx_accounts_parent ON accounts(parent_guid);

Splits must sum to zero. This is the core invariant. Every transaction has 2+ splits. Positive amounts are debits, negative amounts are credits. If you buy $50 of paint with a credit card: debit Paint & Materials +5000, credit Credit Card -5000. The API must enforce this on every write.

Create the infrastructure

bash# Create a new Worker project
npm create cloudflare@latest my-books -- --type worker-typescript
cd my-books

# Create the D1 database
npx wrangler d1 create my-books
# Copy the database_id from the output

# Create the R2 bucket (for receipts)
npx wrangler r2 bucket create my-books-docs

wrangler.toml

tomlname = "my-books"
main = "src/index.ts"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "DB"
database_name = "my-books"
database_id = "YOUR_DATABASE_ID"

[[r2_buckets]]
binding = "DOCS"
bucket_name = "my-books-docs"

Apply the schema migration:

bashnpx wrangler d1 migrations apply my-books --remote

Then seed your chart of accounts. A solopreneur typically needs:

  • Assets: Checking, Savings, Accounts Receivable, Equipment
  • Liabilities: Credit Card, Accounts Payable, Sales Tax Payable
  • Equity: Owner's Equity, Owner's Draws, Retained Earnings
  • Income: Service revenue accounts for what you sell
  • Expenses: Whatever you spend on — rent, software, fuel, materials, insurance, etc.

Build the API

The Worker is a single fetch() handler that parses URL segments and dispatches to route handlers. No framework needed.

ts// src/index.ts — simplified
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const segments = new URL(request.url).pathname
      .replace(/^\//, '').split('/').filter(Boolean);

    const slug = segments[0];
    const resource = segments[1];
    const rest = segments.slice(2);

    // Resolve slug to business
    const business = await env.DB.prepare(
      'SELECT * FROM businesses WHERE slug = ?'
    ).bind(slug).first();
    if (!business) return err('Not found', 404);

    switch (resource) {
      case 'accounts':     return handleAccounts(request, env, rest, business.guid);
      case 'transactions': return handleTransactions(request, env, rest, business.guid);
      case 'pending':      return handlePending(request, env, rest, business.guid);
      case 'docs':         return handleDocs(request, env, rest, business.guid);
      case 'agent':        return handleAgent(request, env, rest, business.guid);
      case 'context':      return handleContext(request, env, business);
      default:             return err('Not found', 404);
    }
  },
};

Routes you need

EndpointWhat it does
GET /:slug/accountsList accounts with computed balances (sum of posted splits)
POST /:slug/transactionsCreate transaction. Validate: 2+ splits, sum to zero, accounts exist, not placeholders. Enters as pending.
POST /:slug/pending/:guid/postApprove a pending transaction (changes status to posted)
POST /:slug/pending/:guid/voidVoid a transaction (posted entries can never be deleted)
POST /:slug/docsUpload a receipt. Deduplicate by SHA-256 hash of file content.
POST /:slug/agent/queryReadonly SQL proxy. Accept SELECT, reject everything else.
GET /:slug/contextReturn full schema + account tree + business rules as markdown. How the AI agent bootstraps itself.

Transaction validation (the important part)

This is the code that enforces double-entry integrity. Every transaction POST must pass these checks:

ts// Reject if fewer than 2 splits
if (!body.splits || body.splits.length < 2)
  return err('At least 2 splits required');

// Reject if splits don't balance
const total = body.splits.reduce((sum, s) => sum + s.amount, 0);
if (total !== 0)
  return err(`Splits must sum to 0 (got ${total})`);

// Reject if any account doesn't exist, belongs to another business, or is a placeholder
for (const split of body.splits) {
  const acct = await env.DB.prepare(
    'SELECT is_placeholder FROM accounts WHERE guid = ? AND business_guid = ?'
  ).bind(split.account_guid, businessGuid).first();

  if (!acct) return err('Account not found in this business');
  if (acct.is_placeholder) return err('Placeholder accounts cannot receive splits');
}

The agent/query endpoint

This is the most powerful endpoint. It lets your AI agent run any SELECT statement against D1. The Worker rejects writes and warns if the query doesn't scope by business_guid.

tsconst SELECT_PATTERN    = /^\s*SELECT\b/i;
const FORBIDDEN_PATTERN = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|ATTACH|DETACH|PRAGMA)\b/i;

if (!SELECT_PATTERN.test(sql)) return err('Only SELECT statements are allowed');
if (FORBIDDEN_PATTERN.test(sql)) return err('Statement contains forbidden keywords');

// Warn (don't block) if query might leak cross-business data
const scopeWarning = !sql.includes('business_guid') && !sql.includes(businessGuid)
  ? 'Warning: query does not filter by business_guid.'
  : undefined;

Lock it down

Two layers of authentication protect the API.

Layer 1: Cloudflare Access (perimeter)

  1. Go to Cloudflare Zero Trust > Access > Applications
  2. Create a self-hosted application pointing to your Worker's URL
  3. Add a policy (e.g., allow your email)
  4. Go to Service Auth > Service Tokens, create a token
  5. Save the Client ID and Client Secret — these are what your AI agent will use

Humans get a login page in the browser. The AI agent sends two headers instead:

CF-Access-Client-Id: <your client id>
CF-Access-Client-Secret: <your client secret>

Cloudflare Access validates either method, then injects a CF-Access-Jwt-Assertion header before the request reaches your Worker.

Layer 2: Worker JWT verification

The Worker doesn't trust the request just because it arrived. It verifies the JWT:

  1. Fetches JWKS from https://<your-team>.cloudflareaccess.com/cdn-cgi/access/certs
  2. Finds the signing key by kid
  3. Verifies the RSA signature
  4. Checks the audience matches your Access application
  5. Checks the token hasn't expired

Store your Access audience tag and team domain as Worker secrets:

bashnpx wrangler secret put CF_ACCESS_AUD
npx wrangler secret put CF_ACCESS_TEAM

Exception: the context endpoint. GET /:slug/context skips JWT verification at the Worker level. CF Access still authenticates the request at the perimeter, but the Worker doesn't double-check. This endpoint returns the schema and account tree — no sensitive data, and the AI agent needs it to bootstrap.

Connect your AI agent

This is the part that makes the system useful. Your AI agent needs three things:

A. Environment variables

Set these in your shell profile so your agent can access them:

bash# ~/.zshrc or ~/.bashrc
export BOOKS_API_URL="your-worker.your-subdomain.workers.dev"
export BOOKS_CF_CLIENT_ID="your-service-token-client-id"
export BOOKS_CF_CLIENT_SECRET="your-service-token-client-secret"

B. The context endpoint

Build a GET /:slug/context endpoint that returns everything the AI needs in one request: schema, account tree, business rules, API routes, and example operations. Return it as markdown.

This is how the agent orients itself. Instead of you explaining the schema every session, the agent fetches the context document and knows what accounts exist, what the API endpoints are, and what the validation rules are.

bash# The agent can bootstrap itself with one call:
curl -s "https://${BOOKS_API_URL}/my-business/context" \
  -H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
  -H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}"

C. A skill file

If you're using Claude Code, create a skill file so the agent remembers how to use the system across sessions:

markdown# .claude/skills/books/SKILL.md
---
name: books
description: Access and manage the bookkeeping system — enter transactions,
  upload receipts, check balances, query the ledger.
allowed-tools: Bash, Read, Glob, Grep
---

# How to use the books API

Source shell vars first: `source ~/.zshrc 2>/dev/null`

All API calls:
  curl -s "https://${BOOKS_API_URL}/<slug>/<resource>" \
    -H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
    -H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}" \
    -H "Content-Type: application/json"

## Workflow
1. Create transaction via POST (lands as pending)
2. Post it via POST /:slug/pending/:guid/post
3. Upload receipt via POST /:slug/docs if applicable

## Common Patterns
- Expense paid by credit card: debit expense +amount, credit CC -amount
- Owner funds business: debit Checking +amount, credit Owner's Equity -amount
- Client pays invoice: debit Checking +amount, credit Income -amount

For other agents (Cursor, Windsurf, etc.), put the same information in whatever project-level instruction file they support (.cursorrules, .windsurfrules, etc.).

The pattern: Context endpoint for live state (accounts, schema). Skill file for static instructions (auth, workflow, common patterns). The agent fetches context on demand and has the skill loaded at session start.

Enter your first transaction

With everything deployed, here's what a real interaction looks like. You drop a few receipt PDFs into a folder and tell your agent:

"Process the receipts in ~/receipts"

The agent reads each file, extracts the details (vendor, date, amount, items, payment method), maps them to your chart of accounts, and for each receipt:

1. Uploads the receipt to R2:

bashcurl -s "https://${BOOKS_API_URL}/my-business/docs" \
  -H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
  -H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}" \
  -F "file=@/Users/you/receipts/home-depot-03-16.pdf"

# Returns: { "key": "a1b2c3...pdf", "size": 155000, "duplicate": false }

2. Creates the transaction with the receipt linked:

The agent reads the PDF — Home Depot, Crescent 12" Measuring Wheel, $74.97 + $6.19 tax = $81.16, paid with Mastercard. It maps Equipment as the debit account, Credit Card as the credit, and builds the API call:

bashcurl -s "https://${BOOKS_API_URL}/my-business/transactions" \
  -H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
  -H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Home Depot — Crescent 12\" Measuring Wheel (Mastercard ...0413)",
    "posted_at": "2026-03-16T00:00:00Z",
    "source_url": "a1b2c3...pdf",
    "splits": [
      { "account_guid": "sc-equipment", "amount": 8116, "memo": "Measuring wheel $74.97 + tax $6.19" },
      { "account_guid": "sc-cc",        "amount": -8116, "memo": "Mastercard ...0413" }
    ]
  }'

3. Posts it:

bashcurl -s -X POST \
  "https://${BOOKS_API_URL}/my-business/pending/<guid>/post" \
  -H "CF-Access-Client-Id: ${BOOKS_CF_CLIENT_ID}" \
  -H "CF-Access-Client-Secret: ${BOOKS_CF_CLIENT_SECRET}"

The agent repeats this for every receipt in the folder. The Worker validates each one — 2 splits, they sum to zero, accounts exist and aren't placeholders. You end up with a clean ledger and every receipt stored in R2, linked to its transaction.

That's the workflow. You hand the agent a stack of receipts. It reads them, does the accounting, uploads the documents, and enters the transactions. You review the pending queue if you want, or let them post automatically.

What's next

  • Receipt compression. Phone photos are huge. Add WASM-based image compression in the Worker (@jsquash/jpeg) to shrink JPEG/PNG uploads before storing in R2.
  • Bank statement import. Parse CSV exports from your bank, match them against existing transactions, create entries for anything new.
  • Scheduled transactions. Add GnuCash's schedxactions table and build a Cron Trigger that auto-creates recurring entries (rent, subscriptions).
  • Reports. Use the agent/query endpoint to build P&L statements, balance sheets, expense breakdowns. The agent can write the SQL.
  • Read-only UI. A static page served from the Worker that shows accounts, balances, and recent transactions. No writes — that's the agent's job.