Guide · Proof
Audit logs for AI agents: what to record, and why a hash-chained ledger
An AI agent audit log should record, for every action: which agent and which credential acted, what it did, in which system and connection, what went in and came out, the outcome, the cost, and the agent's own reason. It should be tamper-evident, so a changed record shows, and it should outlive the agent's session. actionlake records every run this way, in a ledger chained with SHA-256 and sealed daily. This guide covers the fields, how hash chaining works, what to keep for how long, and what auditors ask.
Why agents need their own audit log
Your systems already log changes, but each logs them its own way, and most log the vendor API user, not the agent. When ten agents share one Xero app and one Autotask API user, the vendor's log says "API user" for all of them. The questions that follow an incident cannot be answered from there:
- Which agent did this, and under whose authority?
- What exactly did it send, and what came back?
- Was it allowed to, and did anyone approve it?
- Why did it think this was the right thing to do?
- Has anything in this record been changed since?
An agent audit log answers them in one place, across every system the agent can reach.
What to record for every action
| Field | Example | Why |
|---|---|---|
| Run id | 01JB… | One stable reference for each action |
| Key (the agent's credential) | key: desk-agent-sam | Who acted. One key per agent makes this meaningful |
| Action | xero.invoices.create | What it did, in a stable name |
| Connection | xero / default | Which account or organisation it reached |
| Input hash | sha256(…) | Proves what was sent, without storing it forever |
| Output hash | sha256(…) | Proves what came back |
| Status and error code | ok, or rate_limited | The outcome |
| Billed calls, cache hit | 1, false | The cost, and whether a vendor was called at all |
| Timing | total ms, vendor ms | Where the time went |
| Intent | "The controller asked for a draft invoice for Acme's October hours." | Why it acted |
| Plan run and step | plan.month-end · step 3 | Which program it was part of |
| Approval | approver, decision, time | Who said yes |
| Timestamp | 2026-10-22T08:14:03Z | When |
The intent is the field most logs miss. Every actionlake call requires one: a sentence from the agent saying why. See agent intent.
Hashes, not bodies, in the permanent record
Full request and response bodies hold customer data. Keeping them forever is a privacy liability, not an asset. Keep hashes in the permanent record, and the bodies for a defined window.
actionlake splits it this way:
| What | Kept for |
|---|---|
| The ledger row: ids, hashes, outcome, cost | Permanently |
| The agent's intent text | 90 days, then cleared |
| Run traces: request and response bodies, secrets removed | Your plan's window: 24 hours on Free, 72 hours on Starter, 7 days on Pro, 14 days on Team, 30 days on Business and Enterprise |
Traces are sealed under your tenant's key and readable only by the key that ran the call, the root key, or a key granted traces:*.
How a hash-chained ledger works
A tamper-evident ledger links each record to the one before it. Each row's hash covers the previous row's hash plus the row's own fields:
hash[n] = sha256( hash[n-1] || canonical_json(row[n]) )
hash[0] = 32 zero bytes
Change any field of any past row and its hash changes, so the next row's hash no longer matches, and nor does any after it. To hide an edit, someone would have to rewrite every later hash, and the daily seal stops that.
In actionlake:
- The hashed fields are the run id, tenant, key, connection, action, input hash, output hash, status, error code, billed calls, cache hit and the time. The list is fixed in code, because a chain whose definition drifts reports tampering that did not happen.
- Fields are serialised as canonical JSON, so any verifier gets the same bytes. Each tenant has its own chain, in run order.
- A single chainer hashes new rows in order, about once a second. That leaves a documented window of roughly a second in which a new row exists but is not yet chained.
- Once a day, the chain head is written to object storage as a seal, and every tenant's chain is re-verified. A break is logged as an error and counted, and the next day's check starts from the same place.
- You can verify a single run from its page in the console.
A verifier is short. The canonical form follows RFC 8785, the JSON Canonicalization Scheme: keys sorted, no whitespace, non-ASCII written as UTF-8, binary hashes as hex and times in ISO 8601. For rows like these, a simplified version in Python is:
import hashlib, json
def canonical(row):
return json.dumps(row, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
def verify(rows):
prev = b"\x00" * 32
for row in rows:
expected = hashlib.sha256(prev + canonical(row["fields"])).digest()
if expected != row["hash"]:
return f"chain breaks at run {row['fields']['id']}"
prev = expected
return "chain intact"
A chain cannot stop someone with database access from editing a row. It makes the edit detectable. That is why the right word is tamper-evident.
What auditors and clients ask
| Question | Where the answer comes from |
|---|---|
| Which agents can touch our finance system? | Keys with grants on that service |
| What could each one do? | Each key's allowed_actions, cap and approval rules |
| Who approved this payment? | The approval on the run |
| Show everything agent X did last month | Runs filtered by key |
| Prove these records were not altered | The chain, and the daily seal |
| Why did the agent do this? | The run's intent, within 90 days |
What is not there yet
Be precise about limits; an auditor who finds an undocumented gap stops trusting the rest.
- No external witness. The daily seal is written to our own object storage, not to a third party.
- No whole-chain export with hashes. The console's CSV export carries the run table, not the hash chain.
- No certification. actionlake holds no SOC 2 or ISO 27001 certification today. The ledger is evidence for your auditor; it is not an attestation.
Setting it up so the log means something
- One key per agent. A shared key makes "who" useless. See seven controls for agent access.
- Write intents a person can read. Ask agents to state the request they are serving, not the tool they are calling.
- Name approvers. An approval rule with a named person makes "who allowed it" a name, not a team.
- Pick a plan whose trace window fits your review cycle. If you review weekly, a 24-hour window will not hold the bodies you want to read.
Frequently asked questions
Is the agent's prompt or conversation stored?
No. actionlake records the calls the agent made, with their intent, input and output hashes and outcome. It never sees the conversation between the person and the model.
Can I delete a record?
Ledger rows are not editable through any endpoint. Erasing a whole tenant deletes its data key, which makes everything sealed under it unreadable, and clears stored text such as intents. Intents older than 90 days are cleared for every tenant.
Does logging cost extra?
No. Every run is recorded on every plan, including Free. Only upstream calls sent to a vendor are billed. See pricing.
How is this different from the vendor's own audit log?
The vendor's log sees one shared API user. The agent ledger sees each agent's key, its reason and its approvals, across every connected system, in one chain.
Try it on your own systems.
100,000 free calls a month. No card. Hard caps on every key.