Skip to main content

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

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

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

bash
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.

AgentAudit HTTP endpoints
EndpointDoes
POST /api/v1/agentaudit/decisionsAppend 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=REFRead 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=REFOur 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=REFWhat the record can and cannot evidence, per obligation, with every figure counted at the database.
GET, POST /api/v1/agentaudit/agentsThe 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.

Decision fields
FieldRequiredNotes
decidedAtYesWhen the AGENT decided, ISO 8601. Received-at is recorded separately; the gap between them is itself a fact.
actionYesWhat it did, in your own vocabulary. Never interpreted.
outcomeYesallowed, denied, held, escalated or error. Not assumed — an assumed outcome is a fabricated one.
rationaleNoThe agent’s own stated reasoning. Never generated by us.
subjectNoWhat was acted on: a case reference, an account, a document.
modelNoThe model in force at the time. Where it changed mid-period the record shows exactly when — which a declared specification alone cannot.
policyVersionNoWhat 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.
humanReviewNonone, in-the-loop, on-the-loop or post-hoc. Defaults to none.
reviewerNoWho reviewed it.
detailNoAnything else, stored verbatim as JSON.
clientRefNoYour 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. 1.chain version ("aa-1")
  2. 2.agent reference
  3. 3.sequence number
  4. 4.decidedAt, as UTC ISO 8601
  5. 5.action
  6. 6.outcome
  7. 7.rationale
  8. 8.subject
  9. 9.model
  10. 10.policy version
  11. 11.human review
  12. 12.reviewer
  13. 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.

python
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.