Development

Effect Classifiers for Tool Calls: Read, Write, Money

Antonio Evans10 min read

A three-column table on a white background.
Key points

Key points

  1. 1Label every tool a model can call with exactly one effect: read, write or money, and keep those labels in one table that both the router and the audit read.
  2. 2Reads run automatically, writes run automatically but are logged with before and after state, and money calls stop until a named person approves the exact arguments.
  3. 3Approved money calls execute once with a token that expires, and a tool missing from the table never runs.
  4. 4The tests that matter kill the process between the side effect and the journal, because that window is where duplicate charges appear.

Why retries, not model mistakes, cause most duplicate money calls

The frustrating part of a duplicate charge is that no line of code is wrong. An agent calls a refund tool, the tool succeeds, and the network drops the response on the way back. The harness retries, the tool has no memory of the first call, and the refund runs again. One documented case describes a customer refunded $89.99 four times from a single return because the response was lost and the retry was treated as a fresh attempt side effects and sagas.

The retry itself is correct behavior. Distributed systems are lossy, and silently dropping a request is worse than repeating one. The structural problem is that agent frameworks retry at the model layer: the model sees an error, decides to try again, and calls the tool again. Nothing in that loop tracks whether the first call already changed the world the idempotency problem. Prompt instructions like "only call the payment tool once" do not help, because the model genuinely does not know the first call succeeded.

Durable execution engines narrow the gap but do not close it by default. Restate journals each step so a restarted handler skips finished work, yet a worker killed after the external effect landed and before the result was journaled re-executed the step: a mock payment API received two charges in 3 of 3 trials, and an independent reporter described 30 of 30 trials against a receiver that could not deduplicate durable agents tested. Passing an idempotency key derived from durable state made the second request a no-op in 3 of 3 trials, as long as the receiver honored the key.

That is the case for classifying by effect rather than trusting the model. The classifier is not a safety label for a dashboard. It is the input to the routing decision that determines whether a call fires immediately, fires with a log, or waits for a person.

The one table the router and the audit both read

The table has one row per tool and exactly one effect value. The router reads it at call time to decide auto-run, auto-run with logging, or pause for approval. The audit reads the same rows after the fact to confirm that every money-class call had an approval record. Two readers, one source, no drift.

FieldPurposeExample
toolStable identifier the model requestsissue_refund
effectExactly one of read, write, moneymoney
reversibleCan the effect be undone without a second external actionfalse
approver_roleNamed role for the pause, empty for reads and writesfinance-lead
idempotency_key_sourceWhich durable value seeds the keyworkflowRunId:stepId
log_stateCapture before and after statetrue
sandbox_okSafe to exercise against fixtures in CItrue

Three rules keep the table honest.

First, effect is assigned by the team that owns the downstream system, not by the agent developer who wants fewer pauses. A CRM field update feels like a write, but if it triggers a contract workflow and a scheduled call, the traversal makes it money-class. One governance writeup describes exactly that scope creep: an agent asked to update a single property decided the account was ready to advance and touched status, which fired a chain of downstream workflows runtime enforcement.

Second, reversibility is separate from effect. A refund can be clawed back, an email cannot be unsent. Both are money-class when they spend or commit resources, but the reversibility field tells the approver how much friction to apply.

Third, a tool absent from the table does not run. That default resolves the hardest question in agent governance, which is not "what is this agent allowed to use" but "should this specific invocation, with these arguments, execute right now." Tool attachment during development "just in case" widens the breach radius, and a table with a deny-by-default fallback is the cheapest way to shrink it AI agent tool call security.

Worked table for eight tools

Here is the classifier for a small support and billing agent. Numbers are illustrative, not measured.

ToolEffectReversibleApproverWhy
lookup_customerreadn/anoneReturns a record, changes nothing
get_invoice_pdfreadn/anoneRead plus a signed URL
search_knowledge_basereadn/anoneRetrieval only
update_ticket_tagswriteyesnoneLogged before and after
send_emailmoneynosupport-leadIrreversible external communication
issue_refundmoneypartialfinance-leadMoves funds
apply_creditmoneypartialfinance-leadCreates a balance change
cancel_subscriptionmoneynoaccount-ownerEnds revenue and triggers dunning

Two entries deserve argument. send_email is classified money even though no money moves in the call. The reason is that irreversibility plus audit obligation makes it behave like a spend: once it leaves, revoking the credentials afterward is forensic, not protective, and an auditor asking "did a human authorize this specific operation" cannot be satisfied by an OAuth scope granted weeks earlier human-in-the-loop approval gates. If your organization insists email is a write, keep it in the write row but log the full rendered body, recipients and message ID, because the before and after state is what protects you when someone disputes what was sent.

cancel_subscription is money because a cancel that turns out to be wrong costs a win-back and often a refund. Compare that with update_ticket_tags, where the before state is recoverable from the log with one API call.

The table also answers a question that trips up reviewers: how many tools an agent actually needs. A support agent that summarizes the queue does not need send_email at all. Removing the row from that agent's scope is stronger than approving every call, and it takes one line in the per-agent allowlist.

Routing rules that follow from the effect

The effect decides the path. Write the router so the path is a function of the table row plus the call arguments, never of the tool name string matched inside the model output.

Read. Validate the tool name against the table, check the arguments against the JSON schema, execute, return the result. Reads never pause. If a read fails validation, return a schema error to the model so it can correct the call, which is the intended loop: the model proposes, the runtime validates and executes what is tool calling.

Write. Execute immediately, but capture before and after state in the same record as the call ID. The log entry needs a run ID, the caller fixture or user identity, the tool, the arguments, the prior state, the resulting state, and a cleanup rule when the test ran in a sandbox sandbox side effects. A write without before and after state is untraceable, and untraceable writes are the ones that generate support tickets.

Money. Three steps, in order, and none may be skipped:

  1. Create an approval request that freezes the exact arguments, hashed, and stores the hash next to the request.
  2. Route it to the named approver role from the table. Pause the workflow.
  3. On approval, mint a single-use token bound to the argument hash, with a short expiry. The executor runs the tool once with that token and refuses any call whose arguments no longer hash to the frozen value.

That last constraint is what separates a real gate from a rubber stamp. A queue-then-execute model decouples approval latency from agent latency, and the credential that was valid at pause time may not be valid at resume time, which is the failure most approval implementations skip approval gates. Microsoft's agent framework exposes the same shape: wrap the function so the run returns a ToolApprovalRequestContent carrying the function name and arguments, then create a response with true or false and pass it back in a new run framework approvals.

Low-code runtimes make the pause explicit too: the workflow sends an approval request over a chosen channel showing which tool and which parameters, and on approve the tool executes with the AI-specified input n8n human in the loop. Audit the parameters in that message. If the reviewer sees only the tool name, they are approving an intent, not an amount.

A gate you can read in ten lines

on_tool_call(tool, args, run_id):
  row = table[tool]
  if row is null: return refuse("unknown tool")
  if row.effect == "read": return execute(tool, args)
  if row.effect == "write": return execute_and_log(tool, args, before_after=True)
  h = sha256(canonical_json(args))
  req = approvals.create(run_id, tool, h, approver=row.approver_role)
  approvals.pause(run_id, req)
  return pending(req.id)

on_approval(req_id, decision, approver):
  req = approvals.get(req_id)
  if approver != req.approver_role: return refuse
  if decision == "deny": return cancelled(req)
  token = tokens.mint(req.arg_hash, ttl_seconds=300, single_use=True)
  return execute_once(req.tool, req.args, token)

Idempotency is the second lock, not the first

Classification decides whether a call should happen. Idempotency decides whether a retried call can happen twice. You need both, and the classifier tells you where to spend the effort.

Generate one operation key when the workflow decides what should happen, not when the retry occurs. A run ID, trace ID or tool-call ID changes on every retry and must never serve as the identity of the business effect idempotent tool calls. Store the key with a payload hash, a status, a lease and the result behind a uniqueness constraint. A duplicate with the same payload returns the stored result; the same key with different arguments is rejected. Postgres INSERT ... ON CONFLICT is a working arbiter for the claim.

The record needs a needs_reconciliation state, distinct from failed. Collapsing "safe to try" and "unknown whether it happened" into one bucket is how duplicate effects get created, because a timeout means the client does not know what happened, not that the operation failed the double-charge trap. On the money path, needs_reconciliation should page the approver, not retry the charge.

Pass the same key to the downstream provider. A local database row alone cannot make a card charge, an email or a deployment exactly once, because the second call still leaves the process. The provider has to deduplicate on a key derived from your operation key, or you reconcile against the provider's state before retrying idempotent tools.

Worked example. A refund step creates refund:invoice_842:approval_17 when the approver clicks approve. The tool writes a row with that key, status in_progress, a 30 second lease, and the payload hash of the amount and currency. It calls the provider with the key. The worker dies before writing the result. On restart the workflow regenerates the same key from the durable approval record, finds in_progress with an expired lease, calls the provider with the same key, receives the original charge object, and stores it. One refund, two attempts, one effect.

Tests that prove a money call cannot fire without approval

Test the gate, not the transcript. A passing conversation is evidence that the agent said the right thing. It is not proof that the workflow succeeded or that no unauthorized call ran sandbox side effects.

TestSetupPass condition
No approvalModel requests issue_refundZero calls reach the provider; a pending approval exists
DeniedApprover rejectsZero calls; agent receives a rejection observation and does not retry the same intent
Approved onceApprover acceptsExactly one provider call with the frozen arguments
Argument driftApprove, then change the amount in the stored requestExecution refused; no provider call
Token replayReuse the approval token for a second callRefused; second attempt logged as an incident
Expired tokenApprove, wait past TTL, executeRefused; requires fresh approval
Unknown toolModel calls a tool missing from the tableRefused before validation, logged with the raw arguments
Missing approval recordScan the audit store for money-class calls without a matching approvalZero rows
Crash windowSIGKILL the worker after the provider returns, before the result is stored, with the same operation keyOne provider effect after recovery

The crash test is the one most suites omit, and it is the one that reproduces the reported failures. Kill the process at exactly that seam and count the effects at the receiver. Then repeat with the keyed path and confirm the count stays at one.

The audit scan is worth writing as a scheduled query rather than a test, because it also catches a table edit that reclassified a tool without updating the router. If money-class call counts and approval-record counts diverge, something is wrong, and the divergence is visible in one query.

Run the whole suite against fixtures in an isolated environment, assert the final record state rather than the response body, and clean up by run ID so a failed test cannot poison the next one sandbox side effects. For a broader view of where these gates sit between the agent and the API gateway, the runtime enforcement model is a useful reference ai runtime enforcement.

Once the gate holds, the next question is what the agent uses to reason across the many model calls inside a single workflow. Routing every model through one endpoint keeps credentials, cost and logging in a single place; Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible key, which makes the classifier's own model calls as auditable as the money path they guard (https://quantaintelligence.ai/inference).

FAQ

FAQ

What if a tool could be read in one call and money in another?

Split it into two tools with different names. A single update_account tool that sometimes flips a status and sometimes issues a credit forces the router to guess, and guessing by argument inspection inside the runtime is fragile. Two tools, two rows, two routing paths.

Who should be the named approver for money-class calls?

A role, not a person, so the queue does not stall on vacation, but every approval record stores the individual who clicked. The approver must see the frozen arguments, because approving a tool name is not approving an amount.

Does human approval remove the need for idempotency keys?

No. The gate reduces the frequency of money calls; the key protects the retries that still happen after a timeout, a worker restart or a duplicate queue delivery. Keep both, and make the key derive from the approval record so a resumed workflow reproduces it exactly.

How do we stop the table from rotting as tools are added?

Fail closed at runtime, deny any tool not in the table, and alert on those denials. A denial is the signal that someone shipped a tool without classifying it, which is cheaper to catch in a log than in a duplicate charge.

Is a reversible write ever money-class?

Only if it commits a resource someone else pays for, or reaches a third party who cannot unsee it. Reversible internal state changes belong in the write row with before and after logging, as long as the log genuinely lets you restore the prior state.

Sources

Sources

  1. Side Effects and Sagas: Retry Semantics When Agents ...
  2. The Idempotency Problem in Agentic Tool Calling - TianPan.co
  3. Restate Durable Agents: Duplicate Tool Calls Tested (2026)
  4. AI Agent Governance: Runtime Enforcement with trail
  5. AI Agent Security at Every Tool Call | Saviynt Zuma (51)
  6. Human-in-the-Loop Tool Calling: Approval Gates for AI ...
  7. What Is Tool Calling? How AI Models Turn Decisions Into ...
  8. Voice Agent Sandbox Testing for Tool Calls and Side Effects
  9. Using function tools with human in the loop approvals
  10. Human-in-the-loop for tools | Build
  11. Idempotent Tool Calls for AI Agents: Prevent Duplicate Side ...
  12. Idempotency Keys - The Double-Charge Trap
  13. Idempotent Tool Design: Building Agent Tools Safe to Retry
  14. AI Runtime Enforcement: Real-Time Control for Agents
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles