Agent Playbook, build tutorial
A walkthrough of how to build a billing agent for India: it generates a GST-correct invoice, registers the IRN and signed QR, sends it with a payment link, reconciles incoming payments and your GSTR-2B so input tax credit stops leaking, and chases overdue invoices politely until they are paid. 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.
Billing in India is invoicing, e-invoicing, collection, and tax reconciliation all at once, and each one quietly costs you. Invoices go out late and get chased later, so cash sits with customers: as of March 2024, a reported Rs 7.34 lakh crore was owed to Indian MSMEs in delayed payments, down from Rs 10.7 lakh crore in 2022 but still enormous (GAME, FISME, and C2FO). Meanwhile input tax credit leaks whenever your books and GSTR-2B do not match, and a missed IRN can make an invoice invalid.
A spreadsheet and reminders do not scale to that. An agent is different: it gets the GST right by construction, registers the IRN at issue, matches every payment, reconciles 2B every cycle, and chases on a steady, polite cadence, escalating to a human only when an invoice is disputed or an account needs a call.
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 reconcile, chase, and dispute flow three ways.
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 GST maths, the IRP registration, the bank-feed matching, and the GSTR-2B reconciliation are deterministic and identical in all three; only the orchestration and the model calls differ.
Every invoice flows through one loop: generated and registered, issued and tracked, reconciled, then either closed when paid or chased when overdue. A dispute pulls it to a human, and guardrails for idempotency, consent, and audit wrap the whole thing.
Your GSTIN and place of supply, a GSP or ASP for the IRP, a bank feed or statement source, your GSTR-2B, and the purchase register.
A model API key (Gemini, Claude, or both), your accounting or ERP (Tally, Zoho Books, Vyapar), a payment-link provider, and WhatsApp plus email.
Credit terms and chase cadence, opt-in and DPDP Act 2023 consent, an audit log, and who signs off on disputes and write-offs.
Install the packages and set your Gemini key. That is enough to run the runnable file below, which builds a GST-correct invoice and drafts a chase locally. For production you also connect, with provider-specific credentials, your GSP or ASP for IRN registration (ClearTax, Masters India), your bank feed for reconciliation, and WhatsApp for chasing.
# 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 |
|---|---|---|
| Invoice generation | Your accounting tool API (Tally, Zoho Books, Vyapar, ClearTax) | Create the invoice with the right GST fields: HSN or SAC, place of supply, and the correct CGST and SGST or IGST split. |
| E-invoicing | IRP via a GSP or ASP (ClearTax, Masters India, GSTN) | Register the invoice to get the IRN and signed QR. This is mandatory for B2B once turnover crosses Rs 5 crore, and the invoice is invalid without it. |
| Model | Gemini 2.5 Flash, Claude, or any model behind LangChain | Reads bank narrations, drafts on-brand chase messages, and classifies customer replies. The code tabs show the same call in all three. |
| Agent framework | Google ADK, the Claude SDK, or LangGraph | Drives each invoice through issue, reconcile, chase, and the dispute branch. Pick the one your team runs; the logic is identical. |
| Data | Your ERP API + Postgres | Holds invoices, payments, the purchase register, GSTR-2B, and an audit trail of every issue, chase, and match. |
| Messaging | WhatsApp Cloud API and email | Send the invoice and chase overdue amounts where the customer actually reads, with a payment link attached, opt-in respected. |
The model and orchestration steps show the same logic in Google ADK, the Claude SDK, and LangGraph. Use the tabs to switch. The GST, IRP, and reconciliation steps are deterministic Python and identical everywhere. These are the building blocks, and they call your own systems (the ERP, the GSP, the bank feed) by name; the complete file you can run today is in "Put it together" below.
Getting the tax right is the whole job, and it is deterministic code, not a model guess. From the order, the agent computes the line tax at the correct rate, then splits it: intra-state supply becomes CGST plus SGST, and inter-state becomes IGST, decided by comparing the seller and buyer place of supply. It carries the HSN code for goods or SAC for services, applies GST rounding, and builds the invoice through your accounting tool's API. This is the same in every framework.
from decimal import Decimal, ROUND_HALF_UP
def build_invoice(order, seller_state, buyer_state) -> dict:
cgst = sgst = igst = Decimal(0)
intra = seller_state == buyer_state # intra -> CGST+SGST, else IGST
for li in order.items:
tax = li.qty * li.rate * li.gst_rate / 100
if intra: cgst += tax / 2; sgst += tax / 2
else: igst += tax
base = sum(li.qty * li.rate for li in order.items)
total = (base + cgst + sgst + igst).quantize(Decimal("1"), ROUND_HALF_UP)
return {"items": order.items, "cgst": cgst, "sgst": sgst,
"igst": igst, "total": total} For B2B invoices once your turnover crosses Rs 5 crore, e-invoicing is mandatory: the invoice has to be registered on the Invoice Registration Portal, which returns an Invoice Reference Number and a digitally signed QR code. Without them the invoice is not valid for GST. The agent calls the IRP through your GSP or ASP at issue, stores the IRN and QR, and generates the e-way bill when goods cross the value threshold. This is a plain API call, the same in every framework.
# irp is your GSP/ASP client (ClearTax, Masters India, GSTN).
def register_einvoice(inv: dict) -> dict:
resp = irp.generate_irn(
seller_gstin=inv["seller_gstin"],
buyer_gstin=inv["buyer_gstin"],
payload=to_irp_schema(inv)) # IRP's mandated JSON shape
inv["irn"] = resp["Irn"]
inv["signed_qr"] = resp["SignedQRCode"] # printed on the PDF
if needs_eway(inv): # goods over the value threshold
inv["eway_bill"] = irp.generate_eway(inv)
return inv Now send it where the customer will see it. The agent renders the PDF with the IRN and QR on it, creates a payment link tied to the invoice number, and delivers both over WhatsApp or email. It records the invoice as issued with its due date computed from your payment terms, so the clock that drives reconciliation and chasing starts the moment it goes out.
def issue(inv: dict):
pdf = render_pdf(inv) # IRN + QR on the document
link = payments.create_link(inv["total"], ref=inv["number"]) # UPI / card
wa.send_document(inv["buyer_phone"], pdf,
caption=f"Invoice {inv['number']}. Pay here: {link}")
ledger.record(inv, status="issued", due=inv["date"] + inv["terms"]) Matching money to invoices is where hours disappear. The agent reads each credit from the bank feed, first trying an exact match on the invoice number in the remittance narration plus the amount, then a fuzzy match on the same payer and amount against open invoices. A confident match marks the invoice paid with the UTR; anything it cannot match cleanly goes to a human queue instead of being guessed. Deterministic, and the same in every framework.
def reconcile_payment(txn) -> None: # one bank-feed credit, with UTR
# 1) exact: invoice number in the narration + matching amount
inv = ledger.find(number=extract_ref(txn.narration), amount=txn.amount)
# 2) fuzzy: same payer + amount, open invoice, within a date window
inv = inv or ledger.fuzzy_match(payer=txn.payer, amount=txn.amount)
if inv:
ledger.mark_paid(inv, utr=txn.utr, on=txn.date)
else:
review_queue.add(txn, reason="unmatched_receipt") # human decides Input tax credit is real money, and it leaks when what the portal shows does not match what you booked. The agent compares each supplier invoice in your auto-drafted GSTR-2B against your purchase register, keyed on supplier GSTIN and invoice number. Anything in 2B you have not booked is credit to claim; any tax-amount mismatch is ITC at risk. This runs every cycle, so nothing is found the week after you have filed.
def reconcile_2b(gstr2b: list, purchase_register: list) -> None:
booked = {(p["supplier_gstin"], p["invoice_no"]): p for p in purchase_register}
for row in gstr2b:
key = (row["supplier_gstin"], row["invoice_no"])
if key not in booked:
flag(row, "in_2b_not_booked") # claim it, or fix your records
elif abs(row["tax"] - booked[key]["tax"]) > 1:
flag(row, "tax_mismatch") # ITC at risk until resolved Most invoices are not paid late on purpose, they are forgotten, so a steady, polite cadence collects more than sporadic angry calls. The cadence is deterministic, but the message itself is drafted by the model so it reads on-brand rather than robotic. The tabs show the same chase with the draft call in Gemini, Claude, and LangChain. Every chase is logged so it never sends twice, and the final step escalates to a human.
from google import genai
client = genai.Client()
CADENCE = [("due", "friendly"), (3, "reminder"), (7, "firm"), (15, "final")]
def chase(inv: dict):
days_over = (today() - inv["due"]).days
for trigger, tone in CADENCE:
if reached(days_over, trigger) and not chased(inv, tone):
text = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Write a {tone}, on-brand payment reminder.", str(inv)]).text
wa.send(inv["buyer_phone"], text, attach=payment_link(inv))
log_chase(inv, tone)
if tone == "final": escalate_to_human(inv) from anthropic import Anthropic
client = Anthropic()
CADENCE = [("due", "friendly"), (3, "reminder"), (7, "firm"), (15, "final")]
def chase(inv: dict):
days_over = (today() - inv["due"]).days
for trigger, tone in CADENCE:
if reached(days_over, trigger) and not chased(inv, tone):
text = client.messages.create(model="claude-opus-4-8", max_tokens=300,
messages=[{"role": "user",
"content": f"Write a {tone}, on-brand payment reminder for {inv}."}]
).content[0].text
wa.send(inv["buyer_phone"], text, attach=payment_link(inv))
log_chase(inv, tone)
if tone == "final": escalate_to_human(inv) from langchain_google_genai import ChatGoogleGenerativeAI
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash") # or ChatAnthropic(...)
CADENCE = [("due", "friendly"), (3, "reminder"), (7, "firm"), (15, "final")]
def chase(inv: dict):
days_over = (today() - inv["due"]).days
for trigger, tone in CADENCE:
if reached(days_over, trigger) and not chased(inv, tone):
text = llm.invoke(f"Write a {tone}, on-brand payment reminder for {inv}.").content
wa.send(inv["buyer_phone"], text, attach=payment_link(inv))
log_chase(inv, tone)
if tone == "final": escalate_to_human(inv) A reply changes everything, so the agent listens. When a customer responds over WhatsApp, the model classifies the intent into a fixed set: a promise to pay, a dispute, a partial payment, or other. A dispute immediately pauses chasing and routes to a human, because pursuing a contested bill makes things worse. A partial payment switches the invoice to expect-partial. The tabs show the same classification three ways.
from google import genai
from google.genai import types
client = genai.Client()
LABELS = ["will_pay", "dispute", "partial", "other"]
@app.post("/webhook/whatsapp")
def on_reply(payload: dict):
msg = parse_whatsapp(payload); inv = ledger.find_by_phone(msg.sender)
intent = client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Classify into {LABELS}: {msg.text}"]).text.strip()
handle_intent(inv, intent) # dispute -> hold + escalate, etc. from anthropic import Anthropic
client = Anthropic()
@app.post("/webhook/whatsapp")
def on_reply(payload: dict):
msg = parse_whatsapp(payload); inv = ledger.find_by_phone(msg.sender)
res = client.messages.create(model="claude-opus-4-8", max_tokens=64,
tools=[{"name": "set_intent", "input_schema": {"type": "object",
"properties": {"intent": {"type": "string",
"enum": ["will_pay", "dispute", "partial", "other"]}},
"required": ["intent"]}}],
tool_choice={"type": "tool", "name": "set_intent"},
messages=[{"role": "user", "content": f"Classify intent: {msg.text}"}])
handle_intent(inv, res.content[0].input["intent"]) from langchain_google_genai import ChatGoogleGenerativeAI
from pydantic import BaseModel
from typing import Literal
class Reply(BaseModel):
intent: Literal["will_pay", "dispute", "partial", "other"]
classifier = ChatGoogleGenerativeAI(
model="gemini-2.5-flash").with_structured_output(Reply)
@app.post("/webhook/whatsapp")
def on_reply(payload: dict):
msg = parse_whatsapp(payload); inv = ledger.find_by_phone(msg.sender)
handle_intent(inv, classifier.invoke(f"Classify intent: {msg.text}").intent) Reconciliation and chasing run on a schedule over open invoices. ADK composes the reconciler and chaser under a SequentialAgent and a Runner, the Claude SDK runs them in a tool-use loop, and LangGraph builds the reconcile-then-chase graph. Whichever you pick, the same guardrails apply: key every issue on an idempotency token so a retry never creates a duplicate invoice or a second IRN, never chase the same invoice twice in a day, message only opted-in numbers, and write every action to an immutable audit log while tracking DSO and ITC at risk.
from google.adk.agents import SequentialAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
ar_pipeline = SequentialAgent(name="ar_pipeline",
sub_agents=[reconciler, chaser]) # each an LlmAgent with the tools above
runner = Runner(agent=ar_pipeline, app_name="ar",
session_service=InMemorySessionService())
def guard(inv: dict) -> str | None:
if ledger.has_irn(inv) or ledger.seen(inv["idempotency_key"]):
return "duplicate" # never issue the same invoice twice
if last_chase(inv) and hours_since(last_chase(inv)) < 24:
return "too_soon" # never double-chase in a day
return None
# Run nightly over open invoices via cron / Cloud Scheduler. from anthropic import Anthropic
client = Anthropic()
TOOLS = [reconcile_tool, chase_tool] # JSON tool schemas
def run_ar(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("reconcile", lambda s: {"open": reconcile_open(s["bank_feed"])})
g.add_node("chase", lambda s: {"chased": [chase(inv) for inv in s["open"]
if overdue(inv)]})
g.add_edge(START, "reconcile")
g.add_edge("reconcile", "chase")
g.add_edge("chase", END)
ar_pipeline = g.compile() # invoke nightly via your scheduler
Here is a runnable file. It builds a GST-correct invoice from a sample
order and drafts a payment chase, then prints both. Replace SAMPLE_ORDER
and the state codes with your data. Save it as main.py and run
python main.py.
# main.py -- run: python main.py
# A runnable reference: builds a GST-correct invoice from a sample order and
# drafts a payment chase. Replace SAMPLE_ORDER and the state codes with your data.
import asyncio
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
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
# ---- a sample order (replace with your order source) ----
@dataclass
class Item:
desc: str; qty: float; rate: float; gst_rate: float
SAMPLE_ORDER = [Item("Widget", 50, 2000, 18)]
SELLER_STATE, BUYER_STATE = "KA", "MH" # different states -> IGST
# ---------------------------------------------------------
def build_invoice() -> dict:
"""Compute a GST-correct invoice (CGST+SGST intra-state, else IGST)."""
cgst = sgst = igst = Decimal(0)
intra = SELLER_STATE == BUYER_STATE
for li in SAMPLE_ORDER:
tax = Decimal(str(li.qty * li.rate * li.gst_rate / 100))
if intra: cgst += tax / 2; sgst += tax / 2
else: igst += tax
base = Decimal(str(sum(li.qty * li.rate for li in SAMPLE_ORDER)))
total = (base + cgst + sgst + igst).quantize(Decimal("1"), ROUND_HALF_UP)
return {"base": float(base), "cgst": float(cgst),
"sgst": float(sgst), "igst": float(igst), "total": float(total)}
def draft_chase(invoice_no: str, amount: float, days_overdue: int) -> str:
"""Draft a polite, on-brand payment reminder."""
tone = "friendly" if days_overdue <= 3 else "firm"
return client.models.generate_content(
model="gemini-2.5-flash",
contents=[f"Write a {tone}, one-paragraph payment reminder for invoice "
f"{invoice_no}, amount Rs {amount}, {days_overdue} days overdue."]).text
agent = LlmAgent(
name="billing_agent", model="gemini-2.5-flash",
instruction=("Call build_invoice and report the GST split and total. Then call "
"draft_chase for invoice INV-1 using that total, 7 days overdue, "
"and show the drafted message."),
tools=[build_invoice, draft_chase])
async def main():
runner = InMemoryRunner(agent=agent, app_name="billing")
session = await runner.session_service.create_session(app_name="billing", user_id="demo")
msg = types.Content(role="user", parts=[types.Part(text="Build the invoice and draft a chase.")])
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()) You should see the GST split (CGST and SGST, or IGST for an inter-state supply) with the total, followed by a drafted reminder. IRN registration and bank reconciliation need your GSP and bank credentials, so they stay in the build steps above rather than this local demo.
Finance sees one screen: what is overdue, the invoice and its IRN, the reconciliation status, and a single action to take.
| Approach | Best for | Effort | Cost | Control |
|---|---|---|---|---|
| Custom agent (build in-house) | You want your own rules, tight ERP and GSP fit, and control of the data | High | Build cost + API and model usage | Full |
| GST suite (ClearTax, Zoho Books, Tally add-ons) | Standard GST invoicing and e-invoicing, fast start | Low | Subscription per user or volume | Medium |
| No-code (n8n, Make, Zapier) | Low volume, simple reminders, a quick pilot | Low | Cheap, but brittle once GST logic grows | Low |
Compliance is built in, not bolted on. For B2B invoices above Rs 5 crore turnover, the agent registers the IRN and signed QR on the IRP at issue, generates the e-way bill when goods cross the value threshold, and keeps you inside the 30-day IRP reporting window that applies above Rs 10 crore turnover. The CGST, SGST, and IGST split follows the place of supply, and GSTR-2B is reconciled every cycle so input tax credit is claimed, not lost.
On the customer side, the contact details on every invoice are personal data under the DPDP Act 2023, so the agent chases only opted-in numbers on WhatsApp and falls back to email otherwise, honours a request to stop, and logs consent. Run the pipeline in your own cloud or on-premise where the sensitivity of invoices and bank data demands it.
Benchmarks vary by source and should be treated as ranges, not promises. These are from neutral industry reports and the GST rules, not vendor marketing.
| Metric | Manual | Agent |
|---|---|---|
| Money locked in delayed payments | Rs 7.34 lakh crore owed to MSMEs as of March 2024 (GAME, FISME, C2FO), down from Rs 10.7 lakh crore in 2022 | Every invoice is chased from day one, so less of yours sits unpaid |
| Days sales outstanding (DSO) | Drifts upward because chasing is ad hoc and late | Falls, because nothing is ever chased late and reminders never stop short |
| Input tax credit at risk | GSTR-2B reconciled late or only sampled, so ITC quietly leaks | Every line matched, and mismatches flagged before you file |
| E-invoice validity | Risk of missing the 30-day IRP reporting window for Rs 10 crore+ turnover | IRN registered at issue, so invoices stay valid for GST |
Sources: GAME, FISME, and C2FO Delayed Payments Report 3.0 (March 2024); CBIC and GSTN e-invoicing rules. Figures are industry ranges and current thresholds.
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 billing agents like this on ADK, the Claude SDK, or LangGraph, wired into your ERP, your GSP, and your bank feed. Or explore the ready-made versions, from the GST Invoicer to Payment Recon and the Invoice Chaser, in our agent suite.