docs / agent-id / examplesgithub.com/Fareground/agent-id ↗

Examples

Runnable recipes for the common identity flows, mirroring the repo's README and examples/ directory (python/ and js/: card issue/verify, proof of possession, key rotation).

Zero config — a persistent identity in one line

Created on first run, reloaded ever after — same address every time. A passphrase seals the file at rest, and the key file is byte-compatible across Python and TypeScript:

from fg_agent_id import AgentIdentity, OwnerIdentity

me = AgentIdentity.load_or_create("agent.key")
print(me.address)                             # amp:key:<base58>, stable across runs

sealed = AgentIdentity.load_or_create("sealed.key", "s3cret")   # encrypted at rest
owner = OwnerIdentity.load_or_create("owner.key")               # owners persist too

Loading a sealed file without its passphrase (or a plain file with one) raises a typed KeyFileError — the library refuses to guess.

Owner authorizes an agent, publishes a card, verifies scopes

from fg_agent_id import AgentCard, OwnerIdentity

owner = OwnerIdentity.generate("acme-corp")
agent = owner.create_agent("acme-buyer", scopes={"converse", "negotiate"})

card = agent.card(endpoints={"http": "https://buyer.example/inbox"})
card.verify()                       # self-verifying: no registry
print(agent.address)                # amp:key:<base58>
print(card.did)                     # did:amp:<base58>

wire = card.to_json()               # JSON-ready dict (json.dumps for transport)
AgentCard.from_json(wire).verify()  # a peer re-verifies from the wire form

scopes = agent.delegation_chain.verify(agent.address)
assert scopes == frozenset({"converse", "negotiate"})

A malformed wire dict raises the typed CardError from from_json rather than leaking a raw TypeError.

Scope intersection down a chain

Chains only narrow. The principal grants three scopes, the operator passes on two — the agent ends up with two:

from fg_agent_id import Delegation, DelegationChain

chain = DelegationChain(links=(
    Delegation.grant(principal.keys, principal.address, operator.address,
                     {"converse", "negotiate", "spend"}, ttl_seconds=3600),
    Delegation.grant(operator.keys, operator.address, agent.address,
                     {"converse", "negotiate"}, ttl_seconds=3600),
))
assert chain.verify(agent.address) == frozenset({"converse", "negotiate"})

Audience-bound proof of possession

The verifier issues the challenge, the agent signs it, the verifier consumes it once and checks against its own audience — never the one echoed in the response:

from fg_agent_id import ChallengeStore

store = ChallengeStore()                          # verifier side
challenge = store.issue(audience="https://myapp.example")

response = challenge.respond(agent.keys, agent.address)   # agent side

issued = store.consume(response.challenge_id)     # single use — None the second time
address = response.verify(issued, audience="https://myapp.example")

For multiple worker processes, swap in the shared atomic store:

from fg_agent_id import RedisChallengeStore
store = RedisChallengeStore.from_url("redis://localhost:6379")

Key rotation with a stable name

The identity name is the inception address; rotating changes the key in force, not the name. A registry that learns the chain resolves the name to the current key:

from fg_agent_id import RotatingIdentity, RotationRegistry

identity = RotatingIdentity.create()
rotated = identity.rotate()

assert rotated.identity == identity.identity   # stable name
assert rotated.address != identity.address     # key in force changed

registry = RotationRegistry()
registry.learn(rotated.chain)
assert registry.resolve(identity.identity) == rotated.address

If the registry ever sees two valid rotations at the same sequence (a fork — proven key leak), resolve() fails closed and duplicity_evidence() returns the conflicting records.

Encrypted keys at rest

load_or_create seals automatically when given a passphrase; the raw building blocks are also exported (scrypt + ChaCha20-Poly1305, byte-compatible across both languages):

sealed = agent.keys.to_encrypted_bytes(passphrase)     # bytes safe to write to disk

from fg_agent_id import KeyPair
restored = KeyPair.from_encrypted_bytes(sealed, passphrase)

The header is authenticated — a tampered version byte fails to open — and passphrases are NFC-normalized, so the same passphrase typed on macOS and Linux decrypts the same file.

Revocation, distributed by feed

The owner revokes a grant; the feed carries it; a consumer syncs its registry. Every entry re-verifies on admit, gaps abort the delta, and the registry is marked synced only on full success:

from fg_agent_id import RevocationFeed, RevocationRegistry, sync_registry

revocation = owner.revoke(delegation)          # only the issuer can

feed = RevocationFeed()                        # publisher side
feed.append(revocation)

registry = RevocationRegistry()                # consumer side
cursor = sync_registry(registry, feed)

assert registry.is_revoked(delegation)
registry.require_fresh(max_age_seconds=3600)   # fail closed on stale data

Revoked delegations make chain.verify(...) reject any chain that contains them — pass revoked=registry.digests and revoked_keys=registry.revoked_keys.

Spend scope verification

Grant a payment cap in a scope string, compose caps down the chain (min-cap intersection), and check a concrete payment against them:

from decimal import Decimal
from fg_agent_id import SpendAuthority

grant = owner.grant(agent.address, scopes={"pay:usd:tx<=50:total<=200"})
chain = DelegationChain(links=(grant,))

chain.verify(agent.address)                          # 1. verify the chain first
scope = SpendAuthority.verify(chain, "usd",          # 2. then the authority math
                              amount="25.00",
                              spent_so_far=Decimal("150.00"))
# raises SpendScopeError if amount > tx cap, or spent_so_far + amount > total cap

SpendAuthority does authority math only — you supply the ledger of what's already been spent; settlement is out of scope. A link with no spend scope for the asset voids authority entirely.

TypeScript

Everything above has a byte-identical TS counterpart, facade included; crypto is async, constructors take options objects, and the public-key accessor is keys.public_:

import { AgentIdentity, OwnerIdentity, AgentCard, ChallengeStore } from "@fareground/agent-id";

// One-liner persistence — reads/writes the same key files as Python
const me = await AgentIdentity.loadOrCreate("agent.key");

// Same facade as Python: an owner mints an authorized agent
const owner = await OwnerIdentity.generate("acme-corp");
const agent = await owner.createAgent("acme-buyer", ["converse", "negotiate"]);

// Signed, self-certifying card
const card = await agent.card({ endpoints: { http: "https://buyer.example/inbox" } });
await AgentCard.fromJSON(card.toJSON()).verify(); // verifies from plain JSON, no registry

// Delegation chain, scopes = intersection of all links
const scopes = await agent.delegationChain.verify(agent.address);

// Proof of possession (audience-bound, single-use)
const store = new ChallengeStore();
const challenge = store.issue("https://verifier.example");
const response = await challenge.respond(agent.keys, agent.address);
const issued = store.consume(response.challengeId);
if (!issued) throw new Error("challenge already used or expired");
await response.verify(issued, "https://verifier.example");

Note card.toJSON() returns a plain object, not a string — run it through JSON.stringify for transport; AgentCard.fromJSON accepts the parsed object back. The primitives (KeyPair, Delegation, …) remain exported for composing without the facade. Registry snapshots, the revocation feed, and the Redis challenge store are Python-only; nothing on the wire depends on any of them.