Your agent just told a customer their refund was processed. Transaction ID TXN-84921, should arrive in 3 to 5 business days. The call ended with a five-star rating.
Two days later your billing team flags it. TXN-84921 doesn't exist in your payment system.
The agent described a successful refund in perfect, confident detail. It never actually ran the refund tool.
This is the hallucination most evaluation setups don't catch, and it's sitting quietly in production at almost every team building tool-using agents.
The hallucination nobody talks about
Most hallucination detection focuses on LLM text output. Did the model invent a policy? Misattribute a quote? Cite a statistic that doesn't exist? These matter, and they're reasonably well-covered by LLM-as-judge pipelines and output validators.
Tool-call hallucinations are a different problem entirely. In a tool-using agent, the LLM's job isn't just generating text. It decides when to call a tool, which tool to call, and what parameters to pass. The tool runs, returns a result, and the LLM builds its next response on that result. That chain has three distinct places where fabrication can happen.
First: the LLM claims to have called a tool it never invoked. This happens more than you'd expect under load, when framework timeouts interrupt the tool call cycle, or when the LLM's generation continues after a stream error. The model fills in a plausible-sounding result because completing the pattern is what comes next.
Second: the LLM calls the real tool but claims different parameters than it actually passed. It tells the customer it processed a $49 refund. It actually called the tool with $4.90 due to a decimal serialization issue. The tool ran. Without a receipt, you'd never catch it.
Third: the LLM calls the real tool, gets a real result, and narrates a different result to the user. The tool returned an error code. The LLM told the customer it succeeded.
Function calling accuracy declines as tool count grows, and under that degradation all three failure modes become more frequent. The core problem is that your LLM-as-judge sees only the final response text. It grades the agent on whether the answer sounded right, which is not the same question as whether it was right.
You need a separate, deterministic layer at the tool boundary. That's what receipts provide.
What a tool receipt actually is
A tool receipt is the ground truth record you don't have today. It's a small structured object that the tool creates at execution time, before the LLM can touch the result.
The minimum viable receipt has four fields:
receiptId: a deterministic ID constructed from conversation ID, tool name, and call sequence numbercalledAt: Unix millisecond timestamp written by the tool layer, not the LLM layerinputs: the exact parameters the tool receivedoutputs: the exact value the tool returned
Those four fields give you a fact base. When the agent later claims something about what happened, you have something to check it against.
Here's what the refund tool receipt looks like in practice:
{
"receiptId": "conv_8841_chargeCard_1",
"conversationId": "conv_8841",
"toolName": "chargeCard",
"calledAt": 1750201044123,
"inputs": {
"customerId": "cust_1892",
"amount": 4900,
"currency": "usd",
"refundReason": "duplicate_charge"
},
"outputs": {
"transactionId": "TXN-84921",
"status": "succeeded",
"processorRef": "ch_3P1kLo2eZvKYlo2C0xUTKVXZ"
},
"durationMs": 312,
"status": "success"
}When the agent tells the customer "your refund transaction ID is TXN-84921", you look up receipt conv_8841_chargeCard_1 and check two things: did the tool actually run, and did it return exactly that transaction ID? If both answers are yes, the agent's claim is grounded. If either answer is no, you have a fabrication event.
The receipts don't live in the LLM's context. They live in your receipt store, written by the tool wrapper before the LLM sees the result. That's what makes them unforgeable.
How to implement the wrapper
The receipt wrapper pattern leaves your tool code completely unchanged. You wrap each tool before passing it to your agent framework, and the wrapper handles the logging transparently.
import { Redis } from 'ioredis'
interface ToolReceipt {
receiptId: string
conversationId: string
toolName: string
calledAt: number
inputs: Record<string, unknown>
outputs: unknown
durationMs: number
status: 'success' | 'error' | 'timeout'
error?: string
}
const redis = new Redis(process.env.REDIS_URL!)
export function withReceipt<T extends Record<string, unknown>>(
toolName: string,
toolFn: (inputs: T) => Promise<unknown>,
conversationId: string,
callIndex: number
) {
return async (inputs: T): Promise<unknown> => {
const receiptId = `${conversationId}_${toolName}_${callIndex}`
const calledAt = Date.now()
let status: 'success' | 'error' | 'timeout' = 'success'
let outputs: unknown
let error: string | undefined
try {
outputs = await toolFn(inputs)
} catch (err) {
status = 'error'
error = err instanceof Error ? err.message : String(err)
outputs = null
}
const receipt: ToolReceipt = {
receiptId,
conversationId,
toolName,
calledAt,
inputs,
outputs,
durationMs: Date.now() - calledAt,
status,
...(error && { error }),
}
// Async write: does not block the agent's critical path
redis
.setex(receiptId, 60 * 60 * 24 * 30, JSON.stringify(receipt))
.catch((e) => console.error('Receipt write failed:', receiptId, e))
if (status === 'error') throw new Error(error)
return outputs
}
}Then when you build your agent's tool list, you wrap each entry:
import { withReceipt } from './tool-receipt-wrapper'
import { chargeCard, updateCRM, sendEmail } from './tools'
export function buildTools(conversationId: string) {
let idx = 0
return [
{
name: 'chargeCard',
description: 'Process a refund or charge on the customer account',
execute: withReceipt('chargeCard', chargeCard, conversationId, idx++),
},
{
name: 'updateCRM',
description: 'Write an outcome or attribute to the customer record',
execute: withReceipt('updateCRM', updateCRM, conversationId, idx++),
},
{
name: 'sendEmail',
description: 'Send a transactional confirmation email to the customer',
execute: withReceipt('sendEmail', sendEmail, conversationId, idx++),
},
]
}Each tool now writes a receipt on every call, including failures. The agent framework, the LLM, and your tool logic are all unaffected. Chanl's tool management can hold these tool definitions centrally and inject the conversationId automatically at runtime, so you don't have to wire it manually in every agent deployment.
Verifying claims against receipts
Receipts are only useful if you check them. There are two verification modes depending on the stakes of the action.
Inline verification runs immediately after each tool call, before the LLM formulates its response. You compare the output in the receipt to what you expect the LLM to narrate next. If the model starts generating a different transaction ID than what the tool returned, you can interrupt the stream and substitute the correct value. This matters most for high-stakes actions like payments, where a wrong narration creates immediate customer harm even if the underlying transaction was correct.
Offline verification runs as part of your eval pipeline, after conversations complete. You extract the claims the agent made from your trace logs and check each claimed tool call against its receipt:
interface ToolClaimFromTrace {
conversationId: string
toolName: string
callIndex: number
claimedOutputKey: string
claimedOutputValue: unknown
}
export async function verifyToolClaims(
claims: ToolClaimFromTrace[]
): Promise<{ fabrications: string[]; discrepancies: string[] }> {
const fabrications: string[] = []
const discrepancies: string[] = []
for (const claim of claims) {
const receiptId = `${claim.conversationId}_${claim.toolName}_${claim.callIndex}`
const raw = await redis.get(receiptId)
if (!raw) {
fabrications.push(`${receiptId}: no receipt found for claimed call`)
continue
}
const receipt: ToolReceipt = JSON.parse(raw)
const actual = (receipt.outputs as Record<string, unknown>)?.[claim.claimedOutputKey]
if (actual !== claim.claimedOutputValue) {
discrepancies.push(
`${receiptId}: claimed ${claim.claimedOutputKey}=${claim.claimedOutputValue}, ` +
`receipt shows ${actual}`
)
}
}
return { fabrications, discrepancies }
}Run this nightly against a 10% sample of production conversations. Track fabrication rate by tool name over time. A spike in fabrications for a specific tool usually traces back to a framework version change, a prompt update that altered how the model generates tool calls, or a timeout threshold that's too aggressive for that tool's response time. The monitoring dashboard makes these trends visible alongside your other agent health metrics without needing a custom analytics build.
Testing tool calls before they reach production
The best time to find a fabrication pattern is in test, not in production. Once you have receipts running locally, your test scenarios can verify tool call integrity as part of every standard run. You're not just checking whether the agent gave a good answer. You're checking whether its claims about what happened actually match what happened.
import Chanl from '@chanl/sdk'
import { verifyToolClaims } from './verify-tool-claims'
const chanl = new Chanl({ apiKey: process.env.CHANL_API_KEY })
const result = await chanl.scenarios.run({
agentId: 'agent_billing_v2',
scenario: {
persona: 'Customer asking for a refund on a duplicate charge from last week',
expectedToolCalls: [
{ toolName: 'chargeCard', requiredParam: 'refundReason' },
],
},
})
// Check every tool claim the agent made against its receipt
const { fabrications, discrepancies } = await verifyToolClaims(
result.trace.toolClaims
)
// Grade the conversation including the receipt integrity check
const score = await chanl.scorecards.evaluate({
conversationId: result.conversationId,
criteria: [
{
name: 'tool_call_integrity',
description:
'All tool results cited by the agent match the receipt store exactly',
},
{
name: 'customer_outcome_accurate',
description:
'The outcome communicated to the customer matches what actually happened',
},
],
})
console.log({ fabrications, discrepancies, score })The tool_call_integrity criterion is the one that receipts make possible. Without receipts, scorecards can grade whether the agent described the refund process correctly. With receipts, they verify whether the refund actually ran.
This is the core of what it means to build an agent you can monitor rather than just hope is working. For the full picture of how scenario testing fits into a CX agent QA strategy, scenario testing: the QA approach that catches what unit tests miss walks through how to structure your coverage.
What receipts don't cover
Receipts solve the tool-call integrity problem. They're the right tool for that specific job and nothing else.
They won't catch hallucinations in text the agent generates without making tool calls. They won't help if your tools have bugs that return incorrect data. And they don't apply to agents that don't use explicit tool calls.
For text-level hallucinations you still need LLM-as-judge. See building a production LLM-as-judge eval pipeline for how to run both approaches together for complete coverage.
The mental model that works best: receipts are the integrity layer for the tool-call boundary, and LLM-as-judge is the quality layer for text output. Any production CX agent needs both. They check different failure modes at different points in the same conversation, and the combination is what lets you build and monitor agents you'd trust to represent your business.
Start with the wrapper
If you're not running receipts today, start with the wrapper. It's two hours of work. Drop withReceipt around every tool that touches money, CRM records, or external state. You don't need a full eval pipeline first. Even a simple log of what actually ran versus what the agent claimed will surface your first real fabrications within a week.
In a tool-using agent, the hallucinations that cause real damage aren't the poetic ones. They're the ones that tell a customer their refund processed when it didn't.
Test your agent's tool calls, not just its responses
Chanl's scenario runner captures tool receipts automatically and checks every claimed result against actual execution records. Catch fabrications in test before they reach your customers.
Start FreeCo-founder
Building the platform for AI agents at Chanl — tools, testing, and observability for customer experience.
Learn Agentic AI
Weekly. Patterns for shipping agents that work. MCP, scorecards, regression tests, prompts, model comparisons.
