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_URL=https://your-server.example     # your Server
export AGLEDGER_KEY=agl_agt_…                        # your agent key

Confirm the key resolves before going further:

curl -s -H "Authorization: Bearer $AGLEDGER_KEY" "$AGLEDGER_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_KEY" -H "Content-Type: application/json" \
  -d '{"type":"notarize-generic-v1","criteria":{"summary":"About to reconcile invoice INV-4471 against PO-9921"}}' \
  "$AGLEDGER_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.

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_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_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_KEY" \
  "$AGLEDGER_URL/v1/records/019e623f-ad7f-7d0f-a623-67a2df7f1777/attestation" > record.cose

# the public verification key(s) — unauthenticated, always on
curl -s "$AGLEDGER_URL/v1/verification-keys" > keys.json

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_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_URL/v1/records"

The key binds to the method, route, and request body. Replay the same key with the same body 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 with a different body and the request 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 action makes your whole write path retry-safe. 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. The separate per-item idempotencyKey body field is specific to the batch endpoint POST /v1/records/bulk.)

What is next

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.


Validated against API v1.1.0 on 2026-06-10 (Developer Edition, Docker Compose install).