Index Agentica

Bookkeeping agent stack

An agent that reconciles Stripe or Paddle revenue against the books in Xero, reads receipts and statements, and prepares draft entries for an accountant. It runs on read-only credentials, and every write needs a human.

Type
Stack
Author
Agentica Author
Published
Last verified

Use case

Match payouts and invoices from Stripe or Paddle to what is recorded in Xero each month, explain every difference with the IDs behind it, and draft (never post) the fixes, with read-only credentials and a human approving any write.

Components

RoleComponentWhy
Agent harnessClaude Agent SDKConnects the MCP servers and decides which tool calls run. An allowlist plus dontAsk mode lets read tools run without prompts and denies everything else, and a deny rule can hide whole groups of write tools from the model.
Accounting ledgerXero MCP ServerXero's official local server covers invoices, payments, bank transactions, contacts, the chart of accounts and reports. It has no read-only switch, but it requests exactly the OAuth scopes in XERO_SCOPES, so read-only scopes keep it read-only at Xero's end.
Card payments and invoicesStripe MCP ServerStripe's hosted server reads payouts, balance transactions, charges, refunds, invoices and subscriptions through one stripe_api_read tool, with writes in a separate stripe_api_write tool you can deny. Agent-tagged restricted keys are subject to Stripe's approval rules.
Subscription billing (merchant of record)Paddle MCP ServerFor businesses that sell through Paddle Billing. --tools=read-only loads only read tools, and Paddle API key permissions return 403 for anything the key was not granted.
Reconciliation sandboxE2BRuns the matching and totals code on exported data, so the model does not do arithmetic in its head. Turn its internet access off once the data is loaded.
Receipts and statementsMarkItDownTurns PDF and Excel statements, receipts and CSV exports into Markdown the agent can read, for documents that are not in any API.
TracingLangfuseKeeps every tool call and result, so an accountant can see which Stripe and Xero records each proposed entry came from.

How the pieces fit

Month-end bookkeeping is mostly matching. Money arrives through Stripe or Paddle, it lands in the bank, and it should show up in Xero as paid invoices and bank transactions. The agent's job is to find what doesn't match and explain why: a fee that wasn't booked, a refund with no credit note, a payout split across two bank lines. An accountant then decides what to post.

  1. Pull the month's money movements. From Stripe, list payouts, then the balance transactions in each one. The list endpoint takes a payout filter, which works for automatic payouts only. The payout object says so in reconciliation_status, which is completed when that list is available. Each balance transaction has amount, fee and net, where net is amount minus fee. From Paddle, list transactions and adjustments (refunds and credits), or read an existing CSV report.
  2. Pull what the books say. From Xero, list invoices, payments, bank transactions (each is marked reconciled or unreconciled), the chart of accounts and tax rates, and a trial balance or P&L for the period.
  3. Match outside the model. Export both sides to files and run the matching in the E2B sandbox, not in the conversation. The snippet below is a starting point.
  4. Read the documents that have no API. Bank statement PDFs, supplier receipts and spreadsheets go through MarkItDown.
  5. Report, then draft. The output is a list of matched items, unmatched items with the IDs on both sides, and proposed entries. Writing those entries is a separate, approved step (see below). Langfuse keeps the trace, so each proposal can be checked against the records it came from.

Keep it read-only

Each of the three servers handles "read-only" differently, so set it up at both ends: in the credential, and in the harness.

In the harness, auto-approve only read tools and hide the write tools. MCP tools are named mcp__<server>__<tool>. Allow rules accept a glob after the server name, and deny rules both block calls and remove the tool definitions. In dontAsk mode, any call that isn't approved is denied instead of prompting.

import os
from claude_agent_sdk import ClaudeAgentOptions

# Read-only Xero scopes. Give the Custom Connection only these, too.
XERO_READ_SCOPES = " ".join([
    "accounting.invoices.read", "accounting.payments.read",
    "accounting.banktransactions.read", "accounting.contacts.read",
    "accounting.settings.read", "accounting.reports.profitandloss.read",
    "accounting.reports.trialbalance.read",
])

options = ClaudeAgentOptions(
    mcp_servers={
        "xero": {
            "command": "npx",
            "args": ["-y", "@xeroapi/xero-mcp-server@0.0.17"],
            "env": {
                "XERO_CLIENT_ID": os.environ["XERO_CLIENT_ID"],
                "XERO_CLIENT_SECRET": os.environ["XERO_CLIENT_SECRET"],
                "XERO_SCOPES": XERO_READ_SCOPES,
            },
        },
        "stripe": {
            "type": "http",
            "url": "https://mcp.stripe.com",
            # An agent-tagged restricted key with read permissions only
            "headers": {"Authorization": f"Bearer {os.environ['STRIPE_AGENT_KEY']}"},
        },
    },
    allowed_tools=[
        "mcp__xero__list-*", "mcp__xero__get-*",
        "mcp__stripe__stripe_api_search", "mcp__stripe__stripe_api_details",
        "mcp__stripe__stripe_api_read",
    ],
    disallowed_tools=[
        "mcp__xero__create-*", "mcp__xero__update-*", "mcp__xero__delete-*",
        "mcp__xero__approve-*", "mcp__xero__revert-*", "mcp__xero__add-*",
        "mcp__stripe__stripe_api_write", "mcp__stripe__send_money",
        "mcp__stripe__transfer_money", "mcp__stripe__convert_currency",
    ],
    permission_mode="dontAsk",
)

For Paddle, add a server running npx -y @paddle/paddle-mcp@0.1.6 --tools=read-only with PADDLE_API_KEY and PADDLE_ENVIRONMENT in its env. Every tool it loads is then read-only, so mcp__paddle__* can go in the allowlist.

Writes: drafts first, and a human for each one

When the report is accepted, the fixes can be written, but not by the same unattended run. Use a second configuration with write scopes, and leave write tools out of allowed_tools, so each call reaches your canUseTool callback for a person to approve. That only works in default mode; dontAsk denies the call instead. Know what each Xero write does. In version 0.0.17 of the server:

On the Stripe side, writes go through stripe_api_write. Stripe can require human confirmation in the Dashboard for some actions, such as refunds, and your own approval rules can add others or block them outright.

Match payouts outside the model

The snippet below matches Stripe payouts to Xero bank transactions that receive money. A match needs the exact amount and an arrival date within a few days. Anything with zero or several candidates is sent to a person instead of being guessed. Stripe amounts are integers in the currency's minor unit, so it divides by 100. That is only right for two-decimal currencies; zero-decimal currencies such as JPY need no division.

from decimal import Decimal

def match_payouts(payouts, bank_lines, days=3):
    """payouts: Stripe payout dicts (amount in minor units, arrival_date as a date).
    bank_lines: Xero RECEIVE bank transactions as dicts with total (Decimal), date and id.
    Returns (matches, unmatched_payouts, unmatched_lines). Exact amount, date within +-days."""
    free = list(bank_lines)
    matches, missing = [], []
    for p in sorted(payouts, key=lambda p: p["arrival_date"]):
        want = Decimal(p["amount"]) / 100  # two-decimal currencies only
        hits = [b for b in free if b["total"] == want
                and abs((b["date"] - p["arrival_date"]).days) <= days]
        if len(hits) == 1:
            matches.append((p["id"], hits[0]["id"]))
            free.remove(hits[0])
        else:
            missing.append((p["id"], f"{len(hits)} candidate lines"))
    return matches, missing, [b["id"] for b in free]

For each payout that matches, check the next level down: the payout's balance transactions against the Xero invoices and payments for the same customers. That is where unbooked fees and refunds show up.

Xero's list-bank-transactions tool returns 10 transactions per page as text, newest first. A busy month takes many calls, and every result passes through the model's context. Have the agent page through once, write what it reads to a file, and do the rest of the work in the sandbox.

Test before you connect live books

Variations

Directory entries in this stack

Related

Sources

Machine-readable