Introduction
This finance-domain agent automates short-term cash forecasting (7–45 days) and liquidity rebalancing across bank accounts and entities. It ingests receivables, payables, payroll schedules, tax events, FX settlements, and credit facility data; produces a forecast by currency and legal entity; checks against minimum operating cash and covenant buffers; and proposes sweeps, intercompany loans, or drawdowns. It never asserts success without downstream confirmations (bank wire/swift refs, ledger journal IDs, treasury workstation tickets).
The Use Case
Treasury teams juggle fragmented data (ERP, PSPs, banks) and time-sensitive decisions (fund payroll, avoid overdrafts, minimize idle cash, hedge FX). The agent consolidates data, generates a position/forecast, applies guardrails (min cash, counterparty limits, covenant headroom), and proposes executable actions: move excess to investment accounts, cover deficits via internal transfers, or draw/repay revolvers. All actions are receipt-based and auditable.
Prompt Contract (agent interface)
# file: contracts/treasury_cash_v1.yaml
role: "TreasuryCashAgent"
scope: >
Build a 30-day cash forecast by entity/currency; ensure buffers; propose and execute rebalancing with receipts.
Ask once if critical fields are missing (as_of_date, entities[], accounts[], inflows[], outflows[], fx_rates).
Propose tool calls; never assert success without a bank/ERP receipt.
output:
type: object
required: [summary, decision, positions, forecast, proposals, citations, next_steps, tool_proposals]
properties:
summary: {type: string, maxWords: 100}
decision: {type: string, enum: ["rebalance","hold","need_approval","need_more_info"]}
positions:
type: array
items:
type: object
required: [entity, currency, opening_cash, min_operating_cash]
properties:
entity: {type: string}
currency: {type: string}
opening_cash: {type: number}
min_operating_cash: {type: number}
forecast:
type: array
items:
type: object
required: [date, entity, currency, projected_cash]
properties:
date: {type: string}
entity: {type: string}
currency: {type: string}
projected_cash: {type: number}
proposals:
type: array
items:
type: object
required: [action, from_account, to_account, currency, amount, reason]
properties:
action: {type: string, enum: ["sweep","intercompany_loan","wire","revolver_draw","revolver_repay"]}
from_account: {type: string}
to_account: {type: string}
currency: {type: string}
amount: {type: number}
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: [IngestERP, IngestBankBalances, BuildForecast, CheckCovenants,
OptimizeRebalancing, CreateWire, PostIntercompany, RevolverAction, CreateJournal]
args: {type: object}
preconditions: {type: string}
idempotency_key: {type: string}
policy_id: "treasury_policy.v4"
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 provided to the model)
[
{"claim_id":"policy:buffer:min_operating_cash",
"text":"Each entity must hold at least 10 business days of operating cash.",
"effective_date":"2025-01-01","source_id":"doc:treasury_policy_v4","span":"10 business days"},
{"claim_id":"policy:covenant:revolver_lcr",
"text":"Liquidity coverage ratio (LCR) must remain above 1.1 at all times.",
"effective_date":"2025-01-01","source_id":"doc:treasury_policy_v4","span":"LCR above 1.1"},
{"claim_id":"policy:counterparty:limit",
"text":"No more than 25% of total cash may be held at a single counterparty.",
"effective_date":"2025-01-01","source_id":"doc:treasury_policy_v4","span":"<=25% per counterparty"}
]
Tool Interfaces (typed, with receipts)
# tools.py
from pydantic import BaseModel
from typing import List, Dict, Optional
from datetime import date
class IngestERPArgs(BaseModel):
as_of_date: date
inflows: List[Dict] # [{date, entity, currency, amount, source}]
outflows: List[Dict] # [{date, entity, currency, amount, sink}]
class IngestBankBalancesArgs(BaseModel):
as_of_date: date
accounts: List[Dict] # [{account_id, entity, currency, balance, bank}]
class BuildForecastArgs(BaseModel):
as_of_date: date
fx_rates: Dict[str, float] # e.g., {"EURUSD":1.08}
positions: List[Dict]
inflows: List[Dict]
outflows: List[Dict]
class CheckCovenantsArgs(BaseModel):
forecast: List[Dict]
positions: List[Dict]
counterparty_breakdown: Dict[str, float] # bank -> % of total cash
class OptimizeRebalancingArgs(BaseModel):
forecast: List[Dict]
positions: List[Dict]
min_operating_days: int
class CreateWireArgs(BaseModel):
from_account: str
to_account: str
currency: str
amount: float
value_date: date
class PostIntercompanyArgs(BaseModel):
from_entity: str
to_entity: str
currency: str
amount: float
value_date: date
interest_rate_bps: int
class RevolverActionArgs(BaseModel):
action: str # "draw" or "repay"
currency: str
amount: float
value_date: date
class CreateJournalArgs(BaseModel):
memo: str
entries: List[Dict] # [{gl, entity, currency, debit, credit}]
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 date
def ingest_erp(a: IngestERPArgs) -> ToolReceipt:
return ToolReceipt(tool="IngestERP", ok=True, ref=f"erp-{a.as_of_date}", data={"inflows":a.inflows,"outflows":a.outflows})
def ingest_bank_balances(a: IngestBankBalancesArgs) -> ToolReceipt:
return ToolReceipt(tool="IngestBankBalances", ok=True, ref=f"bal-{a.as_of_date}", data={"accounts":a.accounts})
def build_forecast(a: BuildForecastArgs) -> ToolReceipt:
# naive additive forecast per (entity,currency,date)
proj = {}
for p in a.positions:
key = (a.as_of_date, p["entity"], p["currency"])
proj[key] = p["opening_cash"]
for f in a.inflows:
key = (f["date"], f["entity"], f["currency"]); proj[key] = proj.get(key, 0) + f["amount"]
for o in a.outflows:
key = (o["date"], o["entity"], o["currency"]); proj[key] = proj.get(key, 0) - o["amount"]
series = [{"date":str(k[0]),"entity":k[1],"currency":k[2],"projected_cash":v} for k,v in sorted(proj.items())]
return ToolReceipt(tool="BuildForecast", ok=True, ref="fc-30d", data={"forecast": series})
def check_covenants(a: CheckCovenantsArgs) -> ToolReceipt:
# toy checks
breaches = []
for row in a.forecast:
if row["projected_cash"] < 0:
breaches.append({"date":row["date"],"entity":row["entity"],"type":"deficit","detail":"Projected negative cash"})
if max(a.counterparty_breakdown.values()) > 0.25:
breaches.append({"date":"rolling","entity":"ALL","type":"counterparty","detail":">25% at single bank"})
lcr_ok = True # pretend computed
if not lcr_ok:
breaches.append({"date":"rolling","entity":"ALL","type":"LCR","detail":"LCR below 1.1"})
return ToolReceipt(tool="CheckCovenants", ok=len(breaches)==0, ref="cov-check", data={"breaches":breaches})
def optimize_rebalancing(a: OptimizeRebalancingArgs) -> ToolReceipt:
# toy optimizer: if deficit appears, propose a sweep from the largest surplus
proposals = []
# find first projected deficit
deficits = [r for r in a.forecast if r["projected_cash"] < 0]
if deficits:
d = deficits[0]
proposals.append({"action":"sweep","from_account":"BANK_US_001","to_account":"BANK_US_PAYROLL",
"currency":d["currency"],"amount":abs(d["projected_cash"]), "reason":"Cover projected deficit"})
return ToolReceipt(tool="OptimizeRebalancing", ok=True, ref="opt-1", data={"proposals":proposals})
def create_wire(a: CreateWireArgs) -> ToolReceipt:
return ToolReceipt(tool="CreateWire", ok=True, ref=f"wire-{a.from_account}-{a.to_account}", message="SWIFT MT103 submitted")
def post_intercompany(a: PostIntercompanyArgs) -> ToolReceipt:
return ToolReceipt(tool="PostIntercompany", ok=True, ref=f"iclo-{a.from_entity}-{a.to_entity}", message="Intercompany loan posted")
def revolver_action(a: RevolverActionArgs) -> ToolReceipt:
return ToolReceipt(tool="RevolverAction", ok=True, ref=f"rev-{a.action}-{a.amount}", message="Revolver instruction queued")
def create_journal(a: CreateJournalArgs) -> ToolReceipt:
return ToolReceipt(tool="CreateJournal", ok=True, ref="jrnl-001", message="GL journal posted")
Agent Loop (proposal → verification → execution → receipts)
# agent_treasury_cash.py
import uuid, json
from typing import Any, Dict, List
from tools import *
from adapters import *
ALLOWED = {"IngestERP","IngestBankBalances","BuildForecast","CheckCovenants",
"OptimizeRebalancing","CreateWire","PostIntercompany","RevolverAction","CreateJournal"}
def new_idem(): return f"idem-{uuid.uuid4()}"
def verify_proposal(p: Dict[str,Any]) -> str:
req = {"name","args","preconditions","idempotency_key"}
if not req.issubset(p): return "Missing proposal fields"
if p["name"] not in ALLOWED: return "Tool not allowed"
return ""
def execute(p: Dict[str,Any]) -> ToolReceipt:
n, a = p["name"], p["args"]
if n=="IngestERP": return ingest_erp(IngestERPArgs(**a))
if n=="IngestBankBalances": return ingest_bank_balances(IngestBankBalancesArgs(**a))
if n=="BuildForecast": return build_forecast(BuildForecastArgs(**a))
if n=="CheckCovenants": return check_covenants(CheckCovenantsArgs(**a))
if n=="OptimizeRebalancing": return optimize_rebalancing(OptimizeRebalancingArgs(**a))
if n=="CreateWire": return create_wire(CreateWireArgs(**a))
if n=="PostIntercompany": return post_intercompany(PostIntercompanyArgs(**a))
if n=="RevolverAction": return revolver_action(RevolverActionArgs(**a))
if n=="CreateJournal": return create_journal(CreateJournalArgs(**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]], treasury_input: Dict[str,Any]) -> Dict[str,Any]:
return {
"summary": "30-day forecast built; one deficit detected; propose sweep to cover and maintain buffers.",
"decision": "rebalance",
"positions": treasury_input["positions"],
"forecast": [],
"proposals": [{"action":"sweep","from_account":"BANK_US_001","to_account":"BANK_US_PAYROLL",
"currency":"USD","amount":250000.0,"reason":"Cover day-5 payroll dip"}],
"citations": ["policy:buffer:min_operating_cash","policy:covenant:revolver_lcr","policy:counterparty:limit"],
"next_steps": ["Ingest ERP+banks","Build forecast","Check covenants/counterparty",
"Optimize rebalancing","Execute wires/IC/revolver","Post journals"],
"tool_proposals": [
{"name":"IngestERP","args":{"as_of_date":treasury_input["as_of_date"],
"inflows":treasury_input["inflows"],"outflows":treasury_input["outflows"]},
"preconditions":"Collect AR/AP/Payroll/Tax events.","idempotency_key": new_idem()},
{"name":"IngestBankBalances","args":{"as_of_date":treasury_input["as_of_date"],"accounts":treasury_input["accounts"]},
"preconditions":"Current cash by account.","idempotency_key": new_idem()},
{"name":"BuildForecast","args":{"as_of_date":treasury_input["as_of_date"],"fx_rates":treasury_input["fx_rates"],
"positions":treasury_input["positions"],"inflows":treasury_input["inflows"],"outflows":treasury_input["outflows"]},
"preconditions":"Assemble 30-day series.","idempotency_key": new_idem()},
{"name":"CheckCovenants","args":{"forecast":[],"positions":treasury_input["positions"],"counterparty_breakdown":{"BANK_US":0.22,"BANK_EU":0.18}},
"preconditions":"Ensure buffers/LCR/counterparty limits.","idempotency_key": new_idem()},
{"name":"OptimizeRebalancing","args":{"forecast":[],"positions":treasury_input["positions"],"min_operating_days":10},
"preconditions":"Find cheapest feasible actions.","idempotency_key": new_idem()},
{"name":"CreateWire","args":{"from_account":"BANK_US_001","to_account":"BANK_US_PAYROLL","currency":"USD","amount":250000.0,
"value_date":treasury_input["as_of_date"]},
"preconditions":"Execute sweep with same-day value.","idempotency_key": new_idem()},
{"name":"CreateJournal","args":{"memo":"Treasury sweep BANK_US_001 -> BANK_US_PAYROLL",
"entries":[
{"gl":"1010","entity":"US","currency":"USD","debit":0,"credit":250000.0},
{"gl":"1010","entity":"US-Payroll","currency":"USD","debit":250000.0,"credit":0}
]},
"preconditions":"Mirror cash move in GL.","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']}")
if idx.get("BuildForecast"):
lines.append(f"Forecast points: {len(idx['BuildForecast'].data['forecast'])}")
if plan["proposals"]:
for p in plan["proposals"]:
lines.append(f"- Proposed {p['action']} {p['amount']:.2f} {p['currency']} from {p['from_account']} to {p['to_account']} — {p['reason']}")
if idx.get("CreateWire") and idx["CreateWire"].ok:
lines.append(f"\nWire submitted: {idx['CreateWire'].ref} ({idx['CreateWire'].message})")
if idx.get("CreateJournal") and idx["CreateJournal"].ok:
lines.append(f"Journal posted: {idx['CreateJournal'].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(treasury_input: Dict[str,Any]) -> str:
contract = open("contracts/treasury_cash_v1.yaml").read()
claims: List[Dict[str,Any]] = [] # load treasury policy claims
plan = call_model(contract, claims, treasury_input)
receipts: List[ToolReceipt] = []
for prop in plan["tool_proposals"]:
err = verify_proposal(prop)
if err:
receipts.append(ToolReceipt(tool=prop["name"], ok=False, ref="blocked", message=err)); continue
r = execute(prop); receipts.append(r)
if not r.ok and prop["name"] in {"CreateWire","RevolverAction","PostIntercompany"}: break
return render_response(plan, receipts)
if __name__ == "__main__":
example = {
"as_of_date": "2025-10-20",
"positions":[{"entity":"US","currency":"USD","opening_cash":1_200_000.0,"min_operating_cash":500_000.0}],
"accounts":[{"account_id":"BANK_US_001","entity":"US","currency":"USD","balance":1_200_000.0,"bank":"BANK_US"},
{"account_id":"BANK_US_PAYROLL","entity":"US","currency":"USD","balance":100_000.0,"bank":"BANK_US"}],
"inflows":[{"date":"2025-10-22","entity":"US","currency":"USD","amount":300_000.0,"source":"AR"}],
"outflows":[{"date":"2025-10-25","entity":"US","currency":"USD","amount":550_000.0,"sink":"Payroll"}],
"fx_rates":{"EURUSD":1.08}
}
print(handle(example))
The Prompt You’d Send to the Model (concise and testable)
System:
You are TreasuryCashAgent. Follow the contract:
- Ask once if as_of_date, entities/accounts, inflows/outflows, or fx_rates are missing.
- Cite 1–2 claim_ids per factual sentence using provided claims.
- Propose tools; never assert success without a bank/ERP receipt.
- Output JSON with: summary, decision, positions[], forecast[], proposals[], citations[], next_steps[], tool_proposals[].
Claims (eligible only):
[ ... JSON array of treasury policies like above ... ]
User:
Build a 30-day USD forecast and propose rebalancing:
{
"as_of_date":"2025-10-20",
"positions":[{"entity":"US","currency":"USD","opening_cash":1200000.0,"min_operating_cash":500000.0}],
"accounts":[{"account_id":"BANK_US_001","entity":"US","currency":"USD","balance":1200000.0,"bank":"BANK_US"},
{"account_id":"BANK_US_PAYROLL","entity":"US","currency":"USD","balance":100000.0,"bank":"BANK_US"}],
"inflows":[{"date":"2025-10-22","entity":"US","currency":"USD","amount":300000.0,"source":"AR"}],
"outflows":[{"date":"2025-10-25","entity":"US","currency":"USD","amount":550000.0,"sink":"Payroll"}],
"fx_rates":{"EURUSD":1.08}
}
How to adapt quickly
Connect IngestERP to your ERP/AP/AR and payroll systems; IngestBankBalances to bank APIs or your treasury workstation; and map CreateWire/RevolverAction/PostIntercompany to your payment rail/revolver provider with strict idempotency keys. Encode buffers, LCR, and counterparty limits as claimable policy facts. Store wire SWIFT refs, GL journal IDs, and optimizer rationale in your audit trail. Canary policy changes and optimizers behind flags, with golden traces (payroll weeks, quarter-end tax weeks) in CI to prevent regressions.