Documentation
Both SDKs are dependency-free and can be copied into a project rather than installed — a supplier being told to add a dependency to a security-reviewed codebase will often simply not do it.
Quickstart
Mint an API key in the console, then report your first decision. There is nothing to create first: an unknown agent reference registers itself on arrival.
Python
pip install requests # not needed — the SDK is standard library only
# Drop agentaudit.py into your project, or install the package.
from agentaudit import AgentAudit
audit = AgentAudit(
api_key=os.environ["AGENTAUDIT_KEY"],
agent="triage-bot", # the chain is per agent
agent_name="Benefit claim triage",
model="claude-opus-5",
on_dropped=lambda d, why: log.error("AUDIT LOSS: %s %s", why, d),
)
audit.record(
action="route-claim",
outcome="escalated", # allowed | denied | held | escalated | error
subject="claim/88213",
rationale="Income evidence inconsistent with declared hours.",
policy_version="v3",
human_review="in-the-loop", # none | in-the-loop | on-the-loop | post-hoc
reviewer="caseworker-441",
)
audit.close() # before the process exits. Returns the number never delivered.TypeScript
import { AgentAudit } from '@blackout/agentaudit';
const audit = new AgentAudit({
apiKey: process.env.AGENTAUDIT_KEY!,
agent: 'triage-bot',
agentName: 'Benefit claim triage',
model: 'claude-opus-5',
onDropped: (d, why) => logger.error({ d, why }, 'AUDIT LOSS'),
});
audit.record({
action: 'route-claim',
outcome: 'escalated',
subject: 'claim/88213',
rationale: 'Income evidence inconsistent with declared hours.',
policyVersion: 'v3',
humanReview: 'in-the-loop',
reviewer: 'caseworker-441',
});
// Or wrap an existing function. It records that the call happened and whether
// it threw — it will not invent a rationale, because only you know that.
const route = audit.wrap('route-claim', routeClaim);
await audit.close();Any language
curl -X POST https://your-domain/api/v1/agentaudit/decisions \
-H "authorization: Bearer bk_live_..." \
-H "content-type: application/json" \
-d '{
"agent": "triage-bot",
"decisions": [{
"decidedAt": "2026-09-07T09:14:22Z",
"action": "route-claim",
"outcome": "denied",
"subject": "claim/88212",
"rationale": "Declared hours contradicted by submitted payslips.",
"policyVersion": "v3",
"humanReview": "in-the-loop",
"reviewer": "caseworker-441",
"clientRef": "req-8f21c"
}]
}'clientRef is your idempotency key. Send one and a retry after a timeout cannot append a second copy — a duplicated decision reads as the agent having denied the same person twice.
API reference
Authenticate with a bk_ key in an authorization: Bearer header, or x-api-key. Keys are stored hashed, so a database leak does not hand anybody working credentials.
| Endpoint | Does |
|---|---|
| POST /api/v1/agentaudit/decisions | Append decisions. Up to 500 per request. All-or-nothing: if one fails validation none are written, so a client can correct and resend without risking duplicates. |
| GET /api/v1/agentaudit/decisions?agent=REF | Read the record back, paged by sequence. The response says whether it is complete, so verifying page one of forty cannot be mistaken for verifying the record. |
| GET /api/v1/agentaudit/verify?agent=REF | Our own verification. Convenient, and worth exactly as much as your trust in us — which is why the algorithm below is published. |
| GET /api/v1/agentaudit/compliance?agent=REF | What the record can and cannot evidence, per obligation, with every figure counted at the database. |
| GET, POST /api/v1/agentaudit/agents | The register. Declare an owner, a risk classification, a status. |
What a decision records
Nothing is defaulted except humanReview, which defaults to none — the conservative direction, since claiming a human reviewed something when none did is the serious error. A missing timestamp is refused rather than filled in with the time of receipt; an unrecognised outcome is refused rather than rounded to one we know.
| Field | Required | Notes |
|---|---|---|
| decidedAt | Yes | When the AGENT decided, ISO 8601. Received-at is recorded separately; the gap between them is itself a fact. |
| action | Yes | What it did, in your own vocabulary. Never interpreted. |
| outcome | Yes | allowed, denied, held, escalated or error. Not assumed — an assumed outcome is a fabricated one. |
| rationale | No | The agent’s own stated reasoning. Never generated by us. |
| subject | No | What was acted on: a case reference, an account, a document. |
| model | No | The model in force at the time. Where it changed mid-period the record shows exactly when — which a declared specification alone cannot. |
| policyVersion | No | What governed the agent at that moment. Without it a change in behaviour cannot be tied to the change that caused it, which is the question asked after every incident. |
| humanReview | No | none, in-the-loop, on-the-loop or post-hoc. Defaults to none. |
| reviewer | No | Who reviewed it. |
| detail | No | Anything else, stored verbatim as JSON. |
| clientRef | No | Your idempotency key. Strongly recommended. |
The hashing algorithm
Published in full, and stable. Changing it would break every verifier anybody has written, which is precisely the constraint that makes this worth something: a vendor who can silently change the rules of their own tamper-evidence has no tamper-evidence.
Canonical form, version aa-1
These thirteen fields, in this exact order, joined by the ASCII unit separator (byte 0x1F). Absent and null values contribute an empty string.
- 1.chain version ("aa-1")
- 2.agent reference
- 3.sequence number
- 4.decidedAt, as UTC ISO 8601
- 5.action
- 6.outcome
- 7.rationale
- 8.subject
- 9.model
- 10.policy version
- 11.human review
- 12.reviewer
- 13.detail, keys sorted recursively
The hash is then SHA-256( previousHash + 0x1E + canonicalForm ), where the previous hash is the literal text genesis for an agent’s very first decision. Output is lowercase hexadecimal.
Why unit separators and not JSON
JSON.stringify serialises object keys in insertion order, so the same decision hashed on two code paths can produce two different hashes and a perfectly intact chain reads as broken. Byte 0x1F cannot occur in any of the values, so no field can be made to look like two — nobody can move characters from the end of action to the start of outcome and keep the digest.
Verifying it yourself
A complete independent verifier, in standard-library Python. This is the whole thing — there is no part of the check that requires anything from us.
import hashlib, json, urllib.request
US, RS = "\u001f", "\u001e" # unit separator, record separator
def stable(v):
"""JSON with object keys sorted, recursively. Arrays keep their order."""
if v is None or not isinstance(v, (dict, list)):
return json.dumps(v, separators=(",", ":"))
if isinstance(v, list):
return "[" + ",".join(stable(x) for x in v) + "]"
items = sorted(v.items())
return "{" + ",".join(json.dumps(k) + ":" + stable(x) for k, x in items) + "}"
def canonical(d, agent):
return US.join([
"aa-1",
agent,
str(d["seq"]),
d["decided_at"], # already UTC ISO 8601
d["action"],
d["outcome"],
d.get("rationale") or "",
d.get("subject") or "",
d.get("model") or "",
d.get("policy_version") or "",
d.get("human_review") or "",
d.get("reviewer") or "",
"" if d.get("detail") is None else stable(d["detail"]),
])
def verify(export):
agent, rows = export["agent"], sorted(export["decisions"], key=lambda r: r["seq"])
prev, faults = None, []
for i, d in enumerate(rows):
if i and d["seq"] != rows[i - 1]["seq"] + 1:
faults.append(f'gap before sequence {d["seq"]}')
if (d.get("prev_hash") or None) != prev:
faults.append(f'sequence {d["seq"]} does not point at its predecessor')
pre = (prev or "genesis") + RS + canonical(d, agent)
if hashlib.sha256(pre.encode()).hexdigest() != d["hash"]:
faults.append(f'sequence {d["seq"]} has been altered')
prev = d["hash"]
return faults
with open("export.json") as f:
problems = verify(json.load(f))
print("INTACT" if not problems else "\n".join(problems))Or use the browser verifier, which runs the same check locally and uploads nothing.
What is guaranteed, and what is not
Guaranteed
- A decision cannot be edited after it is written. There is no UPDATE policy and a database trigger refuses one even for a caller that reaches the table directly.
- A decision cannot be deleted. The same trigger blocks DELETE, and an agent row cannot be removed while decisions reference it — so deleting an agent is not a way to erase its history.
- A removal from the middle is detectable by anyone holding the export, with no access to our database.
- Sequence numbers are assigned by the server, so the only thing a caller can do to the record is add to it.
Not guaranteed
- That a decision was reported at all. An agent that never calls the API leaves no gap to find — which is why the SDK treats an undeliverable decision as a loud error rather than a dropped metric, and why
close()returns the number that never arrived. - That what an agent reported is true. We record the account the agent gives; we cannot audit the agent’s honesty about its own reasoning.
- That the agent behaved well. A record can be perfectly intact and describe a system making poor decisions consistently.
- That you are compliant. We evidence what the record can show and name what it cannot. The obligations marked as outside our competence are real obligations, and they are the ones an auditor asks about first.