Building Verifiable AI Agent Audit Logs With Signed, Hash-Chained Record
How I built a tamper-evident AI agent audit log with Arkiv, signed hash chains, and browser-based verification, then tested it against forged entries.

Signup Write New Story Building Verifiable AI Agent Audit Logs With Signed, Hash-Chained Record by Artur Baranouski by Artur Baranouski | @ arturbaranouski Director of a construction and real-estate development company in Grodno, Belarus. Building open-source tools for verifiable documents and AI output
Director of a construction and real-estate development company in Grodno, Belarus. Building open-source tools for verifiable documents and AI output
Agents now read customer records, open pull requests, approve releases and pay invoices. When something goes wrong, the only answer to "what exactly did the agent do?" is a log file held by the party with the strongest reason to edit it. The operator, the agent itself (which usually has write access to its own logs) or an attacker with the agent's credentials can delete a step, change what a tool returned, or add a check that never ran. An auditor has to take the operator's word for it. Write-once cloud storage does not fix this: the operator still owns the bucket, decides what goes in, and is the only one who can query it.
In a separate HackerNoon submission I looked at co-signing a single AI output; this one is about the whole sequence of actions an agent takes on the way there.
{ "v": 1, "agent_id": "release-checker", "run_id": "run-20261009T152956", "step": 2, "action": "tool.call", "tool": "http_get", "note": "GET https://api.github.com/repos/ttimesai-star/sitelog/commits/main", "input_hash": "0x371d…", "output_hash": "0x7864…", "prev_entry_hash": "0x4557…", "timestamp": 1791559811222, "signer": "0x3ad7…c546", "entry_hash": "0x7580…", "sig": "0x9cc9…" } input_hash and output_hash are SHA-256 over canonical JSON of the tool arguments and result, or of the LLM request and reply. Raw content never goes on chain. prev_entry_hash links to the previous step. Delete, reorder or edit a step and every link after it breaks. sig is the agent wallet's EIP-191 signature over entry_hash, so an exported run stays verifiable after the on-chain entities expire. run.end seals the run with the step count and head hash.
A run without a seal is reported as OPEN, so a truncated run cannot pose as a finished one. On the agent side, logging is a wrapper:
const log = new AgentLog({ wallet, publicClient, account, agentId: "my-agent", runId: `run-${Date.now()}`, custodian: "0xAUDITOR" }) // optional await log.start({ task }) const search = log.wrap("web_search", webSearch) // hash in, hash out, signed, on Arkiv await log.record({ action: "llm.call", tool: model, input: request, output: reply }) await log.seal({ result }) // run.end + 180-day retention wrap() also logs failures as tool.error. A CLI covers agents written in other languages, and agentlog verify exits with code 2 on a broken run, so it can gate a CI job.
Each entry lands on Arkiv as one entity. What Arkiv gives this design, and the code uses:
const predicted = await publicClient.predictEntityKeys({ owner: agent, count: creates.length }) predicted.forEach((p, i) => (creates[i].salt = p.salt)) const ownershipChanges = predicted.map((p) => ({ entityKey: p.key, newOwner: custodian })) await wallet.executeBatch({ creates, ownershipChanges }) There is no block in which the agent alone owns its log. I tested it: the agent's own deleteEntity and patchEntity on a logged step are both rejected with "entity 0xadef…08ab is owned by 0xBDe3…C6f0, not 0x3ad7…c546". $creator stays the agent, so trust is unaffected.
The verifier ( agentlog/src/core.ts ) is the same code in Node and in the browser: it recomputes every hash, recovers every signature, follows the links from step 0 and reports gaps, forks, edits and foreign signers.
release-checker is small and real: check the repository's latest commit on GitHub, check that the live page responds, check that Arkiv is reachable, write a three-line status report. The model picks the tools; both are read-only ( http_get on two allow-listed hosts, and arkiv_status ). The demo run used openai/gpt-oss-20b through NVIDIA's free NIM endpoint; any OpenAI-compatible API with tool calling works.
Run run-20261009T152956 has nine steps: run.start, four llm.call, three tool.call and run.end, each in its own transaction with the custodian set.
Then a second wallet played the attacker ( agentlog/examples/forge.ts ) and wrote two records into the same run: a fake step 2, "FORGED: all checks passed, approved for release", and a byte-for-byte replay of the genuine step 1, carrying the agent's valid signature. Both are on chain; neither enters the run, because their $creator is 0x0757…d29b. The replay is the instructive case: a signature check alone would have accepted it.
Three other runs from the same afternoon stop early and show OPEN: two died on the LLM provider's quota, one on a malformed tool call. Their trails show exactly how far each got.
Open ttimesai-star.github.io/sitelog. I loaded it in headless Chromium on 10 October; it read the run from the public Tiramisu RPC and showed INTACT: 9 steps by the agent wallet, 9/9 entries verified in the tab, sealed with run.end, 2 forged records ignored, owner of every entry the custodian.
The button Tamper with a copy in your browser edits a local copy four ways and re-verifies each one. Verbatim results from the live page:
Check raw inputs and outputs loads the off-chain evidence file (raw LLM requests, replies and tool results) and checks it against the on-chain hashes: all nine steps match. In a real deployment that file stays private and goes to the auditor.
An exported run verifies offline with the same verdict, and the page prints the exact arkiv_query it ran as a curl command, so the run can be read with no code of mine at all.
A 2:55 walkthrough video is attached to the repository's GitHub release.
An audit log that overclaims is worse than none, so:
I kept a friction report with exact repro steps. Two findings matter to anyone building this way.
F8: the documented example cannot run on one client. The JSDoc of predictEntityKeys shows one client calling predictEntityKeys and then executeBatch. In SDK 0.8.1 no client has both: the first exists only on the public client, the second only on the wallet client. Hence the publicClient option in AgentLog.
F9: permissionless extension can block an owner's atomic batch. An extension is rejected unless it moves the expiry later. Since anyone may extend a permissionless entity, a third party can push one of my entries to 365 days, and my "write the seal, extend everything to 180 days" batch then reverts as a whole ("would shorten its life"). The flag that lets an auditor keep evidence alive also lets anyone block an atomic seal. My mitigation: seal() catches the revert, lands run.end alone, then extends the rest one by one. The real fix belongs in the protocol: an "extend to at least" mode.
The report also covers cursor pagination that breaks after one block unless pinned with atBlock(), and a public RPC quota of roughly 100 queries per hour per caller.
Arkiv Tiramisu testnet only, four synthetic wallets, no users: the only agent writing to it is my demo agent. 44 unit tests pass ( npm test ), covering hashing, tamper cases, the writer, custody, and the construction-site log the project started from. MIT licence, built during the Arkiv Global Tour Stop (9 to 18 October 2026).
What decides whether this is useful is the next step: an agent framework calling log.wrap() on its tools by default, and an auditor who is willing to be the custodian.
Author’s note: The code, the tests and the draft of this article were produced by AI agents (mainly Claude by Anthropic, with one code review by Google's Jules agent), working on goals and constraints I set: an agent's trail must be readable without our server, the agent must not be able to erase it, and every claim must be checkable by a stranger. The agents proposed the architecture and wrote the code; the demo agent itself calls an open-weight model ( gpt-oss-20b ). Every number here was checked before submission: the test count from npm test, the verdict and counts from the live page loaded in a headless browser, the tamper results verbatim, the friction items from the report in the repository.