Audit Event
FreeNot checkedHash-chained audit log for AI agents — MCP-native, Cloudflare Durable Objects + SQLite
About
Hash-chained audit log for AI agents — MCP-native, Cloudflare Durable Objects + SQLite
README
Agent steward infrastructure — hash-chained audit log with Merkle notarisation and a GDPR/AI-Act compliance skill.
CI npm npm downloads License: MIT Smithery
What it does
- Records agent events (tool calls, decisions, human turns) to a per-client SQLite log with a SHA-256 hash chain — every record is cryptographically linked to the previous one
- Optionally notarises batches with a Merkle root + Ed25519 signature (paid tier) — external auditors can verify without contacting kajaril
- Exports all events for a data subject as NDJSON on demand (GDPR Art. 20 portability)
- Ships a Claude Code skill that runs 8 GDPR/AI-Act axes against any event store
MCP endpoint
https://audit-event.kajaril.com/mcp
JSON-RPC 2.0 over HTTPS, authenticated via Cloudflare Access (service token) or an OAuth client-credentials access token — see Authentication. Contact [email protected] for onboarding.
Tools
| Tool | Description |
|---|---|
record_event |
Write one audit event. Returns { id, chain_hash }. |
verify_chain |
Recompute chain_hash for a range of events. Returns verified count and broken entries. |
query_events |
Filter by session, agent, event type, or date range. input_hash is never returned. |
export_dossier |
Export all events for a subjectId as NDJSON (GDPR Art. 20). Returns a 1-hour download URL. |
request_approval |
Ask a human to approve an action. Returns approvalId, an approvalUrl approve page, and your webhookSecret. Witnessed as approval.requested in the chain. |
check_approval |
Poll an approval: pending | approved | denied | timeout, with reason and responder once decided. |
Quick start
Record an event:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "record_event",
"arguments": {
"agentId": "travel-assistant",
"eventType": "tool.call",
"purpose": "User requested flight search via travel assistant",
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"input": { "tool": "search_flights", "origin": "LHR", "dest": "JFK" },
"lawfulBasis": "contract",
"subjectId": "user-8821"
}
}
}
Verify the chain:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "verify_chain", "arguments": { "limit": 100 } } }
Export a data subject's records:
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "export_dossier", "arguments": { "subjectId": "user-8821" } } }
Authentication
Three ways in; all end at a signature-verified client_id that selects your tenant —
nothing user-supplied ever does.
Cloudflare Access service token (manual onboarding): send the token headers, CF Access injects a verified JWT. Full surface.
OAuth browser flow (MCP clients, zero copy-paste): point your client at the server and let it discover the rest —
claude mcp add --transport http audit-event https://audit-event.kajaril.com/mcp
RFC 8414 metadata advertises dynamic client registration (/oauth/register),
authorization with PKCE S256 (/oauth/authorize), and the shared token endpoint. Consent
is approved in the browser by a signed-in tenant operator; the granted scope (agent or
admin) is bound into the issued token.
OAuth client-credentials (M2M): exchange a client secret for a 1-hour Bearer token at
POST /oauth/token (application/x-www-form-urlencoded, HTTP Basic or client_id /
client_secret body params):
curl -s https://audit-event.kajaril.com/oauth/token \
-u "$CLIENT_ID:$CLIENT_SECRET" \
-d grant_type=client_credentials -d scope=agent
Two scopes, issued and rotated independently:
| Scope | May call |
|---|---|
agent |
record_event, request_approval, check_approval |
admin |
everything, plus POST /credentials/rotate |
Secrets are shown once at issue/rotation (POST /credentials/rotate, body {"scope":"agent"})
and stored only as hashes in your tenant's Durable Object. Rotating a scope invalidates its
old secret immediately; outstanding access tokens expire within the hour.
The approval tools are also plain REST for production backends: POST /approvals (same body
as request_approval) and GET /approvals/:id, with Authorization: Bearer <access token>.
How it works
Hash chain
Every event commits to all events before it. The chain is recomputable from first principles:
input_hash_slot = input_hash ?? input_hash_omitted_reason
chain_hash = SHA-256(id + "|" + event_type + "|" + input_hash_slot + "|" + prev_hash)
When a caller supplies inputHashOmittedReason instead of input, the reason string enters the chain — the omission itself is tamper-evident.
Merkle notary (paid tier)
Every 15 minutes (or at 1,000 pending events), the notary Worker:
- Collects
{ id, chain_hash }pairs from pending records - Sorts them by id and builds a SHA-256 binary Merkle tree
- Signs the root with Ed25519
- Writes
merkle_root+notary_sigback to each record
The notary public key is published at /.well-known/notary-pubkey (also proxied
same-origin on go.kajaril.com for the verify page). Any auditor can verify signatures
offline without contacting kajaril. The notary never receives input_hash, payload_ref,
or any payload content.
Verifying a dossier
export_dossier returns a link to a human-readable dossier on go.kajaril.com with a
raw JSONL evidence file attached. Anyone can drop that file onto
https://go.kajaril.com/verify — the browser recomputes every record's chain
fingerprint from its exported preimage (id | event_type | input_hash ?? omitted_reason | prev_hash) and checks each notary Ed25519 signature against the published key. Nothing is
uploaded; the verdict is computed client-side.
Privacy design
inputis hashed locally; the raw value is not stored unless an R2 bucket is explicitly boundinput_hashis excluded fromquery_eventsresponses; dossiers export it (withprev_hash) as the chain preimage that makes independent verification possible — they are digests, never contentpayload_refis never returned to callers — enforced at every handler boundary- An empty
export_dossierresult (eventCount: 0) is valid GDPR evidence that no data exists for a subject
Event types
tool.call | tool.result | decision.made |
human.turn | memory.read | memory.write | error.raised |
approval.requested | approval.decided
approval.* types are reserved: the witness records them itself when a human approval is
requested and decided. record_event refuses them — an agent must never be able to fabricate
human-decision evidence. (approval.deferred and approval.escalated are reserved for later.)
Decision webhooks
When an approval carries a callback_url, the decision (approve/deny) is POSTed to it the
moment a human decides — this is what lets an interrupted agent resume instead of poll.
Timeouts never fire a webhook; polling resolves those.
Every delivery is signed. The body is JSON:
{
"type": "approval.decided",
"approval": { "id": "…", "status": "approved", "reason": null, "responderId": "…",
"agentId": "…", "sessionId": "…", "actionSummary": "…",
"actionPayloadHash": "…", "createdAt": "…", "decidedAt": "…", "expiresAt": "…" },
"chainEvent": { "id": "…", "chainHash": "…" }
}
chainEvent points at the approval.decided entry in the hash chain — cite it as evidence.
Verifying the signature. Your webhook secret (whsec_…) is returned in every
request_approval response as webhookSecret — no dashboard needed. Each delivery carries:
X-Kajaril-Signature: t=<unix seconds>,v1=<hex>
To verify:
- Read
tandv1from the header. Reject if|now − t|exceeds 300 seconds. - Compute
HMAC-SHA256(key = the literal secret string (UTF-8, whsec_ prefix included), message = "{t}." + raw request body). - Hex-encode and compare to
v1with a constant-time comparison.
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
The reference implementation is verifyWebhookSignature in src/lib/webhook.ts — the same
code path our tests run.
Self-hosting note: webhook signing derives per-tenant secrets from the WEBHOOK_SIGNING_SECRET
Workers Secret. If it is unset, webhooks are not sent at all (never unsigned) and
request_approval returns webhookSecret: null; polling still works.
Notification channels
request_approval accepts channels: ["telegram", "email"] and notifies the approver over a
ladder: instant first, email as the fallback.
Telegram (instant). Connect once — POST /channels/telegram/connect (admin scope) returns a
one-time t.me deep link (15-minute expiry). Open it, press Start, and approval cards arrive in
that chat with inline Approve / Deny / Deny with reason buttons. Deny-with-reason
prompts for a short reply that is recorded with the decision — stronger Art. 14 evidence than a
binary stamp. Connecting again replaces the bound chat.
Email (fallback). Set the approver address once — POST /channels/email (admin scope) with
{"address": "…"}. The email fires only when it is needed:
- immediately, if no instant channel delivered (not connected, send failed, or not requested);
- after 10 minutes, if an instant card was delivered but the approval is still undecided;
- never, if the approval would expire before the email could matter.
Mail is sent from [email protected] with the decide link; replies reach a human via
[email protected].
Dispatch report. Every request_approval response includes what actually happened:
"notifications": { "telegram": "sent", "email": "scheduled" }
(telegram: sent | failed | not_connected | unconfigured | not_requested; email:
immediate | scheduled | expires_first | no_address | unconfigured | arm_failed | not_requested.)
TTL defaults follow the channels: 30 minutes when an instant channel is in play, 4 hours when
the request is email-only, explicit ttlSeconds always wins.
Self-hosting note: channels are optional. Without TELEGRAM_BOT_TOKEN / RESEND_API_KEY /
CHANNELS_KV, dispatch reports unconfigured and the approval still works through
approvalUrl + polling. The bindings are documented in wrangler.jsonc and wrangler.go.jsonc.
Lawful basis (GDPR Art. 6)
legitimate_interest | contract | legal_obligation |
vital_interest | public_task | consent
Provide lawfulBasis for any event that processes personal data. Omit it for events that do not.
Tiers
| Capability | Free | Paid |
|---|---|---|
| Hash-chained event writes | yes | yes |
verify_chain |
yes | yes |
export_dossier (GDPR Art. 20) |
yes | yes |
| R2 payload storage | optional | optional |
| Merkle root + Ed25519 notarisation | — | yes |
| compliance-audit axis 6 (notary coverage) | skipped | evaluated |
compliance-audit skill
skills/compliance-audit/SKILL.md is a Claude Code skill that runs 8 GDPR/AI-Act compliance axes against any event store. Copy it into your workspace:
cp -r node_modules/@kajaril/audit-event-mcp/skills/compliance-audit .claude/skills/
Axes:
- Lawful basis present — GDPR Art. 5–6
- Purpose specificity — GDPR Art. 5(1)(b)
- Subject linkage on
human.turnevents - Retention bounded (≤ 730 days)
- Chain integrity — 10% sample recompute
- Notary coverage ≥ 95% (paid tier only)
- High-risk event completeness — AI Act Art. 12–13
- Data minimisation signal —
payload_reffraction
Each axis produces pass | fail | warn with an evidence snippet. Output is JSON + a markdown summary block.
Self-hosted deployment
Prerequisites
- Cloudflare account with Workers and Durable Objects enabled
wranglerCLI authenticated
Steps
# 1. Create R2 bucket
wrangler r2 bucket create audit-payloads
# 2. Set notary signing key (paid tier)
wrangler secret put NOTARY_PRIVATE_KEY --config wrangler.notary.jsonc
# Value: hex-encoded Ed25519 private key — never committed to source
# 3. Deploy Workers
wrangler deploy
wrangler deploy --config wrangler.notary.jsonc
# 4. Add audit-event.kajaril.com as a CF Access Self-hosted Application
# Issue one service token per client with custom.client_id claim
# 5. Onboard a client
npx tsx src/scripts/onboard-client.ts --client-id acme-crm --tier free --region eu
# 6. Verify
curl https://audit-event.kajaril.com/health
Never run wrangler deploy from a dirty working tree. The deployed code must match git HEAD.
Development
npm install
npm test # 81 tests (node:sqlite adapter, no cloudflare/vitest-pool-workers)
npm run typecheck
npm run lint
License
MIT. Copyright © 2026 Kajaril Ltd.
Installing Audit Event
This server has no published package — it is built from source. Open the repository and follow its README.
▸ github.com/mightbesaad/audit-event-mcpFAQ
Is Audit Event MCP free?
Yes, Audit Event MCP is free — one-click install via Unyly at no cost.
Does Audit Event need an API key?
No, Audit Event runs without API keys or environment variables.
Is Audit Event hosted or self-hosted?
A hosted option is available: Unyly runs the server in the cloud, no local setup required.
How do I install Audit Event in Claude Desktop, Claude Code or Cursor?
Open Audit Event on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.
Related MCPs
wenb1n-dev/SmartDB_MCP
A universal database MCP server supporting simultaneous connections to multiple databases. It provides tools for database operations, health analysis, SQL optim
by wenb1n-devPostgres Server
This server enables interaction with PostgreSQL databases through the Model Context Protocol, optimized for the AWS Bedrock AgentCore Runtime. It provides tools
by madhurprashPostgres
Query your database in natural language
by AnthropicPostgreSQL
Read-only database access with schema inspection.
by modelcontextprotocolCompare Audit Event with
Not sure what to pick?
Find your stack in 60 seconds
Author?
Embed badge for your README
Browse similar
All data MCPs
