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:

The rule is the same everywhere: same key plus same bytes replays, same key plus different bytes is refused.

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.