Skip to content
Blog

Money Movement Agents: Designing Tool Contracts for Transfers and Payouts

Letting an agent move money means strict tool contracts: typed amounts, idempotency keys, confirmation gates, and reversible paths. Patterns that keep finance teams sane.

Published on • October 15, 2026

AI Assistant

The moment an agent gets a tool that can move funds, it stops being a chatbot and becomes payment infrastructure. The failure modes change accordingly: a hallucinated parameter in a support agent produces a bad email; a hallucinated parameter in a payout agent produces a real, hard-to-reverse transaction and an incident review with your finance team in the room.

The good news is that most of the risk lives in one place — the tool contract. Not in the model, not in the prompt, but in the typed boundary between what the model can say and what the system will actually do. This post covers the contract patterns that make agentic money movement reviewable.

Start from the rail, not the model

Before designing anything, write down the properties of the payment rail you’re calling — bank transfers, card payments, wallet payouts, crypto all differ. Then design the tool so the model can’t express anything the rail can’t honor.

Stripe’s documentation is a useful reference model here because its API encodes these decisions explicitly:

  • Amounts are integers in the smallest currency unit. 2499 means 24.99 USD (or 24.99 in any zero-decimal-safe representation). Floats never cross the boundary.
  • Every POST supports an Idempotency-Key header. The client generates a unique key (Stripe suggests a V4 UUID or another random string with enough entropy), up to 255 characters, and retries with the same key return the original saved response instead of creating a second object. Keys are pruned after roughly 24 hours, after which reuse creates a new request — and Stripe errors if reused parameters don’t match the original.
  • GET and DELETE don’t need keys — they’re idempotent by definition.

The model should never choose these values. The runtime should.

The four layers of a transfer tool contract

Layer 1: Typed amounts and destinations

from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field, model_validator
from uuid import uuid4

Currency = Literal["USD", "EUR", "GBP", "VND"]

class TransferRequest(BaseModel):
    amount_minor: int = Field(gt=0, le=100_000_00)      # cents, hard-capped
    currency: Currency
    source_account: str = Field(pattern=r"^acct_[A-Za-z0-9]{16}$")
    destination: str = Field(pattern=r"^(acct_|card_|ba_)[A-Za-z0-9]+$")
    reference: str = Field(max_length=140)                # memo, not instruction
    purpose_code: Literal["payroll", "refund", "vendor", "disbursement"]

    @model_validator(mode="after")
    def reject_suspicious_split(self):
        # Contract-level rules beat prompt-level rules.
        if self.amount_minor >= 99_000_00 and self.purpose_code == "refund":
            raise ValueError("refunds over limit require manual review")
        return self

Design notes:

  • amount_minor is an integer with gt=0. The model cannot send 0.1 + 0.2, a negative amount, or a currency it wasn’t authorized for. Schema validation is a hard wall; prompt instructions are a suggestion the model may decline to follow.
  • Account identifiers are pattern-validated. The model fills in a known destination format; it cannot invent free-form strings that reach the payment API.
  • Caps and purpose codes live in the schema, so a violation fails before execution, with a message the model can read and correct.

Layer 2: Idempotency generated by the runtime

Never let the model produce an idempotency key — it will reuse one across two distinct transfers (that’s an economic bug) or mint a new one on retry (that defeats the key’s entire purpose). Generate it in the tool wrapper, derived from the request’s logical identity:

import hashlib, time

def idempotency_key(req: TransferRequest, run_id: str) -> str:
    # Stable per logical intent: same request + same run => same key.
    basis = f"{run_id}:{req.source_account}:{req.destination}:{req.amount_minor}:{req.currency}:{req.reference}"
    return hashlib.sha256(basis.encode()).hexdigest()[:40]

def submit_transfer(req: TransferRequest, run_id: str) -> TransferResult:
    key = idempotency_key(req, run_id)
    # Surface the key in logs so support can correlate retries.
    with span("transfer.submit", idempotency_key=key):
        return rail.create_transfer(req, idempotency_key=key)

Now a retry storm, a model loop, or a worker restart all collapse into one transfer. If the model calls submit_transfer twice with identical parameters in the same run, the rail sees one idempotent request — which is exactly what happened historically when a worker retried after a network timeout.

Layer 3: Confirmation gates for anything irreversible

Split “propose” from “commit” for transactions above a threshold or outside a whitelist:

@function_tool
async def propose_transfer(req: TransferRequest, ctx: ToolContext) -> Proposal:
    """Stage a transfer for approval. Does NOT move money."""
    proposal = proposals.create(req, proposed_by=ctx.agent, run_id=ctx.run_id)
    if req.amount_minor < AUTO_APPROVE_MINOR and is_known_destination(req.destination):
        proposals.auto_approve(proposal.id, policy="P-TRANSFER-04")
        return Proposal(status="approved", transfer_id=commit(proposal))
    return Proposal(status="pending_approval", proposal_id=proposal.id,
                    approver_queue="finance-review")

@function_tool
def commit_proposal(proposal_id: str, approval_token: str) -> TransferResult:
    """Execute a previously approved proposal. Requires a human-signed token."""
    p = proposals.get(proposal_id)
    verify(approval_token, p)   # signed by the approver, not the model
    return rail.create_transfer(p.request, idempotency_key=p.key)

The key detail is the approval_token. It is signed by the human approver’s identity through a separate channel (a Slack approval button, an internal console). The model can call commit_proposal all it likes; without a token minted by a human, verification fails. This is what keeps the gate out of the prompt — a gate the model can talk itself past isn’t a gate.

This mirrors how mature agent frameworks wire human-in-the-loop: the run pauses at an interruption, state is persisted, and the run resumes only when an external approval arrives.

Layer 4: A reversible path, deliberately designed

Every money-movement tool should ship with its inverse, and the contract should say when it applies:

  • Card payments → refund (POST /refunds), partial or full.
  • Bank transfers → recall/return where the rail supports it, plus a compensating transfer if not.
  • Payouts to connected accounts → reversal before settlement, dispute flow after.
@function_tool
def reverse_transfer(transfer_id: str, reason_code: str) -> ReversalResult:
    """Reverse a completed transfer. Irreversible past settlement cutoff."""
    t = rail.get_transfer(transfer_id)
    if t.settled_at and t.settled_at < now() - RAIL_CUTOFF:
        return ReversalResult(status="too_late", action_required="escalate_to_ops")
    return rail.reverse(transfer_id, reason=reason_code)

Make “too late to reverse” an explicit, returned state rather than an exception the model retries. Escalation to a human is a first-class outcome, not an error.

Practical pitfalls and gotchas

  • Prompt-level guardrails are advisory. “Never transfer more than $1,000” in the instructions is bypassable by prompt injection or plain model error. Put the cap in the Pydantic field constraint where it fails closed.
  • Retries without keys double-charge. Any agent runtime that retries tool calls (network timeouts, worker restarts) must carry the idempotency key across retries, derived from stable request identity — not uuid4() at call time.
  • Keys expire. Stripe’s keys are pruned after about 24 hours, and reuse with different parameters errors. Your own store needs the same discipline: keep a record of what each key was first used for, and treat a mismatch as a bug, not something to paper over.
  • Letting the model name the destination is the classic footgun. Restrict destinations to an allowlist or an account chosen earlier in the conversation by the user and bound into the run state.
  • Log the money, redact the payload. Store amounts, currencies, ids, and idempotency keys in your observability layer; keep full account details out. Sensitive-data capture is often on by default in tracing SDKs — turn it off or redact at your exporter.
  • The model should never see raw settlement errors. Translate rail failures into typed, actionable statuses (insufficient_funds, destination_closed, limit_exceeded) so the agent can respond correctly instead of dumping gateway HTML into the conversation.
  • Don’t put reversible and irreversible actions behind the same tool name. transfer and propose_transfer must be unmistakably different in both name and description; the model chooses tools by description under pressure.

A quick review checklist

Before shipping a money-moving tool, be able to answer yes to all five:

  1. Can every amount be expressed as a positive integer in minor units, within a schema-enforced cap?
  2. Is the idempotency key generated deterministically outside the model, and logged?
  3. Does any irreversible action require a human-signed approval token?
  4. Does the tool have a defined inverse or an explicit too_late state?
  5. If the model hallucinated every parameter, what’s the worst that could happen — and is that answer written down?

If the worst case is “an error message,” ship it. If the worst case is “an unauthorized six-figure disbursement,” the contract isn’t done.

Sources