Skip to main content

obsigna

Python SDK for the Agent Receipts protocol

PyPI License: Apache 2.0 Python CI


Create, sign, hash-chain, store, and verify cryptographically signed audit trails for AI agent actions.

Spec • TypeScript SDK • Reference Implementation


Why receipts?

If you're building with AI agents, you're probably already logging what they do. Receipts go further: they're cryptographically signed, hash-chained records that can't be quietly altered after the fact — and they follow a standard format that works across languages, agents, and systems.

Here's where that matters in practice:

  • Post-incident review — An agent ran overnight and something broke. The receipt chain shows exactly which actions it took, in what order, and whether each succeeded or failed — with cryptographic proof the log hasn't been tampered with after the fact.

  • Compliance and audit — Regulated environments require evidence of what systems did and why. Receipts are W3C Verifiable Credentials with Ed25519 signatures, giving auditors a tamper-evident trail they can independently verify.

  • Safer autonomous agents — Agents can query their own audit trail mid-session. Before taking a high-risk action, an agent can check what it has already done and whether previous steps succeeded, enabling self-correcting workflows.

  • Multi-agent trust — When agents collaborate, receipts serve as proof of prior actions. Agent B can verify that Agent A actually completed step 1 before proceeding to step 2, without trusting a shared log.

  • Usage tracking — Every action is classified by type and risk level, giving you a structured breakdown of what agents spent their time on.

Beyond local storage

The protocol is designed for receipts to travel — publishing to a shared ledger, forwarding to a compliance system, or exchanging between agents as proof of prior actions. Receipts are portable W3C Verifiable Credentials, but where they go is always under the user's control.


Install

The Python SDK is on PyPI:

pip install obsigna

Receipts are signed by a separate obsigna-daemon process, which also ships the agent-receipts CLI (including the verify command). Install it on Linux or macOS (Windows is not supported yet) — see the Daemon Setup guide.

Quick start

Agent Receipts are signed by a separate obsigna-daemon process, not by your application. Your code sends tool-call events over a local Unix socket; the daemon holds the Ed25519 signing key, builds the receipt, signs it, and appends it to the hash-chained store. The signing key never enters your process — so the audit trail holds up even if the agent is compromised. This is the canonical deployment shape (ADR-0022) and the first thing you should reach for. To learn the receipt API without a daemon, see the in-process appendix.

1. Start the daemon

Generate the signing key once, then run the daemon. It listens on the per-OS default socket — see the Daemon Setup guide for socket paths and running it as a service.

obsigna-daemon --init   # one-time: creates the signing key
obsigna-daemon          # start the daemon (leave it running)

2. Emit a receipt

DaemonEmitter forwards the tool-call event to the daemon, which constructs, signs, and chains the receipt. By default emit() surfaces transport failure (ADR-0025): an unreachable daemon is logged at DEBUG and raised as EmitTransportError rather than dropped silently, so start the daemon before your app. The call stays non-blocking (bounded by the dial + write timeout); construct the emitter as DaemonEmitter(best_effort=True) to opt into loss-tolerant emission (emit() swallows the error and returns None instead of raising). best_effort is a constructor argument, not an emit() argument.

from obsigna import DaemonEmitter

with DaemonEmitter() as e:  # uses AGENTRECEIPTS_SOCKET or the per-OS default
    e.emit(
        channel="my-app",
        tool_name="filesystem.file.read",
        decision="allowed",
    )

3. Verify

agent-receipts verify reads the database directly and confirms hash linkage and signatures — the daemon does not need to be running.

AGENTRECEIPTS_DB=~/.local/share/agent-receipts/receipts.db \
  agent-receipts verify \
  --public-key ~/.local/share/agent-receipts/signing.key.pub

A successful run prints the chain length and confirms the signatures are intact. If you started the daemon with a non-default chain id (AGENTRECEIPTS_CHAIN_ID / --chain-id) or overrode AGENTRECEIPTS_DB or AGENTRECEIPTS_PUBLIC_KEY, pass those same values here — otherwise verify reads the default chain at the default paths.

Action taxonomy

The standardized action taxonomy (action types and risk levels) is defined in the protocol specification. The SDK ships classification helpers: classify_tool_call maps a tool name to an action type and risk level using your mappings, load_taxonomy_config loads those mappings from a JSON config ({ "mappings": [...] }), and resolve_action_type looks up a single action type's metadata.

from obsigna import classify_tool_call, load_taxonomy_config

mappings = load_taxonomy_config("taxonomy.json")
result = classify_tool_call("read_file", mappings)
print(result.action_type, result.risk_level)

Delivering receipts to a remote collector

When the agent host and the receipt-storage host differ, sign receipts client-side and POST them to an obsigna-collector over HTTPS. This is an enterprise / multi-host shape, not the first-run path — the daemon above is what most adopters want.

obsigna.emitters (re-exported at the package top level) provides the building blocks below, all delivering signed AgentReceipt values — the receipts you sign with sign_receipt, shown in the in-process appendix:

  • HttpEmitter — POSTs each receipt to the collector with retry + backoff. Default "sync" mode waits for the collector ack (201, or 409 for a duplicate id); "fire-and-forget" schedules the POST on a background thread and never raises to the caller (call drain() before shutdown for a best-effort flush).
  • WalEmitter — wraps a synchronous inner emitter (HttpEmitter in its default "sync" mode — "fire-and-forget" returns before the POST lands, so the WAL entry is cleared prematurely and the guarantee is lost) with a write-ahead log for at-least-once delivery. Each receipt is recorded before delivery and cleared only on ack, so a failed delivery is retained and re-sent — by replay() (call once at startup to drain a backlog a previous crash left behind) or flush(deadline_ms) (bounded best-effort drain on shutdown). Use FileWal for long-lived compute (survives restart) and MemoryWal for ephemeral compute (Lambda, Cloud Run).
  • CompositeEmitter (fan-out), BufferingEmitter (batching), and InMemoryEmitter (testing) round out the set.
from obsigna import (
    AgentReceipt,
    FileWal,
    HttpEmitter,
    HttpEmitterConfig,
    WalEmitter,
)

# Construct once at startup, then drain anything a previous crash left behind.
http = HttpEmitter(HttpEmitterConfig(endpoint="https://collector.example/receipts"))
emitter = WalEmitter(inner=http, wal=FileWal("/var/lib/my-app/wal"))
emitter.replay()


def deliver(receipt: AgentReceipt) -> None:
    emitter.emit(receipt)  # at-least-once: retained in the WAL until acked

Confirm receipts landed with agent-receipts verify (above) against the collector's store.

Signing with AWS KMS (production key custody)

Client-side signing needs a private key. Loading raw PEM bytes from an env var or secrets manager keeps an extractable key in process memory — it will not pass most security reviews. The optional aws extra provides a Signer whose key never leaves AWS KMS:

pip install "obsigna[aws]"
from obsigna import (
    ActionInput,
    Chain,
    CreateReceiptInput,
    Issuer,
    Outcome,
    Principal,
    create_receipt,
)
from obsigna.aws import KMSSigner
from obsigna.receipt import public_key_to_pem, sign_receipt, verify_receipt

# keyId: a key ID, key ARN, alias name, or alias ARN. The key must be an
# ECC_NIST_EDWARDS25519 (Ed25519) key with SIGN_VERIFY usage. Credentials come
# from the ambient AWS provider chain (instance role, IRSA, env, profile).
signer = KMSSigner("arn:aws:kms:us-east-1:111122223333:key/abc…", timeout=5.0)

unsigned = create_receipt(
    CreateReceiptInput(
        issuer=Issuer(id="did:agent:my-agent"),
        principal=Principal(id="did:user:alice"),
        action=ActionInput(type="filesystem.file.read", risk_level="low"),
        outcome=Outcome(status="success"),
        chain=Chain(sequence=1, previous_receipt_hash=None, chain_id="chain_session-1"),
    )
)

# `KMSSigner` implements the `Signer` protocol, so it drops straight into
# `sign_receipt` in place of a PEM string — the raw private key never enters
# this process.
receipt = sign_receipt(unsigned, signer, "did:agent:my-agent#key-1")

# `Signer.get_public_key()` returns raw 32 bytes (RFC 8032 §5.1.5);
# `verify_receipt`/`verify_raw` take PEM, so bridge with `public_key_to_pem`.
public_key_pem = public_key_to_pem(signer.get_public_key())
assert verify_receipt(receipt, public_key_pem)

sign_receipt calls signer.sign() with the canonical (RFC 8785) bytes of the receipt, which delegates to kms:Sign with SigningAlgorithm=ED25519_SHA_512 and MessageType=RAW (pure Ed25519); the public key is fetched once via kms:GetPublicKey and cached. The key is not extractable, not present in process memory, and revocable via IAM — the production answer to the "Not for production" caveat below.

Limitations (shared with the Go SDK's aws module):

  • Use a key ARN (or key ID) for production signing, not an alias. get_public_key() caches the public key on first use, but sign() always targets the live key_id. If key_id is an alias and the alias is repointed to a different key during the signer's lifetime, signatures are produced under the new key while the cached/published public key is the old one — so receipts fail verification. This fails closed (verifiers reject; no forged receipt is accepted), but a key ARN avoids the divergence entirely. Aliases remain fine for dev and for resolving the key once at startup.
  • Receipts must canonicalize to ≤ 4096 bytes. kms:Sign with MessageType=RAW caps the message at 4096 bytes, and pure Ed25519 (ED25519_SHA_512) cannot use the DIGEST pre-hash path. A receipt whose canonical form exceeds 4 KB — typically from large parameters_disclosure envelopes or long prompt previews — cannot be signed by this adapter, even though the core SDK signs arbitrary-length bytes locally. The call fails loudly with an AWS error (the audit gap is visible, never silent); keep disclosed payloads hashed rather than inlined to stay under the limit.

Sequential receipt construction (parallel tool calls)

Hash chaining is inherently sequential: receipt N must be fully signed and its hash computed before receipt N+1 is constructed, or the previous_receipt_hash link cannot be formed. A single-threaded agent satisfies this for free, but an agent that fires parallel tool calls (across threads) would race on the shared chain head (sequence + previous_receipt_hash) and produce colliding sequence numbers or a forked chain.

ReceiptChain owns that head and serialises the whole build → sign → hash → link → deliver pipeline under a lock, so concurrent emit() calls are sequenced at the receipt layer even when the tool calls that triggered them ran in parallel. Concurrent emission is not supported as parallel chains in v1 (a future ADR may add forked sub-chains); overlapping calls from other threads block until the in-flight one completes, and the first overlap logs a one-shot warning so the misuse is visible.

from obsigna import (
    ChainEmitInput,
    InMemoryEmitter,
    ReceiptChain,
    generate_key_pair,
)
from obsigna.receipt.create import ActionInput
from obsigna.receipt.types import Issuer, Outcome, Principal

keys = generate_key_pair()

# One ReceiptChain per logical chain (e.g. per agent session). It owns the
# chain head; pass any Emitter — here the in-memory test double, in production
# an HttpEmitter or a WAL-backed emitter.
chain = ReceiptChain(
    chain_id="session-1",
    private_key=keys.private_key,
    verification_method="did:agent:my-agent#key-1",
    emitter=InMemoryEmitter(),
)

# Every emit() is sequenced: receipt N is signed and hashed before N+1 is
# built, even if your tool calls run on parallel threads.
receipt = chain.emit(
    ChainEmitInput(
        issuer=Issuer(id="did:agent:my-agent"),
        principal=Principal(id="did:user:alice"),
        action=ActionInput(type="filesystem.file.read", risk_level="low"),
        outcome=Outcome(status="success"),
    )
)

print(receipt.credentialSubject.chain.sequence)  # 1

The head advances as soon as a receipt is signed and hashed — before delivery — so a transient emitter failure does not fork or stall the chain; wrap a WalEmitter around your transport for at-least-once delivery.

Appendix: in-process signing (tutorial and testing only)

The SDK can also create and sign a receipt entirely in your process, with no daemon. This is useful for learning the receipt API and for unit tests that should not depend on a running daemon.

Not for production. This pattern keeps the signing key inside the agent process. Anyone with code execution in the agent can forge receipts. For real deployments, use the daemon-mediated path shown above (see also the Daemon Setup guide).

Create and sign a receipt

from obsigna import (
    ActionInput,
    Chain,
    CreateReceiptInput,
    Issuer,
    Outcome,
    Principal,
    create_receipt,
    generate_key_pair,
    hash_receipt,
    sign_receipt,
)

# Generate an Ed25519 key pair
keys = generate_key_pair()

# Create an unsigned receipt
unsigned = create_receipt(
    CreateReceiptInput(
        issuer=Issuer(id="did:agent:my-agent"),
        principal=Principal(id="did:user:alice"),
        action=ActionInput(
            type="filesystem.file.read",
            risk_level="low",
        ),
        outcome=Outcome(status="success"),
        chain=Chain(
            sequence=1,
            previous_receipt_hash=None,
            chain_id="chain_session-1",
        ),
    )
)

# Sign and hash
receipt = sign_receipt(unsigned, keys.private_key, "did:agent:my-agent#key-1")
receipt_hash = hash_receipt(receipt)

Verify a receipt and chain

from obsigna import verify_chain, verify_receipt

valid = verify_receipt(receipt, keys.public_key)
print(f"Signature valid: {valid}")  # True

# Verify a list of receipts (e.g. [receipt] from the example above)
result = verify_chain([receipt], keys.public_key)
print(f"Chain valid: {result.valid}")
print(f"Receipts verified: {result.length}")
if not result.valid:
    print(f"Broken at index: {result.broken_at}")

Verifying against raw wire bytes

verify_receipt and hash_receipt validate a receipt through the AgentReceipt Pydantic model, so a field a newer SDK version added — but this model doesn't know about — is silently dropped before verification, which can turn a genuinely valid signature into a false negative. verify_raw and hash_raw_receipt instead operate on the verbatim on-wire JSON, so every field present contributes to the verified/hashed payload:

from obsigna import hash_raw_receipt, verify_raw

raw = receipt.model_dump_json(by_alias=True)  # or bytes/dict from the wire
valid = verify_raw(raw, keys.public_key)
receipt_hash = hash_raw_receipt(raw)

Prefer these when you hold the verbatim bytes a collector or auditor received, and only need to trust the schema for cryptographic verification — not the installed SDK's model coverage.

What is an Agent Receipt?

A W3C Verifiable Credential signed with Ed25519, recording:

Field What it captures
Action What happened, classified by a standardized taxonomy
Principal Who authorized it (human or organization)
Issuer Which agent performed it
Outcome Success/failure, reversibility, undo method
Chain SHA-256 hash link to the previous receipt (tamper-evident)
Privacy Parameters are hashed, never stored in plaintext

API reference

Receipt creation and signing

from obsigna import (
    ActionInput,  # Action fields for CreateReceiptInput
    CreateReceiptInput,  # Input bundle for create_receipt
    create_receipt,  # Build an unsigned receipt from input fields
    generate_key_pair,  # Ed25519 key pair (PEM-encoded)
    sign_receipt,  # Sign with Ed25519Signature2020 proof
    verify_receipt,  # Verify a receipt's signature
    verify_raw,  # Verify a signature against verbatim wire JSON
)

Hashing and canonicalization

from obsigna import (
    canonicalize,  # RFC 8785 JSON canonicalization
    hash_receipt,  # Hash receipt (excluding proof) -> "sha256:<hex>"
    hash_raw_receipt,  # Hash verbatim wire JSON -> "sha256:<hex>"
    sha256,  # Hash arbitrary data -> "sha256:<hex>"
)

Chain verification

from obsigna import (
    verify_chain,  # Verify signatures, hash links, and sequence numbering
)

Types (Pydantic v2 models)

from obsigna import (
    AgentReceipt,  # Signed receipt with proof
    UnsignedAgentReceipt,  # Receipt before signing
    Action,
    ActionTarget,
    Authorization,
    Chain,
    CredentialSubject,
    Intent,
    Issuer,
    Operator,
    Outcome,
    Principal,
    Proof,
    StateChange,
)

ActionReceipt / UnsignedActionReceipt remain as deprecated aliases for backwards compatibility — prefer AgentReceipt / UnsignedAgentReceipt.

Subpackage imports

from obsigna.receipt import create_receipt, sign_receipt
from obsigna.receipt.hash import canonicalize
from obsigna.receipt.types import CONTEXT, CREDENTIAL_TYPE

TypeScript SDK compatibility

camelCase aliases are available for users coming from the TS SDK:

from obsigna import (
    createReceipt,  # = create_receipt
    generateKeyPair,  # = generate_key_pair
    signReceipt,  # = sign_receipt
    verifyReceipt,  # = verify_receipt
    verifyRaw,  # = verify_raw
    hashReceipt,  # = hash_receipt
    hashRawReceipt,  # = hash_raw_receipt
    verifyChain,  # = verify_chain
)

Cross-language compatibility

This SDK produces byte-identical output to @obsigna/sdk-ts:

  • RFC 8785 canonical JSON matches exactly
  • SHA-256 hashes are identical
  • Ed25519 signatures from either SDK verify in the other

Cross-language compatibility is verified by test vectors generated from the TypeScript SDK.

Project structure

src/obsigna/
  receipt/
    types.py       # Pydantic models for all receipt types
    create.py      # Receipt creation with auto-generated IDs
    signing.py     # Ed25519 signing and verification
    hash.py        # RFC 8785 canonicalization + SHA-256
    chain.py       # Chain verification

Development

uv sync --all-extras
uv run pytest              # run tests
uv run ruff check .        # lint
uv run ruff format .       # format
uv run pyright             # type check
Language Python 3.11+
Types Pydantic v2, pyright strict mode
Linting ruff
Testing pytest
Dependencies pydantic>=2.0, cryptography>=41.0

Ecosystem

Component Description
agent-receipts/obsigna Monorepo: spec, SDKs, daemon, MCP proxy, hook
Python SDK (this package) PyPI
TypeScript SDK npm
Go SDK go get github.com/agent-receipts/ar/sdk/go
obsigna-daemon Out-of-process signer + agent-receipts verify CLI (canonical deployment)
agent-receipts/spec Protocol specification, JSON Schemas, canonical taxonomy
ojongerius/attest MCP proxy + CLI (reference implementation)

License

Apache 2.0 — see LICENSE.

Metadata

Release files for obsigna 0.16.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for obsigna 0.16.0
File Size Uploaded
obsigna-0.16.0.tar.gz 185.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for obsigna 0.16.0
File Interpreter ABI Platform
obsigna-0.16.0-py3-none-any.whl Python 3 none any Details

Total release size: 272.2 kB

Release files / obsigna-0.16.0.tar.gz

Download URL obsigna-0.16.0.tar.gz
Size 185.7 kB
Tags Source
SHA-256 checksum
How to use checksums
8cbf2389428b07150c6df810f8e2780b1934e3c1a58ccda30614f5881b0c27d1
BLAKE2b-256 checksum
How to use checksums
482814091a9cabe18a1577b21bdd14422d62f82b4fa03bd51161681af24ec582
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.

Transparency log

Release files / obsigna-0.16.0-py3-none-any.whl

Download URL obsigna-0.16.0-py3-none-any.whl
Size 86.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
81b1bcd28d1719874342c4f6f4125274ab7b48ea31abc57489e4d8f594f84630
BLAKE2b-256 checksum
How to use checksums
017edf0cf5375eb15a006ba719d27363d4ddfe2248d56f6257807a38939eee68
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.16.0 This release

2 release files

0.15.0

2 release files

0.14.0

2 release files

0.13.0

2 release files

0.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page