Founders

How to Write a Tool Call a Rule Can Rely On

Antonio Evans10 min read

A split diagram: on the left, a decision rule written as a flowchart ('if order late -> refund'); on the right, a box labeled 'Refund tool' containing a JSON schema with required fields (order_id string, amount integer.
Key points

Key points

  1. 1A rule like "refund if the order is late" only becomes executable when every term maps to an exact API field with a fixed type, such as an order identifier string and an amount in minor units.
  2. 2Every mutating tool call should carry a client-generated idempotency key, and the API should return the stored original result when that key repeats.
  3. 3The conflict case matters: the same key with a different payload must be rejected, not silently executed twice.
  4. 4A replayable log records the correlation ID, a hash of the input, the HTTP status, and the before/after state transition, which is what makes audit and replay possible without guesswork.

Why the plain-language rule is not yet a tool call

"Refund if the order is late" reads like a complete instruction. It is not. A rule is a judgment; a tool call is a transaction. Between them sit at least five decisions the sentence leaves open: which order, what counts as late, how much money, in what unit, and what happens if the refund was already issued. Every one of those gaps is a place where an agent will guess, and guesses against a payment API are expensive.

The reliability math is unforgiving. If a model produces a fully valid tool call 95 percent of the time, then over a 20-step session the chance of at least one invalid output is 1 minus 0.95 to the twentieth power, about 64 percent, as one agent engineering guide puts it in its discussion of structured outputs and function calling. That guide also lays out the spectrum from free text to constrained decoding and notes that strict structured outputs approach near-perfect schema compliance, while plain JSON mode hovers around 95 percent. The lesson for tool design is blunt: you cannot prompt your way out of a contract problem. You fix the schema.

There is a second, subtler failure. Language models are known to have ordering effects in what they learn. The Reversal Curse paper showed that models fine-tuned on "A is B" statements, such as a name followed by a description, often failed to answer the reverse question, and that GPT-4 answered one direction correctly 79 percent of the time against 33 percent for the reversed form. The paper notes models can deduce the reverse relationship when the fact is present in context. Translate that to tool calls: an agent that has seen an order described as "late" in one direction may not reliably infer that a status of LATE in an enum means the same. Give it an enum value, not a synonym, and the mapping is a lookup rather than an inference.

Part one: validation maps every term in the rule to an exact field

Start from the rule's words and work backwards to fields. Here is the worked example we will carry through the article, an endpoint for refunding a late order.

Rule phraseField nameTypeConstraintWhy
"the order"order_idstringmust match ord_ prefix, exists, not already refundedprevents fuzzy matching on customer name or email
"refund"amount_minorintegergreater than zero, less than or equal to refundable balanceavoids floating point rounding and negative refunds
"the order" (unit of money)currencystring, ISO 4217 codemust equal the order's currencyrejects mismatched currency before the ledger is touched
"is late"reasonenumone of LATE_DELIVERY, LOST_IN_TRANSIT, DAMAGEDthe rule's judgment becomes a fixed vocabulary
auditidempotency_keystring, UUID v4required, 8 to 64 characterslets retries return the same result
auditcorrelation_idstringrequired, passed through from the orchestratorties the call to the decision that caused it

Three of these deserve a note. First, IDs are opaque strings. The API should reject a request that supplies an email address where an order ID belongs, with a 422 and a field-level error, rather than searching for a match. Second, money is an integer in minor units. A refund of nine dollars and ninety-nine cents is 999, and the API never sees a decimal point. Third, an enum is a commitment. If the rule can produce reasons that are not in the list, the tool is under-specified, and the agent will either invent a reason or fail. Both outcomes are worse than expanding the enum deliberately.

The schema itself should be flat. That same structured outputs chapter recommends naming tools with verbs, describing constraints in the description, using enums for fixed choices, and keeping schemas flat because deeply nested objects confuse models. So the tool is called issue_refund, not refund, and the description says plainly that it is for late delivery refunds only and returns the refund record.

{
  "name": "issue_refund",
  "description": "Issue a full or partial refund for a delivered order. Use only when the reason field matches the delivery failure. Returns the refund record including its status.",
  "parameters": {
    "type": "object",
    "properties": {
      "order_id": {"type": "string", "pattern": "^ord_[A-Za-z0-9]{8,}$"},
      "amount_minor": {"type": "integer", "minimum": 1},
      "currency": {"type": "string", "enum": ["USD", "EUR", "GBP"]},
      "reason": {"type": "string", "enum": ["LATE_DELIVERY", "LOST_IN_TRANSIT", "DAMAGED"]},
      "idempotency_key": {"type": "string", "minLength": 8, "maxLength": 64},
      "correlation_id": {"type": "string"}
    },
    "required": ["order_id", "amount_minor", "currency", "reason", "idempotency_key", "correlation_id"]
  }
}

Validation happens twice and that is correct. The schema helps the model produce a well-formed call, and the server validates again because a schema is advice to the model, not a guarantee at the network layer. Server-side validation should return structured, field-level errors so the agent can correct one field and retry rather than regenerate the whole call.

Part two: idempotency makes retries safe

The moment an agent can retry, duplicates become a design concern. RFC 9110, quoted in this guide to idempotent REST APIs, defines a method as idempotent when the intended effect of many identical requests is the same as the effect of one. GET, PUT and DELETE are idempotent; POST and PATCH are not. A refund endpoint is a POST, so it is not idempotent by method, and you have to make it idempotent by contract.

The standard mechanism is a client-supplied key. As the Zuplo guide to idempotency keys explains, the client generates a key, typically a UUID version 4, sends it in an Idempotency-Key header or the payload, and the server stores the key alongside the result, including the status code and response body. On a repeat, the server skips the operation and returns the stored result. The guide is precise about timing: the key is created before the first request is sent, not during retries, so every attempt of the same operation uses the same key. It recommends storing keys long enough to cover typical retry windows, in the region of 24 hours to 7 days, and warns that concurrent requests with the same key need a database or distributed lock so only one executes.

The server must also compare the payload. If a client sends the same key with different data, the request is a misuse and should be rejected, not executed. A concrete contract:

SituationServer responseWhat the agent should do
New key, valid payloadProcess, store result, return 201 with refund recordRead the record, proceed
Existing key, identical payload hashReturn stored status and body, do not reprocessTreat as success; do not retry again
Existing key, different payload hash409 Conflict with an error codeDo not retry; surface the mismatch to a human
Key in progress409 or wait, depending on implementationBack off and poll with the same key
Invalid field422 with field errorsFix that field, reuse the same key

Two decisions in that table carry weight. First, replaying the stored status and body is what makes the retry idempotent from the agent's point of view; a repeated call that returns 200 with the original body looks exactly like the first success. Second, the conflict response is a guardrail, not an annoyance. Returning 409 on a key reuse with different data is the only way to catch an agent that has lost track of which refund it is attempting. The Confluent and HTTP treatments of idempotency make the same point, noting that stale or collided keys should be rejected rather than trusted.

One more implementation detail worth stating because it is cheap and it prevents a real class of bug: hash the request body with a stable canonicalization (sorted keys, no whitespace) and store the hash next to the key. Compare hashes, not raw JSON, because key ordering and formatting will differ between the first attempt and the retry.

Part three: a log you can replay, not just read

Logs in an agent system have to answer three different questions. What did the agent decide? What did the tool do? And what was the state before and after? A single log line that records only "refund issued" answers none of them.

The framing comes from a paper on log analysis for agent evaluation, which argues that outcome-only benchmarks conceal what happened, and that logs of inputs, execution and outputs are needed to make evaluation credible. The same argument applies to operations: if you only see the final status, you cannot tell whether a refund was issued once or three times.

Open Policy Agent's decision logs are a useful reference for the field set. Per the OPA decision log documentation, each event includes a decision_id for traceability, a trace_id compliant with the W3C trace context, a span_id, the input as received, the policy path, the result, and an RFC 3999 timestamp. The docs also note fields for erased and masked data, which is how sensitive values are handled without dropping the record. A refund log can mirror that shape.

A minimal replayable record for the refund tool looks like this:

{
  "correlation_id": "corr_9f2c1a",
  "decision_id": "dec_77ab03",
  "tool": "issue_refund",
  "idempotency_key": "5c1e0f5e-8d0a-4f54-9f4e-1a2b3c4d5e6f",
  "input_hash": "sha256:9b1d...e4",
  "order_id": "ord_4127",
  "amount_minor": 2400,
  "currency": "USD",
  "reason": "LATE_DELIVERY",
  "status_code": 201,
  "state_before": {"order_status": "DELIVERED", "refund_total_minor": 0},
  "state_after": {"order_status": "REFUNDED", "refund_total_minor": 2400},
  "attempt": 1,
  "duration_ms": 187
}

Four fields do the heavy lifting. The correlation_id ties this tool call to the rule evaluation that triggered it, so a reviewer can walk from "rule fired" to "refund issued" in one query. The input_hash lets you prove that retry number two carried the same payload as attempt one without storing the body twice. The status_code is what the agent actually saw. And the state_before and state_after pair is the only field that lets you verify the transition independently of the API's own reporting, which matters when the API is the thing under suspicion.

This is not a new idea in software engineering. Debugging tools like Claude Code and isolated sandboxes get written up with logs precisely because the log is how you reconstruct what happened, and a guide to debugging agent sandboxes and the OPA docs both point at the same discipline: capture the input, the decision, and the result, and give each a stable identifier. If you maintain a decision-log.md style file for an AI-assisted project, as this practitioner describes, the same structure applies at the level of the agent's reasoning, and the two logs should share a correlation ID so they join.

Replay deserves a separate sentence. To replay a call safely, you need the input, the key, and the recorded response. With those three, you can send the same key and payload to a sandbox copy of the API and confirm it returns the recorded status and body, which proves determinism. Without the key, replay is just a second refund.

A decision rule for when to add idempotency

Not every tool call needs the full treatment. The cost of an idempotency key and a stored result is real: storage, a lock, a hash, and a cleanup job. The benefit is proportional to the cost of a duplicate. Here is a rule you can apply at design time.

Property of the actionAdd idempotency key?Why
Moves money, creates or cancels ordersYes, alwaysA duplicate is a financial or legal event
Sends an external message (email, SMS, webhook)YesRecipients cannot un-receive
Mutates shared state other agents readYesA duplicate corrupts the shared record
Read-only lookupNoRepeated reads are already safe per RFC 9110
Idempotent by construction (set status to X)Usually no, but log anywaySame final state, but you still want the audit trail
Draft or scratch writeNoCheap to redo, nobody depends on exactly-once

The middle rows are where teams argue. The way to settle the argument is to ask what a duplicate costs in dollars and in customer trust, and compare it to the engineering cost of a key table. For a refund API the answer is obvious, which is why the worked example uses one.

Connecting the rule, the decision, and the tool

The rule itself needs a record. A decision log captures the reasoning behind a choice, and the framing used by monday's guide to decision logs is that you record the decision, the alternatives, the owner, and the review trigger. Written properly, one entry for the refund rule reads: "Refund in full when the delivery status is LATE after the promised window, using the enum reason LATE_DELIVERY, through issue_refund with a client key." That sentence contains the rule, the field name, the enum value, and the tool name. It is testable, and when the policy changes, the entry is superseded rather than rewritten.

The log templates make the same point about separating decision from context. As Boardmix's template guide puts it, keep the decision field separate from the context, because a paragraph that mixes both is hard to quote later. Applied here, the decision log holds "refund if late, with this reason code," and the replay log holds "this refund was issued for this order at this time." Different files, joined by the correlation ID.

If you are mapping this onto metrics, treat the count of 409 conflict responses as a health signal. A conflict means an agent reused a key with different data, which is exactly the class of bug you designed the guardrail to catch. Plot it next to refund volume; the Qlik KPI examples guide makes the general distinction between a KPI, which tracks a strategic objective, and a metric, which tracks a tactical process, and a conflict rate is firmly the second. Rising conflicts against flat refund volume is the pattern that tells you the agent is retrying badly before a customer complains.

Getting the build started

The fastest way to test a contract like this is to run it against a sandbox. Stub the database and the payment provider, as Braintrust's guide to testing multi-agent systems recommends, so a test never issues a real refund. Then write four cases: a clean refund, a retry with the same key, a retry with the same key and a changed amount, and a retry that arrives while the first is still in flight. The first two should return 201 and 200 (or 201 replayed) with matching bodies. The third must return 409. The fourth must not double the refund.

When you are ready to wire this to a real model, Quanta Inference gives you text and embedding models through one OpenAI-compatible endpoint with a prepaid balance, which keeps the tool-calling schema identical whichever model you route to. For teams that want a specialist to sit with your team and map a rule set like this onto your own API, a free 30-minute discovery call is the shortest path. If you already know the shape of the contract and want the endpoint, the worker, and the log viewer built, describe it to Quanta and a specialist picks it up with you.

FAQ

FAQ

What is the difference between a rule and a tool contract?

A rule is a judgment expressed in natural language, such as "refund if the order is late." A tool contract is the executable form: named fields, fixed types, allowed values, and a defined response for every outcome, including duplicates and conflicts. The contract is what removes the agent's need to infer anything.

Why use integer minor units instead of decimal amounts?

Floating point arithmetic produces rounding errors that accumulate across totals and reconciliation, and a refund of a decimal of a cent is meaningless. An integer in minor units, so 999 for nine dollars and ninety-nine cents, is exact and compares cleanly. It also makes the validation rule trivial: the amount must be a positive integer no greater than the refundable balance.

What should the API return when the same idempotency key arrives with different data?

A 409 Conflict with a stable error code, and no change to state. The client reused a key for a different operation, which means the agent has lost track of which refund it is attempting. Returning the stored result of the first call would silently discard the second intent. A reference on idempotency key handling makes the same recommendation: verify that repeated requests match the original parameters and reject mismatches.

How long should idempotency keys and their results be kept?

Long enough to cover every realistic retry and replay window. Common practice is 24 hours to 7 days, with the choice driven by how long a client or orchestrator might retry. Keeping them forever turns the key table into a growing index; expiring too early reopens the duplicate window. A scheduled cleanup job with a documented retention period is the honest compromise.

What makes a log replayable rather than just readable?

Three things: the exact input, the idempotency key, and the recorded status and body. With those, you can resend the call to a sandbox and confirm it produces the same result, which is what proves determinism. A log that says only "refund issued" tells you what happened but gives you no path to re-execute or audit it, which is the gap that log analysis research on agents argues against.

Sources

Sources

  1. Ch. 30: Structured Outputs & Function Calling
  2. The Reversal Curse: LLMs trained on “A is B” fail to learn “ ...
  3. Idempotency - What is an Idempotent REST API?
  4. Implementing Idempotency Keys in REST APIs
  5. Idempotency in API Design Clearly Explained
  6. Log analysis is necessary for credible evaluation of AI agents
  7. Decision Logs
  8. The decision-log.md File That Gave My AI Sessions Memory
  9. Decision log: template, examples, and best practices 2026
  10. Decision Log Template: Fields, Examples and Follow-up
  11. 170 Key Performance Indicator (KPI) Examples & Templates - Qlik
  12. How to test multi-agent systems - Articles - Braintrust
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