Agent Playbook, build tutorial
A walkthrough of how to build a reconciliation agent that matches money across the places it moves: your payment gateway settlements, your bank statement, your marketplace payouts, and your own sales ledger. It normalizes every source into one shape, matches on reference and amount, scores the fuzzy cases, checks each settlement against the fee it should have cost, and routes only the genuine exceptions to a person. Every code step is shown in three frameworks, Google ADK, the Claude SDK, and LangGraph, so you can build it on the stack your team already knows. The code here is the agent's core logic; standing it up in production also means adding the setup, integrations, and hardening around it.
Reconciliation is the unglamorous work of proving that the money you think you earned is the money that actually arrived. For an Indian SME it is rarely one comparison. A single sale can pass through a payment gateway that deducts a fee and settles a day or two later, a marketplace that nets commission and returns, and a bank account that shows only the final credit, while your books record the gross. Matching all of that by hand, every month, is slow, and the gaps are where money quietly leaks: a gateway fee charged at the wrong rate, a settlement that never landed, a short payment booked as paid, a refund counted twice.
A spreadsheet does not scale to thousands of rows across four sources. An agent does. It pulls each source, normalizes them to one shape, matches the obvious cases in deterministic code, checks every payout against the net it should have been, and surfaces only the handful that need a human, with the reason already attached. If you want to see the idea on your own export before you build, the free data analyzer already flags where the numbers do not add up.
You do not build the agent loop from scratch. A framework gives you the moving parts, and you supply the logic: a step is a model plus an instruction plus a few typed tools. The code tabs on the build steps show the same match, classify, and post flow three ways, so you can read it in the stack you already use.
Google ADK composes the steps with a SequentialAgent and a Runner, the Claude SDK drives a tool-use loop, and LangGraph builds an explicit graph. The normalization, the exact and fuzzy matching, and the settlement check are deterministic and identical in all three; only the orchestration and the model calls differ.
Every source flows through one loop: ingested and normalized, matched exactly then fuzzily, checked for fees, then either posted when confident or sent to a person when not. Exceptions are classified and a fix is proposed; guardrails for idempotency, tolerance, and audit wrap the whole thing.
Your sales ledger or ERP, a bank feed or statement source, gateway settlement reports, and any marketplace payout reports you sell through.
A model API key (Gemini, Claude, or both), your gateway and marketplace APIs or report exports, and your accounting system (Tally, Zoho Books) for posting.
Your amount tolerance and auto-post ceiling, the chart of accounts for corrections, DPDP Act 2023 handling, and who approves exceptions and write-offs.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which reconciles sample sources and classifies an exception locally. For production you also connect, with provider-specific credentials, your gateway and marketplace report sources, your bank feed, and your accounting system for posting approved entries.
# Python 3.10 or newer
python -m venv venv && source venv/bin/activate
# The runnable path below uses Google ADK + Gemini:
pip install google-adk google-genai
# Your Gemini key from aistudio.google.com/apikey:
export GEMINI_API_KEY="your-key-here" | Layer | Pick | Why |
|---|---|---|
| Ingestion | Gateway, bank, and marketplace sources (Razorpay, PayU, Cashfree, your bank, Amazon, Flipkart) | Pull settlements, bank credits, and order data. Where there is no clean API, parse the settlement report or bank statement (CSV, Excel, MT940) instead of re-keying it. |
| Normalization | A common transaction schema in Python | Every source names its columns differently. Map them all to one shape (date, amount, reference, counterparty, source) so matching is uniform and testable. |
| Matching engine | Deterministic Python: exact, then scored fuzzy | Match on reference and amount first, then a scored pass on amount, date window, and counterparty. This is code you can unit-test, not a model guess. |
| Model | Gemini 2.5 Flash, Claude, or any model behind LangChain | Reads messy bank narrations, classifies each leftover exception (fee, short pay, timing, chargeback), and drafts the correcting entry. The code tabs show the same call three ways. |
| Agent framework | Google ADK, the Claude SDK, or LangGraph | Drives ingest, match, check, classify, and post, with a human-review branch. Pick the one your team runs; the logic is identical. |
| Data and audit | Postgres or your ERP API, plus an immutable log | Holds the ledger, the matched and unmatched sets, the tolerance rules, and an audit trail of every match, every posting, and every override. |
The model and orchestration steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The normalization, matching, and settlement steps are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the ledger, the bank feed, the gateway report) by name; the complete file you can run today is in "Put it together" below.
Reconciliation fails before it starts when each source is a different shape. So the first job is plumbing, not intelligence: pull the gateway settlement, the bank statement, and your sales ledger, then map each one to a single common record. A payout report calls it 'settlement_amount', the bank calls it 'credit', your ledger calls it 'amount'. Once they all become the same Txn shape, every later step treats them identically. This is plain Python, the same in every framework.
from dataclasses import dataclass
from datetime import date
@dataclass
class Txn:
source: str # "gateway" | "bank" | "ledger"
ref: str # order id, UTR, or settlement id
amount: float # signed: credit positive, debit negative
on: date
party: str = ""
def normalize(rows: list[dict], source: str, cols: dict) -> list[Txn]:
# cols maps THIS source's column names onto the common shape
return [Txn(source, str(r[cols["ref"]]), float(r[cols["amount"]]),
parse_date(r[cols["date"]]), str(r.get(cols.get("party", ""), "")))
for r in rows]
# Now gateway, bank, and ledger are all list[Txn], ready to match the same way. Most lines reconcile themselves, so clear those first and cheaply. The agent indexes the ledger by reference, then for each bank or gateway line looks up the same reference and confirms the amount agrees within a tolerance (a rupee, to absorb rounding). A hit is a confident, auditable match and needs no model and no human. Everything that does not match exactly falls through to the next pass. Deterministic, and identical in every framework.
def exact_match(ledger: list[Txn], feed: list[Txn], tol: float = 1.0):
by_ref = {t.ref: t for t in ledger if t.ref}
matched, leftovers = [], []
for t in feed:
m = by_ref.get(t.ref)
if m and abs(m.amount - t.amount) <= tol:
matched.append((m, t)) # same reference, same amount
else:
leftovers.append(t) # send to the fuzzy pass
return matched, leftovers
# Reference plus amount within tolerance is a match you can defend in an audit. The leftovers are where judgement usually creeps in, so replace judgement with a score. For each unmatched line the agent looks for an open ledger entry whose amount is within tolerance and whose date is within a window, and rewards a matching counterparty. That produces a confidence between 0 and 1. Only matches above a gate are accepted; weaker ones are never auto-closed, they go to a human with the suggestion attached. The score is what keeps a plausible-but-wrong match out of your books.
def fuzzy_match(open_ledger: list[Txn], leftovers: list[Txn],
days: int = 3, tol: float = 1.0, gate: float = 0.6):
pairs, review = [], []
for t in leftovers:
best, score = None, 0.0
for m in open_ledger:
if abs(m.amount - t.amount) > tol: continue
if abs((m.on - t.on).days) > days: continue
s = 1 - abs((m.on - t.on).days) / (days + 1) # closer date scores higher
if same_party(m.party, t.party): s += 0.3
if s > score: best, score = m, round(s, 2)
(pairs if best and score >= gate else review).append((t, best, score))
return pairs, review # review = low confidence, a human decides A gateway payment almost never matches its order on amount, because the gateway keeps a fee and adds GST on that fee before settling. The fee depends on the rail, a percentage on cards and a small flat fee on UPI, which carries no percentage charge, so the rate is a parameter you set per method. That is not a mismatch to chase, it is arithmetic to verify. The agent recomputes the net it expects, gross minus the fee minus GST on the fee, and compares it to what actually landed. A zero gap means the difference was just the fee and the order is fine; a negative gap is a short settlement or a wrong fee rate worth flagging. This is deterministic and is where fee leakage gets caught.
def check_settlement(gross: float, net: float,
fee_rate: float = 0.02, gst: float = 0.18, tol: float = 1.0):
fee = round(gross * fee_rate, 2)
expected_net = round(gross - fee - fee * gst, 2) # GST applies on the fee
gap = round(net - expected_net, 2)
if abs(gap) <= tol:
return "ok", gap # the difference was only the fee
return ("short_settlement" if gap < 0 else "over_credit"), gap
# Run on every payout: this is how a fee charged at the wrong rate gets noticed. What is left is the genuinely ambiguous: a credit with a cryptic narration, a gap that is not just a fee, a debit you did not expect. Here the model earns its place, reading the narration and the numbers and sorting each exception into a fixed set of labels so the right workflow can run. It is classification into a closed list, not free text, so the output is predictable. The tabs show the same classification in Gemini, Claude, and LangChain; the labels never change.
from google import genai
client = genai.Client()
LABELS = ["bank_charge", "short_payment", "timing_difference",
"missing_settlement", "duplicate", "chargeback", "other"]
def classify(txn: Txn, gap: float) -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Classify this reconciliation exception into one of {LABELS}. "
f"Reply with the label only. "
f"Narration: {txn.party} {txn.ref}. Gap: Rs {gap}."]).text.strip() from anthropic import Anthropic
client = Anthropic()
def classify(txn: Txn, gap: float) -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=64,
tools=[{"name": "set_label", "input_schema": {"type": "object",
"properties": {"label": {"type": "string", "enum": [
"bank_charge", "short_payment", "timing_difference",
"missing_settlement", "duplicate", "chargeback", "other"]}},
"required": ["label"]}}],
tool_choice={"type": "tool", "name": "set_label"},
messages=[{"role": "user",
"content": f"Classify: {txn.party} {txn.ref}, gap Rs {gap}."}])
return res.content[0].input["label"] from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
from typing import Literal
class Exception_(BaseModel):
label: Literal["bank_charge", "short_payment", "timing_difference",
"missing_settlement", "duplicate", "chargeback", "other"]
classifier = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Exception_)
def classify(txn: Txn, gap: float) -> str:
return classifier.invoke(
f"Classify: {txn.party} {txn.ref}, gap Rs {gap}.").label A labelled exception still needs a fix, and the safe pattern is propose then approve. The model drafts the correcting journal entry in plain double-entry terms, a bank charge debited to charges, a short payment held in a suspense account, so a person sees exactly what would post and signs off before anything touches the books. The agent never posts an exception on its own. The tabs show the same draft three ways.
from google import genai
client = genai.Client()
def propose_entry(txn: Txn, label: str, gap: float) -> str:
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Propose a one-line double-entry journal to clear a "
f"'{label}' of Rs {gap} for reference {txn.ref}. "
f"Format exactly: Dr <account> / Cr <account> Rs <amount>."]).text.strip()
# Output is a suggestion shown to a reviewer, not something that posts itself. from anthropic import Anthropic
client = Anthropic()
def propose_entry(txn: Txn, label: str, gap: float) -> str:
res = client.messages.create(model="claude-opus-4-8", max_tokens=120,
messages=[{"role": "user", "content":
f"Propose a one-line double-entry journal to clear a '{label}' of "
f"Rs {gap} for reference {txn.ref}. "
f"Format: Dr <account> / Cr <account> Rs <amount>."}])
return res.content[0].text.strip() from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
def propose_entry(txn: Txn, label: str, gap: float) -> str:
return llm.invoke(
f"Propose a one-line double-entry journal to clear a '{label}' of "
f"Rs {gap} for reference {txn.ref}. "
f"Format: Dr <account> / Cr <account> Rs <amount>.").content.strip() Now act on the split. Matches that cleared exactly or scored above the gate are marked reconciled with their reference and the source, all auditable. Everything else, the low-confidence pairs and the labelled exceptions, lands in a review queue with its suggestion, its confidence, and the proposed entry, so a person works a short list instead of the whole statement. Nothing ambiguous is ever closed automatically. Deterministic, and the same in every framework.
def settle(matched, review, auto_post: bool = True) -> dict:
posted = 0
for (m, t) in matched: # exact or above the gate
if auto_post:
ledger.mark_reconciled(m, t) # within tolerance, written to audit
posted += 1
for (t, suggestion, score) in review: # below the gate or an exception
review_queue.add(t, suggestion=suggestion, confidence=score)
return {"posted": posted, "to_review": len(review)}
# A clean run posts the obvious and hands a human only what truly needs a decision. The pieces run on a schedule over each day's sources. ADK composes the matcher and the classifier under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds an explicit match-then-classify graph. Whichever you pick, the same guardrails apply: key every source row on an idempotency token so a re-run never reconciles the same line twice, hold the amount tolerance and the auto-post ceiling as config rather than guesses, never auto-post an exception, and write every match, posting, and override to an immutable audit log while tracking match rate and leakage caught.
from google.adk.agents import SequentialAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
recon_pipeline = SequentialAgent(name="recon_pipeline",
sub_agents=[matcher, classifier]) # each an LlmAgent with the tools above
runner = Runner(agent=recon_pipeline, app_name="recon",
session_service=InMemorySessionService())
def guard(row: Txn, gap: float, ceiling: float = 5000) -> str | None:
if ledger.seen(row.ref): return "already_reconciled" # idempotent
if abs(gap) > ceiling: return "needs_human" # too big to auto-post
return None
# Run nightly over the day's gateway, bank, and ledger feeds via Cloud Scheduler. from anthropic import Anthropic
client = Anthropic()
TOOLS = [match_tool, classify_tool, post_tool] # JSON tool schemas
def run_recon(messages: list):
while True:
resp = client.messages.create(model="claude-opus-4-8", max_tokens=1024,
tools=TOOLS, messages=messages)
if resp.stop_reason != "tool_use":
return resp
messages.append({"role": "assistant", "content": resp.content})
for b in resp.content:
if b.type == "tool_use":
out = dispatch(b.name, b.input)
messages.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": b.id, "content": str(out)}]}) from langgraph.graph import StateGraph, START, END
g = StateGraph(dict)
g.add_node("match", lambda s: {"matched": reconcile(s["feeds"])})
g.add_node("classify", lambda s: {"exceptions": classify_exceptions(s["matched"])})
g.add_edge(START, "match")
g.add_edge("match", "classify")
g.add_edge("classify", END)
recon_pipeline = g.compile() # invoke nightly via your scheduler
Here is a runnable file. It reconciles sample orders against bank credits,
shows that one order's smaller bank amount is exactly the gateway fee, and
asks the model to classify a settlement that never landed. Replace the
SAMPLE lists with your data. Save it as main.py and run
python main.py.
# main.py -- run: python main.py
# A runnable reference: reconciles a few orders against bank credits, checks one
# gateway settlement against its expected fee, and asks the model to classify a
# missing settlement. Replace the SAMPLE lists with your data.
import asyncio
from dataclasses import dataclass
from datetime import date
from google import genai
from google.genai import types
from google.adk.agents import LlmAgent
from google.adk.runners import InMemoryRunner
client = genai.Client() # reads GEMINI_API_KEY
@dataclass
class Txn:
source: str; ref: str; amount: float; on: date; party: str = ""
# ---- sample sources (replace with your ledger, bank, and gateway data) ----
LEDGER = [Txn("ledger", "ORD-1", 10000.0, date(2026, 6, 1), "Acme"),
Txn("ledger", "ORD-2", 5000.0, date(2026, 6, 1), "Globex"),
Txn("ledger", "ORD-3", 8000.0, date(2026, 6, 2), "Initech")]
BANK = [Txn("bank", "ORD-1", 9764.0, date(2026, 6, 2), "RAZORPAY"), # net of fee + GST
Txn("bank", "ORD-2", 5000.0, date(2026, 6, 3), "Globex")] # ORD-3 never landed
# --------------------------------------------------------------------------
def reconcile(tol: float = 1.0) -> dict:
"""Exact match on reference + amount; report matched, unmatched, missing."""
by_ref = {t.ref: t for t in LEDGER}
matched, unmatched = [], []
for b in BANK:
m = by_ref.get(b.ref)
(matched if m and abs(m.amount - b.amount) <= tol else unmatched).append(b.ref)
landed = {b.ref for b in BANK}
missing = [t.ref for t in LEDGER if t.ref not in landed] # booked, no money in
return {"matched": matched, "unmatched": unmatched, "missing_settlement": missing}
def check_settlement(gross: float, net: float,
fee_rate: float = 0.02, gst: float = 0.18) -> dict:
"""Expected net = gross - fee - GST on the fee. A zero gap means it was just the fee."""
fee = round(gross * fee_rate, 2)
expected = round(gross - fee - fee * gst, 2)
return {"expected_net": expected, "actual_net": net, "gap": round(net - expected, 2)}
def classify_gap(detail: str) -> str:
"""Ask the model what kind of exception this is (closed label set)."""
labels = ["bank_charge", "short_payment", "timing_difference",
"missing_settlement", "duplicate", "chargeback", "other"]
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Classify this reconciliation exception into one of {labels}. "
f"Reply with the label only. Detail: {detail}"]).text.strip()
agent = LlmAgent(
name="recon_agent", model="gemini-2.5-flash",
instruction=("Call reconcile and report matched, unmatched, and missing "
"settlements. For ORD-1 call check_settlement(gross=10000, net=9764) "
"to show its bank amount is only the gateway fee, not a problem. "
"Then call classify_gap for the missing ORD-3 settlement. "
"Summarize what is fine and what a human must chase."),
tools=[reconcile, check_settlement, classify_gap])
async def main():
runner = InMemoryRunner(agent=agent, app_name="recon")
session = await runner.session_service.create_session(app_name="recon", user_id="demo")
msg = types.Content(role="user", parts=[types.Part(text="Reconcile and report exceptions.")])
async for event in runner.run_async(user_id="demo", session_id=session.id, new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
if __name__ == "__main__":
asyncio.run(main()) With a valid key, the agent reconciles ORD-2, uses check_settlement to show ORD-1's smaller bank amount is exactly the fee plus GST (a zero gap), and flags ORD-3 as a missing settlement to chase. The summary is model-generated, so the exact wording varies. Live gateway, bank, and marketplace feeds need their own credentials, so they stay in the build steps above rather than this local demo.
A reviewer sees one screen: the exceptions queue, the transaction in question, the suggested match with its confidence, and a single action to take.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You reconcile across several sources, gateway, bank, and marketplace, with your own rules, and want the data in-house | High | Build cost + API and model usage | Full |
| Recon feature in accounting or recon software (Tally, Zoho Books bank rules, marketplace recon tools) | Standard single-source bank reconciliation, fast start | Low | Subscription per user or volume | Medium |
| No-code (n8n, Make, Zapier) | Low volume, one or two sources, a quick pilot | Low | Cheap, but brittle once the matching logic grows | Low |
Reconciliation touches your most sensitive records, so control of the data is part of the design. Bank statements and settlement reports carry personal data in line with the DPDP Act 2023, whose core obligations phase in through 2027, and payment data is kept in India in line with the RBI storage directive. You can run the whole pipeline in your own cloud or on-premise, so account numbers and transaction histories never leave infrastructure you control.
Every match, every posting, and every override is written to an immutable audit log with the reference, the amount, the confidence, and who approved it. That trail is what makes an automated reconciliation defensible to your auditor, and it is why nothing ambiguous, and nothing above your auto-post ceiling, is ever closed without a person.
These are directional, not promises, and depend on your volume and how many sources you reconcile. The point is the shape of the change: less time, less leakage, full coverage, fewer errors.
| Metric | Manual | Agent |
|---|---|---|
| Time spent at month-end close | Days of an accountant's month spent matching rows across statements by hand | Matched continuously, so the close is shorter and people review exceptions, not every line |
| Money lost to leakage | Fee overcharges, short settlements, missed marketplace credits, and duplicate payments slip past unnoticed | Every payout checked against the net it should have been, so leakage surfaces the same week, not at audit |
| Coverage | On high-volume days, transactions get sampled rather than fully reconciled | Every transaction is matched or queued with a confidence score, so nothing is skipped |
| Errors and rework | Manual matching mis-keys and double-books under time pressure | Deterministic matching within set tolerances, with an audit trail behind every decision |
Gateway fee and settlement-cycle norms reflect public pricing from Razorpay, PayU, and Cashfree; payment-data storage follows the RBI directive. Figures are ranges that vary by business, not guarantees.
Everything you need to know about the service and how it works. Can’t find an answer? Mail us at info@galific.com
Galific designs, builds, and runs reconciliation agents like this on ADK, the Claude SDK, or LangGraph, wired into your gateway, your bank feed, your marketplaces, and your ledger. Or explore the ready-made versions, from Payment Recon to Bank Reconciliation, in our agent suite.