Introduction
This pattern delivers an Expense Report Auditing & Reimbursement Agent. It ingests employee expense reports, normalizes line items, validates receipts, enforces policy (per-diem, category caps, receipt requirements), detects anomalies, and—only if compliant—submits reimbursement for payment with a verifiable receipt. It never asserts success without downstream confirmations.
The Use Case
Finance teams must process high volumes of expenses with consistent, auditable rules. The agent categorizes each line, checks date/merchant plausibility, validates amounts against caps and per-diems, flags duplicates, requires approvals for exceptions, and either pays or returns the report with precise fixups. Every factual statement references a policy claim; every write returns a receipt (approval id, payment id).
Prompt Contract (agent interface)
# file: contracts/expense_audit_v1.yaml
role: "ExpenseAuditAgent"
scope: >
Audit expense reports against policy; request approvals or corrections; reimburse when compliant.
Ask once if critical fields are missing (report_id, employee_id, currency, lines[]).
output:
type: object
required: [summary, decision, totals, findings, citations, next_steps, tool_proposals]
properties:
summary: {type: string, maxWords: 90}
decision: {type: string, enum: ["reimburse","reject","need_approval","need_more_info","partial_reimburse"]}
totals:
type: object
required: [submitted_cents, allowed_cents, flagged_cents]
properties:
submitted_cents: {type: integer}
allowed_cents: {type: integer}
flagged_cents: {type: integer}
findings:
type: array
items:
type: object
required: [line_id, status, reason]
properties:
line_id: {type: string}
status: {type: string, enum: ["allowed","reduced","rejected","needs_receipt","needs_approval"]}
reason: {type: string}
citations: {type: array, items: {type: string}}
next_steps: {type: array, items: {type: string}, maxItems: 6}
tool_proposals:
type: array
items:
type: object
required: [name, args, preconditions, idempotency_key]
properties:
name: {type: string, enum: [NormalizeReport, ValidateReceipts, CheckPolicy, DetectDuplicates, RequestApproval, CreatePayment]}
args: {type: object}
preconditions: {type: string}
idempotency_key: {type: string}
policy_id: "expense_policy.v8"
citation_rule: "1–2 minimal-span claim_ids per factual sentence"
decoding:
narrative: {top_p: 0.9, temperature: 0.7}
bullets: {top_p: 0.82, temperature: 0.45}
Example claims (context to the model)
[
{"claim_id":"policy:meal:cap","text":"Meals are capped at $75 per traveler per day (tax+tip included).",
"effective_date":"2025-03-01","source_id":"doc:expense_policy_v8","span":"$75 per traveler per day"},
{"claim_id":"policy:lodging:per_diem","text":"Lodging per-diem is $220 per night; higher amounts require manager approval.",
"effective_date":"2025-03-01","source_id":"doc:expense_policy_v8","span":"$220 per night; require manager approval"},
{"claim_id":"policy:receipt:threshold","text":"Receipts are required for any line item >= $25.",
"effective_date":"2025-03-01","source_id":"doc:expense_policy_v8","span":"Receipts required >= $25"},
{"claim_id":"policy:alcohol:not_reimbursed","text":"Alcohol is not reimbursable unless a client event is pre-approved.",
"effective_date":"2025-03-01","source_id":"doc:expense_policy_v8","span":"Alcohol not reimbursable unless pre-approved"},
{"claim_id":"policy:duplicate:window","text":"Duplicate detection window is 30 days by amount±$2 and merchant fuzzy match.",
"effective_date":"2025-03-01","source_id":"doc:expense_policy_v8","span":"30 days, amount±$2, merchant fuzzy"}
]
Tool Interfaces (typed, with receipts)
# tools.py
from pydantic import BaseModel, Field
from typing import List, Optional, Dict
from datetime import date
class LineItem(BaseModel):
line_id: str
date: date
category: str # "meal" | "lodging" | "transport" | "other"
merchant: str
amount_cents: int
has_receipt: bool = False
notes: Optional[str] = None
class NormalizeReportArgs(BaseModel):
report_id: str
employee_id: str
currency: str
lines: List[LineItem]
class ValidateReceiptsArgs(BaseModel):
report_id: str
lines: List[LineItem]
class CheckPolicyArgs(BaseModel):
report_id: str
lines: List[LineItem]
class DetectDuplicatesArgs(BaseModel):
report_id: str
employee_id: str
lines: List[LineItem]
class RequestApprovalArgs(BaseModel):
report_id: str
approver_id: str
reason: str
lines: List[str] # line_ids
class CreatePaymentArgs(BaseModel):
report_id: str
employee_id: str
amount_cents: int
currency: str
class ToolReceipt(BaseModel):
tool: str
ok: bool
ref: str
message: str = ""
data: Optional[Dict] = None
# adapters.py (demo logic)
from tools import *
from datetime import timedelta
# pretend storage
HISTORY = {
# (employee_id, amount_cents, merchant_norm, date) -> line_id
}
def _norm_merchant(m: str) -> str:
return "".join(ch.lower() for ch in m if ch.isalnum())
def normalize_report(a: NormalizeReportArgs) -> ToolReceipt:
# demo: return as-is, marking currency normalized
return ToolReceipt(tool="NormalizeReport", ok=True, ref=f"norm-{a.report_id}",
data={"currency": a.currency.upper(), "lines": [li.model_dump() for li in a.lines]})
def validate_receipts(a: ValidateReceiptsArgs) -> ToolReceipt:
missing = [li.line_id for li in a.lines if li.amount_cents >= 2500 and not li.has_receipt]
return ToolReceipt(tool="ValidateReceipts", ok=len(missing)==0, ref=f"rcpt-{a.report_id}",
message="All receipts present" if not missing else "Missing receipts",
data={"missing_line_ids": missing})
def check_policy(a: CheckPolicyArgs) -> ToolReceipt:
findings = []
allowed_total = 0
flagged_total = 0
for li in a.lines:
status, reason = "allowed", ""
if li.category == "meal" and li.amount_cents > 7500:
status, reason = "reduced", "Meal cap $75" # reduce to cap
allowed_total += 7500
flagged_total += li.amount_cents - 7500
elif li.category == "lodging" and li.amount_cents > 22000:
status, reason = "needs_approval", "Over lodging per-diem $220"
allowed_total += 22000
flagged_total += li.amount_cents - 22000
elif "alcohol" in (li.notes or "").lower():
status, reason = "rejected", "Alcohol not reimbursable without pre-approval"
else:
allowed_total += li.amount_cents
findings.append({"line_id": li.line_id, "status": status, "reason": reason})
return ToolReceipt(tool="CheckPolicy", ok=True, ref=f"pol-{a.report_id}",
data={"findings": findings, "allowed_total": allowed_total, "flagged_total": flagged_total})
def detect_duplicates(a: DetectDuplicatesArgs) -> ToolReceipt:
dups = []
for li in a.lines:
key = (a.employee_id, li.amount_cents, _norm_merchant(li.merchant), li.date)
if key in HISTORY:
dups.append(li.line_id)
else:
HISTORY[key] = li.line_id
return ToolReceipt(tool="DetectDuplicates", ok=len(dups)==0, ref=f"dup-{a.report_id}",
message="No duplicates" if not dups else "Possible duplicates",
data={"duplicate_line_ids": dups})
def request_approval(a: RequestApprovalArgs) -> ToolReceipt:
return ToolReceipt(tool="RequestApproval", ok=True, ref=f"appr-{a.report_id}",
message=f"Sent to {a.approver_id}", data={"lines": a.lines})
def create_payment(a: CreatePaymentArgs) -> ToolReceipt:
return ToolReceipt(tool="CreatePayment", ok=True, ref=f"pay-{a.report_id}",
message="Payment queued", data={"amount_cents": a.amount_cents, "currency": a.currency})
Agent Loop (proposal → verification → execution → receipts)
# agent_expense_audit.py
import uuid, json
from typing import Any, Dict, List
from tools import *
from adapters import *
ALLOWED_TOOLS = {"NormalizeReport","ValidateReceipts","CheckPolicy","DetectDuplicates","RequestApproval","CreatePayment"}
def new_idem(): return f"idem-{uuid.uuid4()}"
def verify_proposal(p: Dict[str, Any]) -> str:
need = {"name","args","preconditions","idempotency_key"}
if not need.issubset(p): return "Missing proposal fields"
if p["name"] not in ALLOWED_TOOLS: return "Tool not allowed"
if p["name"]=="CreatePayment" and "amount_cents" not in p["args"]: return "Payment requires amount"
return ""
def execute(p: Dict[str, Any]) -> ToolReceipt:
n, a = p["name"], p["args"]
if n=="NormalizeReport": return normalize_report(NormalizeReportArgs(**a))
if n=="ValidateReceipts": return validate_receipts(ValidateReceiptsArgs(**a))
if n=="CheckPolicy": return check_policy(CheckPolicyArgs(**a))
if n=="DetectDuplicates": return detect_duplicates(DetectDuplicatesArgs(**a))
if n=="RequestApproval": return request_approval(RequestApprovalArgs(**a))
if n=="CreatePayment": return create_payment(CreatePaymentArgs(**a))
return ToolReceipt(tool=n, ok=False, ref="none", message="Unknown tool")
# --- Model shim (replace with your LLM call) ---
def call_model(contract_yaml: str, claims: List[Dict[str,Any]], report: Dict[str,Any]) -> Dict[str,Any]:
submitted = sum(li["amount_cents"] for li in report["lines"])
# naive expectations for demo; real values come from tool outputs
allowed = submitted - 1500
flagged = 1500
return {
"summary": f"Report {report['report_id']} audited; reimburse compliant lines and request approval for exceptions.",
"decision": "partial_reimburse",
"totals": {"submitted_cents": submitted, "allowed_cents": allowed, "flagged_cents": flagged},
"findings": [],
"citations": ["policy:meal:cap","policy:lodging:per_diem","policy:receipt:threshold","policy:alcohol:not_reimbursed","policy:duplicate:window"],
"next_steps": ["Normalize report","Validate receipts","Check policy caps/per-diem","Detect duplicates","Request approvals","Create payment for allowed total"],
"tool_proposals": [
{"name":"NormalizeReport",
"args":report,
"preconditions":"Inputs normalized for currency/fields.","idempotency_key": new_idem()},
{"name":"ValidateReceipts",
"args":{"report_id":report["report_id"],"lines":report["lines"]},
"preconditions":"Receipts present for >=$25.","idempotency_key": new_idem()},
{"name":"CheckPolicy",
"args":{"report_id":report["report_id"],"lines":report["lines"]},
"preconditions":"Apply category caps and exclusions.","idempotency_key": new_idem()},
{"name":"DetectDuplicates",
"args":{"report_id":report["report_id"],"employee_id":report["employee_id"],"lines":report["lines"]},
"preconditions":"Flag likely duplicates within 30 days.","idempotency_key": new_idem()},
{"name":"RequestApproval",
"args":{"report_id":report["report_id"],"approver_id":"M015","reason":"Over per-diem / reduced lines",
"lines":["l2"]},
"preconditions":"Only if any lines need approval.","idempotency_key": new_idem()},
{"name":"CreatePayment",
"args":{"report_id":report["report_id"],"employee_id":report["employee_id"],
"amount_cents":allowed,"currency":report["currency"]},
"preconditions":"Reimburse allowed total; approvals outstanding for exceptions.","idempotency_key": new_idem()}
]
}
def render_response(plan: Dict[str,Any], receipts: List[ToolReceipt]) -> str:
idx = {r.tool:r for r in receipts}
lines = [plan["summary"], ""]
lines.append(f"Decision: {plan['decision']}")
t = plan["totals"]
lines.append(f"Totals — submitted ${t['submitted_cents']/100:.2f}, allowed ${t['allowed_cents']/100:.2f}, flagged ${t['flagged_cents']/100:.2f}")
if idx.get("CheckPolicy"):
findings = idx["CheckPolicy"].data["findings"]
for f in findings:
lines.append(f"- Line {f['line_id']}: {f['status']} ({f['reason']})" if f['reason'] else f"- Line {f['line_id']}: {f['status']}")
if idx.get("ValidateReceipts") and not idx["ValidateReceipts"].ok:
lines.append(f"Missing receipts for: {', '.join(idx['ValidateReceipts'].data['missing_line_ids'])}")
if idx.get("DetectDuplicates") and not idx["DetectDuplicates"].ok:
lines.append(f"Possible duplicates: {', '.join(idx['DetectDuplicates'].data['duplicate_line_ids'])}")
if idx.get("RequestApproval") and idx["RequestApproval"].ok:
lines.append(f"Approval requested ({idx['RequestApproval'].ref}) for lines: {', '.join(idx['RequestApproval'].data['lines'])}")
if idx.get("CreatePayment") and idx["CreatePayment"].ok:
amt = idx["CreatePayment"].data["amount_cents"]/100
lines.append(f"Payment queued: ${amt:.2f} ({idx['CreatePayment'].ref})")
lines.append("\nNext steps:")
for s in plan["next_steps"]: lines.append(f"• {s}")
lines.append("\nCitations: " + ", ".join(plan["citations"]))
return "\n".join(lines)
def handle(report: Dict[str,Any]) -> str:
contract = open("contracts/expense_audit_v1.yaml").read()
claims: List[Dict[str,Any]] = [] # load policy claims
plan = call_model(contract, claims, report)
receipts: List[ToolReceipt] = []
for prop in plan["tool_proposals"]:
reason = verify_proposal(prop)
if reason:
receipts.append(ToolReceipt(tool=prop["name"], ok=False, ref="blocked", message=reason)); continue
r = execute(prop)
receipts.append(r)
if not r.ok and prop["name"] in {"CreatePayment"}: break
return render_response(plan, receipts)
if __name__ == "__main__":
example_report = {
"report_id":"R-784",
"employee_id":"U042",
"currency":"USD",
"lines":[
{"line_id":"l1","date":"2025-10-02","category":"meal","merchant":"Cafe Rio","amount_cents":8200,"has_receipt":True,"notes":"team dinner"},
{"line_id":"l2","date":"2025-10-03","category":"lodging","merchant":"HotelMax","amount_cents":25900,"has_receipt":True,"notes":"conference rate"},
{"line_id":"l3","date":"2025-10-03","category":"other","merchant":"Office Depot","amount_cents":1800,"has_receipt":False,"notes":"supplies"},
{"line_id":"l4","date":"2025-10-03","category":"meal","merchant":"BarCo","amount_cents":4600,"has_receipt":True,"notes":"alcohol"}
]
}
print(handle(example_report))
The Prompt You’d Send to the Model (concise and testable)
System:
You are ExpenseAuditAgent. Follow the contract:
- Ask once if report_id, employee_id, currency, or lines[] are missing.
- Cite 1–2 claim_ids per factual sentence using provided claims.
- Propose tools; never assert success without a receipt.
- Output JSON with keys: summary, decision, totals{}, findings[], citations[], next_steps[], tool_proposals[].
Claims (eligible only):
[ ... JSON array of expense policy claims like above ... ]
User:
Please audit and reimburse this report:
{
"report_id":"R-784","employee_id":"U042","currency":"USD",
"lines":[
{"line_id":"l1","date":"2025-10-02","category":"meal","merchant":"Cafe Rio","amount_cents":8200,"has_receipt":true,"notes":"team dinner"},
{"line_id":"l2","date":"2025-10-03","category":"lodging","merchant":"HotelMax","amount_cents":25900,"has_receipt":true,"notes":"conference rate"},
{"line_id":"l3","date":"2025-10-03","category":"other","merchant":"Office Depot","amount_cents":1800,"has_receipt":false,"notes":"supplies"},
{"line_id":"l4","date":"2025-10-03","category":"meal","merchant":"BarCo","amount_cents":4600,"has_receipt":true,"notes":"alcohol"}
]
}
How to adapt quickly
Wire ValidateReceipts
to your OCR/receipt-store and DetectDuplicates
to your ledger or expense system. Load per-diem tables by city/country and policy flags (alcohol, client events). Keep idempotency and minimal-span citations on every factual sentence. Enforce no implied writes—only reimburse with a CreatePayment
receipt. Ship as a feature-flagged bundle with canary and rollback and record golden traces (representative reports) for regression testing.