Quick start: notarize an agent action and verify it offline
In the next few minutes you will notarize what an agent is about to do, notarize what it did, and then verify one of those records on your own machine using nothing but the Server's published public key. That last step is the point: a record is not "trust us, it is in our database" - it is a signed artifact anyone can check without us.
Prefer to read? The written walkthrough follows.
Before you start - one prerequisite. This guide assumes a running Server and an agent
key, so the curl below is your first notarization, not your first step. If you do not
have a Server yet, that is one command and about five minutes - see the
quick install on Docker Compose (Developer Edition). For an agent key, one
call: see authentication.
The model in three sentences
An agent notarizes what it is about to do, then notarizes what was done; each call returns a signed, tamper-evident record. Between the two an agent can reset its context, hand off, or be replaced - the records stand on their own, byte for byte. Anyone holding the Server's public key can verify each record offline, with no access to the Server, its database, or its network. (For the underlying data model, see records and the chain.)
You do not need the full API to start. Notarize, notarize, verify is the entire on-ramp; delegation, gates, federation, and export are there when you reach for them, not before.
Set your two inputs
export AGLEDGER_API_URL=https://agledger.example.com # your Server
export AGLEDGER_API_KEY=agl_agt_… # your agent key
Confirm the key resolves before going further:
curl -s -H "Authorization: Bearer $AGLEDGER_API_KEY" "$AGLEDGER_API_URL/v1/auth/me"
This page uses notarize-generic-v1, the example notarize-only contract type seeded into
each new org by default. List the types available on yours with GET /v1/schemas; fetch any
type's schema and a copy-pasteable example with GET /v1/schemas/{type}.
1. Notarize what the agent is about to do
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" -H "Content-Type: application/json" \
-d '{"type":"notarize-generic-v1","criteria":{"summary":"About to reconcile invoice INV-4471 against PO-9921"}}' \
"$AGLEDGER_API_URL/v1/records"
{
"id": "019e623f-ad7f-7d0f-a623-67a2df7f1777",
"status": "RECORDED",
"type": "notarize-generic-v1",
"signedStatement": {
"chainPosition": 1,
"leafHash": "c76de57bb39a7a82563ecfa3d10d0d411edbc35805919c1810ee89c8276653d1",
"previousHash": null,
"signingKeyId": "3fecdcafbc1a8b56",
"signedCheckpointRef": null,
"url": "/v1/records/019e623f-ad7f-7d0f-a623-67a2df7f1777/attestation"
}
}
The record is RECORDED and already signed: signedStatement gives its hash, the key
that signed it, and the URL of the signed envelope you will verify in step 3. As an agent
key, you did not have to name the org or the principal - the Server resolved both from your
key. Save the id.
Send the types your schema declares
A JSON body is not type-coerced. A field declared integer refuses "42", a field declared
boolean refuses "true", and neither is rewritten for you. Query strings, path parameters and
headers do coerce, so ?limit=10 stays correct; this rule is about the body only.
The identifier fields are string even when the value is all digits, so quote them:
externalTaskId, projectRef, correlationId, platformRef, publisher.
If you get it wrong, the 400 tells you the fix: details[].received and details[].expected
carry the values, and recoveryHint names the field and the type to send. See
Define custom Types for how this applies to your own criteria fields.
2. Notarize what was done
Between these two calls the agent can do the work, lose its context, or hand off to another process. The first record already stands on its own; the second is a second standalone record. Notarize the outcome the same way:
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" -H "Content-Type: application/json" \
-d '{"type":"notarize-generic-v1","criteria":{"summary":"Reconciled invoice INV-4471 against PO-9921: matched, variance $0.00"}}' \
"$AGLEDGER_API_URL/v1/records"
You now hold two signed records - what was intended and what happened - each independently
verifiable. (notarize-generic-v1 is notarize-only: a record terminalizes at RECORDED on
creation, with no later completion phase. Types that add a completion-and-verdict phase are a
separate step - see what is next, below.)
3. Verify a record offline - the point of the exercise
Verification needs two things and no running Server: the record's signed envelope and the Server's public key. Both are served unauthenticated for the key; the envelope is read with your key.
# the record's signed envelope — served as application/cose-sequence
curl -s -H "Authorization: Bearer $AGLEDGER_API_KEY" \
"$AGLEDGER_API_URL/v1/records/019e623f-ad7f-7d0f-a623-67a2df7f1777/attestation" > record.cose
# the public verification key(s) — unauthenticated, always on
curl -s "$AGLEDGER_API_URL/v1/verification-keys" > keys.json
The attestation read requires the audit:read scope. A default agent key carries it; a
hand-narrowed key scoped to only records:read / records:write gets a 403 here.
The endpoint returns application/cose-sequence: one tagged COSE_Sign1 envelope (RFC 9052)
over an in-toto statement, Ed25519-signed, per chain entry. A record you just notarized has a
single chain entry, so this response is exactly one envelope. Verifying it takes only stock
libraries - here, Python with cbor2 and cryptography, neither of them ours. Save this as
verify.py:
import sys, json, base64, cbor2
from cryptography.hazmat.primitives.serialization import load_der_public_key
from cryptography.exceptions import InvalidSignature
cose = open(sys.argv[1], "rb").read()
keys = {k["keyId"]: k["publicKey"] for k in json.load(open(sys.argv[2]))["data"]}
protected, _unprotected, payload, signature = cbor2.loads(cose).value
kid = cbor2.loads(protected)[4] # COSE protected header: key id
kid = kid.hex() if isinstance(kid, (bytes, bytearray)) else kid
pubkey = load_der_public_key(base64.b64decode(keys[kid]))
sig_structure = cbor2.dumps(["Signature1", protected, b"", payload]) # RFC 9052 Sig_structure
statement = cbor2.loads(payload)
summary = statement["predicate"]["payload"]["criteria"]["summary"]
try:
pubkey.verify(signature, sig_structure)
print(f"[PASS] signature verifies against published key {kid}")
print(f" notarized: {summary!r}")
except InvalidSignature:
print("[FAIL] signature does not verify"); sys.exit(1)
python3 verify.py record.cose keys.json
You should see:
[PASS] signature verifies against published key 3fecdcafbc1a8b56
notarized: 'About to reconcile invoice INV-4471 against PO-9921'
That is the whole guarantee in one line: the record is authentic and unaltered, proven against a key you fetched once, with the Server out of the loop.
When verification fails
A failed verification is the system working. Change one byte of the signed payload and the signature no longer matches:
tampered = bytearray(payload); tampered[len(tampered)//2] ^= 0x01
try:
pubkey.verify(signature, cbor2.dumps(["Signature1", protected, b"", bytes(tampered)]))
print("[BUG ] tampered payload still verified")
except InvalidSignature:
print("[PASS] tampered payload rejected")
[PASS] tampered payload rejected
A signature mismatch like this is one of a small set of integrity failure classes the
auditor-grade verifier reports (CHAIN_SIGNATURE_INVALID, CHAIN_HASH_MISMATCH,
CHAIN_LINK_BROKEN, and others). What each one means, and the full database-independent
audit handoff, is in the audit guide.
Retrying safely (idempotency)
A network timeout on a POST /v1/records leaves you unsure whether the record was written. Retrying
blind would notarize the same action twice. To make a retry safe, send an Idempotency-Key request
header with a unique value (≤256 characters) you choose per logical action:
curl -s -X POST -H "Authorization: Bearer $AGLEDGER_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: reconcile-INV-4471-attempt-1" \
-d '{"type":"notarize-generic-v1","criteria":{"summary":"About to reconcile invoice INV-4471 against PO-9921"}}' \
"$AGLEDGER_API_URL/v1/records"
The key binds to the whole request: its method and route, the record in its path, the body, and the
subject the work is recorded on behalf of. Replay the same key on the same request and you get the
original response back unchanged, with a X-Idempotency-Replayed: true header so you can tell a
replay from a fresh write. Reuse the key on a different request and it is rejected (400, with a
recoveryHint) rather than silently returning the wrong record. A duplicate that arrives while the
first is still in flight gets a retryable 409.
The same header works on every state-changing single-record endpoint - /records, /transition,
/completions, /verdict, /revision, /accept, /reject, /cancel, /dispute, and the rest -
so a wrapper that injects one key per call makes your whole write path retry-safe. Note per call,
not per action: because the record in the path is part of what the key binds to, one key cannot cover
a cancel of two different records. Derive it from the record id if you derive it at all. The
settlement tail (/verdict and /revision) is replay-safe end to end, so a retried verdict or
revision returns the original outcome rather than rendering a second one. Keys are retained for a
bounded window and then reaped, so this protects retries, not indefinite de-duplication.
A few endpoints also take a key in the request body, as idempotencyKey, because a header is not
always available and because these two need a lifetime of their own:
POST /v1/records/bulktakes one per item, so a partial retry of a batch re-sends only the items that did not land.POST /v1/records/{id}/completionstakes one scoped to that record. It binds to the evidence you sent, so replaying it with the same evidence returns the original completion even if the record has since been fulfilled, and reusing it with corrected evidence is refused rather than answered with the first submission. Send a fresh key when the evidence changes.- The A2A surface (
POST /a2a) carries it in the intent's data part oncreate_record,transitionandsubmit_completion, because JSON-RPC has no headers to put it in.
The rule is the same everywhere: same key plus same bytes replays, same key plus different bytes is refused.
What is next
- Verify at scale, for an auditor. This page verified one envelope by hand. The audit guide covers the database-independent export an auditor verifies offline with only your published keys.
- Link work across agents. Name a performer on the record (
performerAgentId) and the two parties sign their steps in sequence - a delegation chain across a handoff. - Add a verdict. Contract types with a completion-and-verdict phase let a principal accept or reject a delivered result, on top of the notarize spine.
Air-gapped
Nothing here depends on our website, Docker Hub, or npm. The two inputs - the record
envelope and the public keys - are served by your own Server; verify.py uses only stock
cryptography libraries and runs with no network. Save the envelope and the key once and you
can verify the record on a disconnected machine indefinitely.