If you came here from the search box: log before the tool function runs, in your process, not in the model's transcript. Write the tool name, a hash of the arguments, the user, the decision (allow / deny / ask), and a timestamp. Persist that row somewhere the agent runtime cannot truncate. That is an audit log for agent actions. Chat history is not.
The rest of this page is when a console.log inside execute is enough, when it quietly lies, and one library-shaped way to get denials on the same stream as successes.
The naive approach (and when it is sufficient)
Most teams start here:
async function issueRefund(input) {
console.log('refund', input)
return payments.refund(input)
}
That is enough when all of the following are true:
- every write goes through this one function (no second client the model can call);
- you only care about calls that succeeded — not about the ones you blocked;
- the log sink is already append-only and access-controlled (your app's existing audit table is fine);
- nobody will later ask you to prove the file was not edited.
If your agent is read-only, skip the log. There is no action worth reconstructing. If your platform already records every privileged API call with the human user as actor, and the agent cannot go around that API, you already have the audit trail — do not add a second one for the sake of having an "agent log."
Where the naive log goes silent
Three holes show up the first time something goes wrong:
- Denials never appear. If you log inside
execute, a policy orif (amount > 50) returnthat runs beforeexecuteleaves no row. The interesting events — "the model asked to export customers" — are exactly the ones that never entered the function. - The model is not the logger. A prompt-injected agent that is allowed to run shell or to edit files can omit, rewrite, or flood the same stream. The log has to live in the host, wrapping the tool, not in a "please log this" tool the model may skip.
- Transcripts are not evidence. The chat contains whatever the framework serialized for the next turn. It is sampled, truncated, and often missing tool arguments you later need. An auditor asking "what did we allow as Alice at 14:03?" should not have to grep a JSONL of model messages.
A wrapper that logs on the way in, including the reject path, closes (1) and (2):
function audited(name, fn, { user, decide }) {
return async (input) => {
const decision = decide({ name, input, user }) // allow | deny
await audit.append({ at: Date.now(), user, tool: name, decision, inputHash: sha256(JSON.stringify(input)) })
if (decision === 'deny') throw new Error(`denied: ${name}`)
return fn(input)
}
}
Hash the input by default. Raw arguments in a log are how refund destinations and email bodies leak into Datadog. Keep the preimage in the approval UI or a restricted store if a human must see it.
If you need to notice a deleted or rewritten row, hash-chain the records: each row's hash covers its body plus the previous hash. Anyone can recompute the chain; a gap or an edit fails verification. That property is tamper-evident: the break is detectable. A privileged operator who holds the signing key and the database can still write a replacement chain, so a real investigation still wants the log copied off-box.
When nominee is a fit (and when it is not)
nominee appends a receipt on every policy decision, approval, and (if you use it) token grant — including denies — before the tool function runs. Default input mode is a SHA-256 of canonical JSON, not the raw args. verifyReceipts() checks the hash chain; npx nominee-cli verify receipts.json does the same offline.
const nominee = new Nominee({
policy: { rules: [allow('orders.read'), deny('customers.export')], fallback: 'ask' },
receipts: { key: process.env.NOMINEE_RECEIPT_KEY, onReceipt: (r) => sink.write(r) },
})
await nominee.run({ tool: 'customers.export', input: {}, user }, exportCustomers)
// exportCustomers never ran; a deny receipt is already on the chain
Skip nominee when you need a catalog of SaaS connectors (Arcade, Composio), when the agent cannot write anything that matters, when your platform's native permission log already covers every call end-to-end, or when you wanted a standalone identity provider or policy engine — nominee is the enforcement point, not OpenFGA/OPA/Auth0.
Receipts are evidence primitives. They are not a compliance certification. Production mode asks for a durable store and strict delivery; that is still your key management and your off-box copy.
If you want the deeper argument — why a transcript of "the agent logged in as you" is the wrong artifact — read Your agent logs in as you. It shouldn't get to be you.
See it for yourself
A deny, an allow, and a chain that verifies.