誓
AgentOath Integrate
← Back · GitHub · 中文
INTEGRATION GUIDE

Turn an action into a verifiable fact

Your log is your word. A receipt is verifiable evidence. The difference only shows up on the day someone disputes it — by then it is too late to add it. This page covers what actually bites during integration; every item here was written down after getting bitten by it.

Step one: decide what to notarize

Do not "record everything." Receipts are public and cannot be deleted — noise stays forever.

✓

Worth notarizing

Something someone will later need to prove happened, and happened a specific way. Compliance verdicts, approvals and denials, trades and pricing, commitments made to a customer. The common thread: it can be disputed, and a second party can disagree.

✗

Not worth it

Internal process steps, debug messages, every poll, cache hits. Nobody will ever ask you to prove these — integrating them is just extra maintenance, and it turns the Registry into a log system, which is not what it is for.

◈

The test is one sentence

"What can I produce when this gets disputed?" A receipt and your own log are, to the other party, answers of two different classes.

The three interop rules

🔴 Violating one of these shows up as "receipt rejected," not a code error— which makes it hard to diagnose. The client library below already blocks all three locally.

①

rating must be a JSON integer, 0–10

Not "a number" — an integer. 7.5 gets rejected. If you are not rating it, leave the field out entirely.

Why: Python prints 7.0 as "7.0"; JavaScript prints it as "7". The same receipt hashes to different canonical bytes on each side, so the signature never verifies.

②

Omit empty optional fields entirely

Do not send to_agent: "" or action: "". Include the key only when it has a value — no value means no key at all.

Why: The signature covers the canonical bytes. One extra empty string means what you signed is not what the Registry verifies ⇒ empty_optional_field comes back.

③

metadata must always be present — {} if empty

And it has to be filled in before you sign, never added afterward.

Why: metadata is part of the signed bytes. Add it after signing and the bytes you send no longer match the bytes you signed.

The metadata naming trap

The easiest one to trip over. The Registry keeps a private-keyword blocklist, and itsplits field names on underscores and matches each segment — so the instinct of "just add a suffix" is wrong here.

# ❌ any of these gets the WHOLE receipt rejected
prompt_hash      # splits out prompt ⇒ blocked
user_email       # splits out email ⇒ blocked
raw_count        # splits out raw ⇒ blocked
session_id       # splits out session ⇒ blocked

# ✅ just rename it — meaning is unchanged
content_digest   text_digest   actor_ref   order_ref   tenant_ref

This is deliberate, not a bug. It blocks accidentally notarizing customer data. Full blocklist (28 keys — blocked at any level, any segment, anywhere in the receipt):

api_key   api_keys   access_token   auth_token   authorization   cookie   cookies   credential   credentials   email   full_receipt   html   image   messages   ocr_text   password   private_key   prompt   raw   raw_content   request_body   response   secret   screenshot   session   token   transcript   user_content

Only put in irreversible values

Receipts are public, and there is no delete API.

✗

Do not include

Raw copy text, names, phone numbers, email addresses, raw order numbers, exact monetary amounts, any free-text user input.

✓

Do include

Enum values (verdict / decision / kind), version numbers (rulebook version, code commit),sha256 reference digests, bucketed ranges (amounts as buckets, not exact figures).

◈

Ask yourself one question

"Would I regret it if this receipt were posted online?" If yes, leave it out. A hash reference can prove "this is the same one" without revealing which one — that is exactly the property you want.

What this guarantees, and what it does not

🔴 See where the line is before you rely on this.The "guarantees" column below is mathematics — anyone can verify it independently; the "does not guarantee" column is a current implementation limit, not a promise.

✓

Guaranteed: content is unaltered

Change a single byte in the receipt and the signature fails to verify. The Registry itself cannot forge this either — it does not have your private key, and the verify endpoint does not need to trust it.

✓

Guaranteed: identity cannot be forged

The DID is derived from the public key (sha256), and anyone can recompute it offline. Without your private key, nobody can sign a receipt under your DID.

✗

Not guaranteed: it will never be deleted

Receipts live in an ordinary database. There is no blockchain-style immutability guarantee, and no hash chain or external anchor. "It will not be deleted" currently means trusting the Registry's operator, not mathematics.

✗

Not guaranteed: the timestamp is real

The timestamp field is filled in by the signer — you could sign a receipt dated three years ago. The Registry separately records when it received the receipt, but that is also just its own word, not independently verifiable proof.

✗

Not guaranteed: the list is complete

There is no way to prove a given receipt was not inserted after the fact, and no way to prove the list you see has nothing left out.

◈

So what it is good for

Good for evidence you produce yourself — you want to prove something happened the way you said, and the other party's doubt is "you edited this afterward." Not good for arguing against the Registry itself, and not a trusted timestamp on its own. For that, see below.

Need a trusted timestamp or non-deletion? Both gaps already have a ready-made fix, and it ships in the package:

# only the hash is sent — the receipt and the file never leave your machine
pip install "agentoath[timestamps]"

from agentoath.timestamps import stamp_receipt, verify, upgrade

proof = stamp_receipt(receipt)
report = verify(proof)
report["any_valid"]          # a timestamp authority signed this hash together with its own clock
report["bitcoin_confirmed"]   # …and it is already written into a Bitcoin block

proof = upgrade(proof)        # a few hours later: pending → confirmed

Both mechanisms are used deliberately, because they fail differently. RFC 3161 has a timestamp authority sign with its own clock — instant, and a format courts and eIDAS recognize; you trust that authority, not us. OpenTimestamps writes the hash into Bitcoin — no party to trust, at the cost of waiting a few hours for it to land in a block.

🔴 Once you hold an OpenTimestamps proof plus your own copy of the receipt, even if every receipt here were deleted, you can still prove the thing happened, and when. It does not make the database undeletable — it makes the database **not matter** — which is the property you actually want.

The JavaScript package does not have this module: the only long-lived OpenTimestamps library on that side stopped being maintained in 2019, and the newer alternatives are all v0.x, unknown authors, published less than two months ago. A package other people install should not carry that kind of dependency. targetOfReceipt() gives you the hash to stamp; do the actual stamping with the Python package or the ots CLI — the proof format is language-neutral JSON.

Attesting a document

There is no "document receipt" type, and that is the point: a document is an ordinary receipt plus one metadata convention, so signing, the cross-language byte-identical canonical form, the privacy blocklist, and the Registry's validation all keep working unchanged. A new type would mean re-earning all of that from scratch.

# Python
from agentoath.documents import build_document_receipt
receipt = build_document_receipt(identity, "scans/book-0417.pdf",
    refs={"source_ref": "isbn:9780000000001", "acquired_ref": "po-2026-0817"})

// JavaScript
import { buildDocumentReceipt } from 'agentoath/documents';

receipt_id is derived from the file's hash, so attesting the same file twice produces the same receipt, not two unrelated ones. The file is read as a stream — a multi-gigabyte scan does not have to fit in memory.

🔴 The file itself never leaves your machine — only its sha256 goes into the receipt. The hash proves "this is the same one" to someone who already has the file, and proves nothing to anyone who does not — which is exactly the property you want when the thing you are attesting is a scan you cannot redistribute.

Reference fields must end in _ref and stay under 128 characters. Receipts are public and permanent — a sentence you paste in cannot be taken back.

🔴 A receipt is a side effect, not a precondition

If the Registry is down, the key is not set, or the network is unreachable, your main function must still complete normally. The reverse must never happen.

// ✅ the correct shape
const result = await doTheActualWork();      // do the real work first
try {
  recordReceipt(result);                     // async — don't make the user wait on the network
} catch { /* a recording failure must never touch the line above */ }
return result;                               // return it exactly as-is

Three more things worth doing in practice: off by default (not a single byte is sent until it is explicitly turned on); if it cannot send, sign it and land it in an outbox to retry later — the receipt id is fixed at signing time, so a retry goes through the idempotent path and never becomes two records; catch at both layers — the caller wraps it in try/catch, and the recording function itself never throws either.

Client libraries

Both official packages build this in. Zero extra dependencies (Python adds onlycryptography; JS uses Node's built-in crypto), canonical JSON byte-identical to the Registry, all three interop rules enforced locally first, the blocklist scanned locally first, off by default.

# Python
pip install agentoath
from agentoath.hosted import Identity, build_signed_receipt

// JavaScript (ESM; use await import for CJS)
npm install agentoath
import { Identity, buildSignedReceipt } from 'agentoath/hosted';

Don't want the dependency, or not using either language? This is the same thing as a single zero-dependency file you can drop straight into your project:

⬇ Download agentoath-client.mjs

// save as lib/agentoath.mjs and import it directly
import { Identity, RegistryClient, buildSignedReceipt, sha256Json } from "./lib/agentoath.mjs";

// 1. generate an identity (store the private key in an env var, never commit it)
const id = Identity.generate();
console.log(id.did, id.privateKey);

// 2. register once (idempotent — rerunning never creates a second identity)
const c = new RegistryClient({ apiKey: process.env.AGENTOATH_KEY, enabled: true });
await c.registerIdentity(id, { name: "my-service", platform: "my-platform" });

// 3. every action worth notarizing
const receipt = buildSignedReceipt(id, {
  receiptId: `verdict-$${orderId}`,
  action: "compliance.verdict.red",
  metadata: { schema: "v1", rulebook_version: "2026-08-20a", content_digest: sha256Json(adText) },
});
await c.publishReceipt(receipt);

🔴 Do not rewrite canonical JSON yourself. One bit of difference between the two sides and the signature never verifies, and the symptom is "receipt rejected," not an error. Use the file above, or implement the spec at GET /api/v1/registryliterally, byte for byte. The Python-side equivalent is json.dumps(v, ensure_ascii=False, sort_keys=True, separators=(",", ":")).

How to prove it actually worked

🔴 Do not look at "no errors" — that is not evidence. Evidence looks like a state flip.

# before
curl -s $REGISTRY/api/v1/agents/$DID/status
# → receipt_count: 0

# trigger one REAL action (do not use fake test data)

# after
curl -s $REGISTRY/api/v1/agents/$DID/status
# → receipt_count: 1   ← this 0→1 is the evidence

# negative control: change any one field in the same receipt, it must flip to valid=false
curl -s -X POST $REGISTRY/api/v1/receipts/verify \
  -H "Content-Type: application/json" \
  -d '{"receipt": <receipt with one field changed>, "from_public_key": "<your public key>"}'
# → {"valid": false, ...}

Verify both directions. Checking only that the valid case passes is not enough — it might just say pass to everything. Only when the negative control actually flips to false is the signature proven to be protecting the content.

Common snags

SymptomActual cause
publish returns 422, message says signature invalidNine times out of ten it is one of the three interop rules, or a self-written canonical JSON that differs from the Registry by one bit. Run the client's verifyLocally locally first.
Returns private field / field rejectedThe metadata key, split on underscores, hits the blocklist (things like prompt_hash, user_email). Rename it and it's fixed.
Receipt count stays 0, but the code reports no errorsThe client is off by default, and it silently does not send. Check whether enabled and the API key are actually being read.
Two receipts show up for the same actionreceipt_id used a timestamp or a random value. It must be derived from the action itself (e.g. a hash of the order id), so a rerun lands on the same record.
Old receipts don't show up after rotating keysThe DID is derived from the public key, so a new key means a new identity — reputation resets to zero. This is by design, not a bug. Treat your private key as a long-term asset.