SDK

There are two first-party SDKs - @agledger/sdk for TypeScript and agledger for Python. Both wrap the same REST API thinly: one client object, one resource per route group, one method per call. The method names, parameters, and return shapes track the API one-to-one. This page covers the jobs an agent does most often - initialize a client, notarize a record, run the gated lifecycle, and verify an audit export offline - with the TypeScript and Python call paired for each.

Use the SDK when you are writing an agent in TypeScript or Python and want typed methods, retries, and an offline verifier without hand-rolling HTTP. For request and response field detail, the API reference is canonical - this page does not restate it.

The SDK is a thin wrapper

The SDK does not hold a model of the lifecycle, decide what to do next, or cache state. Each method is one HTTP request (retried on transient failures), and the response is the API's response unchanged. The one thing it adds on the wire is a signature over each request body when you authenticate with an OIDC certificate, covered below. That is deliberate. The API teaches the caller what to do next inside each response: a created record carries nextActions (the exact transition names valid right now) and completionHint (the evidence fields the next step expects); an error carries detail (the human-readable text), error (the machine-readable code), retryable, suggestion, and - on a state rejection - recoveryHint and refreshUrl naming the corrective call. The guidance lives in the response, so the SDK stays small and never drifts behind the API.

Two consequences follow. First, read the response, not the SDK, to decide the next call - record.nextActions over a hard-coded transition order. Second, when a route is newer than your SDK version, you do not have to wait for a typed method: every client exposes a request escape hatch that forwards a method, path, and body to the API verbatim.

// TypeScript: reach any route the SDK does not yet model
const result = await client.request('POST', '/v1/custom/endpoint', { foo: 'bar' })
# Python: same escape hatch
result = client.request("POST", "/v1/custom/endpoint", json={"foo": "bar"})

Install and initialize

npm install @agledger/sdk@3.0.0
pip install 'agledger==3.0.0'

The 3.x SDKs target API 2.0 only; a Server still on 1.x needs the 1.12 SDKs.

AGLedger is self-hosted, so the client needs your Server's base URL and an API key (see Authentication for minting one). There is no default URL: omitting it raises ConfigurationError. The TypeScript client takes both in its constructor; the Python client reads AGLEDGER_API_KEY from the environment when api_key is omitted and supports a context manager for connection cleanup.

import { AgledgerClient } from '@agledger/sdk'

const client = new AgledgerClient({
  apiKey: process.env.AGLEDGER_API_KEY!,
  baseUrl: process.env.AGLEDGER_EXTERNAL_URL!, // your Server's URL
  // optional: maxRetries (default 3), timeout (ms, default 30_000)
})
import os
from agledger import AgledgerClient

with AgledgerClient(
    api_key=os.environ["AGLEDGER_API_KEY"],
    base_url=os.environ["AGLEDGER_EXTERNAL_URL"],  # your Server's URL
    # optional: max_retries (default 3), timeout (seconds)
) as client:
    ...

An agent whose identity comes from your own IdP can skip the stored key entirely. oidcCertCredential exchanges a fresh OIDC token for a short-lived, Server-signed certificate, refreshes it at half its lifetime, and signs every request body with the key the certificate is bound to. The callback must return a new token each time, because the Server accepts a given token id only once. Pass bearerToken / bearer_token in place of the API key; the client takes exactly one of the two:

import { AgledgerClient, oidcCertCredential } from '@agledger/sdk'

const client = new AgledgerClient({
  baseUrl: process.env.AGLEDGER_EXTERNAL_URL!,
  bearerToken: oidcCertCredential({
    getOidcToken: async () => mintTokenFromYourIdp(),
  }),
})
from agledger import AgledgerClient, oidc_cert_credential

with AgledgerClient(
    base_url=os.environ["AGLEDGER_EXTERNAL_URL"],
    bearer_token=oidc_cert_credential(get_oidc_token=mint_token_from_your_idp),
) as client:
    ...

In Python the certificate path needs cryptography, which the oidc extra installs (pip install 'agledger[oidc]==3.0.0'); AsyncAgledgerClient takes async_oidc_cert_credential. The token decides which agent the certificate binds to. Both credentials take an optional agentId / agent_id, which is an assertion rather than a choice: a value that differs from the token's binding, or any value on a token that binds no agent, is refused with 403 CERT_AGENT_BINDING_MISMATCH. A refused exchange raises OidcExchangeError in TypeScript and OidcCertExchangeError in Python, carrying the Server's recoveryHint.

bearerToken / bearer_token also takes a plain string, or a function called before every request, which is the shape for an admin OIDC token handed to the Server directly on /v1/admin/*. Either way the Server side of this is the OIDC-cert path, which has to have a registered trusted issuer before an exchange can succeed.

Confirm the credential resolves before wiring it into an agent. getMe / get_me echoes the resolved identity, role, and scopes - the same data GET /v1/auth/me returns.

const me = await client.auth.getMe()
console.log(me.role, me.scopes)
me = client.auth.get_me()
print(me.role, me.scopes)

Python also ships AsyncAgledgerClient with the same method surface under await:

import asyncio
import os

from agledger import AsyncAgledgerClient


async def main() -> None:
    async with AsyncAgledgerClient(base_url=os.environ["AGLEDGER_EXTERNAL_URL"]) as client:  # reads AGLEDGER_API_KEY
        record = await client.records.create(
            type="notarize-generic-v1",
            criteria={"summary": "async hello"},
        )
        print(record.id, record.status)


asyncio.run(main())

Notarize a record (the on-ramp)

The common case is a single agent notarizing what it is about to do. For a notarize-only Type - one whose schema declares no completion - records.create is the whole interaction: the record is signed into the chain and terminalizes at RECORDED in that one call. An agent key resolves its own org and principal from the key, so you pass only the Type and the criteria. A fresh org is seeded with notarize-generic-v1, whose schema requires summary and accepts any other fields.

const record = await client.records.create({
  type: 'notarize-generic-v1',
  criteria: {
    summary: 'rotate prod DB credentials',
    requested_by: 'ops-oncall',
  },
})

console.log(record.id, record.status) // status: 'RECORDED'
record = client.records.create(
    type="notarize-generic-v1",
    criteria={
        "summary": "rotate prod DB credentials",
        "requested_by": "ops-oncall",
    },
)

print(record.id, record.status)  # status: 'RECORDED'

The returned record carries a signedStatement block - the chain position, leaf hash, previous hash, and the signing key id - so a notarize-only caller can confirm the chain head without a follow-up call.

console.log(record.signedStatement?.chainPosition, record.signedStatement?.leafHash)
print(record.signed_statement.chain_position, record.signed_statement.leaf_hash)

Run the gated lifecycle

A Type that declares a completion schema runs the gated lifecycle: the principal creates the record, the performer submits a Completion as evidence once the record is ACTIVE, and a verdict (accept or reject) settles it. The SDK exposes one method per step. The seeded principal-gate-generic-v1 holds for the principal's verdict. Two clients below, one per party, each with its own agent key:

// 1. Principal creates the record; autoActivate takes it straight to ACTIVE
const record = await principal.records.create({
  type: 'principal-gate-generic-v1',
  performerAgentId: '<performer-agent-id>',
  autoActivate: true,
  criteria: { summary: 'Nightly warehouse export', dealRef: 'ETL-2026-03-10' },
})

// 2. Performer submits a Completion (evidence of what was done)
const completion = await performer.completions.submit(record.id, {
  evidence: { summary: 'Exported 487,231 rows', evidenceUrl: 'https://etl.example.com/runs/2026-03-10' },
})

// 3. Principal renders the verdict
await principal.records.submitVerdict(record.id, { completionId: completion.id, verdict: 'accept' })
# 1. Principal creates the record; auto_activate takes it straight to ACTIVE
record = principal.records.create(
    type="principal-gate-generic-v1",
    performer_agent_id="<performer-agent-id>",
    auto_activate=True,
    criteria={"summary": "Nightly warehouse export", "dealRef": "ETL-2026-03-10"},
)

# 2. Performer submits a Completion (evidence of what was done)
completion = performer.completions.submit(
    record.id,
    evidence={"summary": "Exported 487,231 rows", "evidenceUrl": "https://etl.example.com/runs/2026-03-10"},
)

# 3. Principal renders the verdict
principal.records.submit_verdict(record.id, completion_id=completion.id, verdict="accept")

autoActivate skips the performer's consent and records that on the chain. When the consent is what you need, leave it off and run the handshake: the principal proposes (records.transition(id, 'propose')), the performer accepts (records.accept), and the principal activates. A Type with no principal gate settles in auto mode instead: the rules engine evaluates the completion and the record reaches FULFILLED or FAILED with no verdict call. The principal is always the real judge in principal mode - the SDK only carries the call.

A record moves through the chain by named transitions, not arbitrary status assignment. Read record.nextActions for the exact calls valid right now rather than hard-coding an order. getValidTransitions / get_valid_transitions makes no API call: it returns the record's own validTransitions as the Server served them, or, on a row read without them, the statuses any record at its current status can reach.

const next = client.records.getValidTransitions(record) // e.g. ['ACTIVE', 'CANCELLED', 'EXPIRED', 'PROPOSED'] from CREATED
next_statuses = client.records.get_valid_transitions(record)

Act on behalf of someone

When the work is done for a person or another party, pass the RFC 8693 delegation token your IdP issued (it must carry an act claim naming the agent). It is sent as the AGLedger-On-Behalf-Of header, which record create, transition and verdict, completion submit, and A2A accept:

await client.records.create(
  { type: 'notarize-generic-v1', criteria: { summary: 'expense report for alice' } },
  { onBehalfOf: delegationToken },
)
client.records.create(
    type="notarize-generic-v1",
    criteria={"summary": "expense report for alice"},
    on_behalf_of=delegation_token,
)

The Server validates the token against a trusted issuer and seals the delegation into the chain entry, bound under an OIDC certificate whose subject is the token's act and unbound on an API key. The Server side is on Authentication.

Read the delegation chain

When work is subcontracted, delegate creates a child record linked to its parent, and the chain methods read the linked graph back. The child's principal is the parent's performer, so the performer's client makes this call:

const child = await performer.records.delegate(record.id, {
  type: 'principal-gate-generic-v1',
  performerAgentId: '<subcontractor-agent-id>',
  criteria: { summary: 'Transform stage' },
})

const chain = await performer.records.getChain(record.id) // RecordRow[], parent through children
child = performer.records.delegate(
    record.id,
    type="principal-gate-generic-v1",
    performer_agent_id="<subcontractor-agent-id>",
    criteria={"summary": "Transform stage"},
)

chain = performer.records.get_chain(record.id)  # list[RecordRow], parent through children

Export and verify a chain offline

The audit export is a record's full hash-chained, signed trail. Both SDKs ship a standalone verifier that re-walks the chain and checks every signature (Ed25519 or ES256) with no dependency on the Server - the load-bearing property of an offline auditor. Pull the export through the client, then verify it.

A key the Server publishes, embedded in the export or served by GET /v1/verification-keys, comes from the Server's database, so on its own it proves only that the chain agrees with that database. What makes a key trusted is a pin taken out of band: the SPKI digest (sha256:<hex>) of a vault signing key, which install.sh prints as the "Vault signing key pin" and the operator hands to whoever verifies. Pass it as trustAnchors / trust_anchors, and the verifier walks the signed key statements the export carries from that pin, so an entry signed by a key the walk does not reach fails CHAIN_SIGNING_KEY_UNANCHORED.

import { verifyExport } from '@agledger/sdk/verify'

const exportData = await client.records.getAuditExport(record.id)
const result = verifyExport(exportData, { trustAnchors: [process.env.AGLEDGER_VAULT_KEY_PIN!] })

if (result.verdict === 'failed') {
  console.error(`Broken at position ${result.brokenAt?.position}: ${result.brokenAt?.code}`)
} else if (result.verdict === 'unanchored') {
  console.warn(result.keyTrust.detail)
}
import os
from agledger.verify import verify_export

export_data = client.records.get_audit_export(record.id)
result = verify_export(export_data, trust_anchors=[os.environ["AGLEDGER_VAULT_KEY_PIN"]])

if result.verdict == "failed":
    print(f"Broken at position {result.broken_at.position}: {result.broken_at.code}")
elif result.verdict == "unanchored":
    print(result.key_trust.detail)

Read verdict, not valid alone. It is trusted when the chain verifies and its signatures verify under keys the pin reaches, unanchored when nothing failed but nothing pinned backs the result, and failed otherwise. A run without trustAnchors is at best unanchored, with keyTrust.status / key_trust.status set to no_anchor: a key written into the Server's database alone would pass it. The export's own exportMetadata.anchoredFrom names the Server's key and is reported against your pins (keyTrust.anchoredFromPinned / key_trust.anchored_from_pinned), but it is the export's word and never counts as a pin.

The result also carries a per-entry breakdown and a signatureCoverage / signature_coverage discriminator - a contiguous, unbroken chain does not imply every entry was cryptographically signed, so the result reports the two separately. A failure names the first broken position and a machine-readable code from a SCREAMING_SNAKE taxonomy (CHAIN_LINK_BROKEN, CHAIN_HASH_MISMATCH, CHAIN_SIGNATURE_INVALID, CHAIN_SIGNING_KEY_UNANCHORED, and so on). An entry whose actorId, actorRole or actorOwnerId disagrees with the signed actor claim fails CHAIN_ACTOR_ATTRIBUTION_MISMATCH, so an export re-attributed to another actor does not verify. A key statement that does not hold (KEY_STATEMENT_INVALID, KEY_CLOSURE_INVALID, CHAIN_KEY_WINDOW_DRIFT) is listed in keyTrust.findings / key_trust.findings and fails the result at position 0.

Both verifiers raise TypeError on an option they do not read, so a misspelt option, or a 1.x name such as requireOutOfBandKeys, cannot switch a check off without a word. Where the operator has distrusted a leaked key on the Server (VAULT_DISTRUSTED_KEYS), pass the same entries as distrustedKeys / distrusted_keys beside the pins.

Air-gapped verification

Neither verifier calls home. Persist the export to disk on the connected side, carry it across the air gap with the pin, and verify it where there is no network. Both verifiers read the export from a plain object, so nothing in the verify path depends on the Server, this website, npm, or PyPI once the SDK and its verify dependencies are installed.

import { readFileSync } from 'node:fs'
import { verifyExport } from '@agledger/sdk/verify'

const exportData = JSON.parse(readFileSync('audit-export.json', 'utf8'))
const result = verifyExport(exportData, { trustAnchors: [process.env.AGLEDGER_VAULT_KEY_PIN!] })
import json
import os
from agledger.verify import verify_export

with open("audit-export.json") as f:
    export_data = json.load(f)
result = verify_export(export_data, trust_anchors=[os.environ["AGLEDGER_VAULT_KEY_PIN"]])

The Python verifier needs cbor2 and cryptography for COSE_Sign1 decoding and signature verification - install them with the verify extra:

pip install 'agledger[verify]==3.0.0'

Supplied keys and key policy

The verifier checks signatures against the keys embedded in the export unless you supply keys. publicKeys / public_keys takes the verificationKeys.list() / verification_keys.list() result as it comes (the key statements it lists are walked with the export's), or a compact {keyId: base64 SPKI DER} map, and a supplied key overrides an embedded one under the same id. Supplying keys says where a key came from, not that it is trusted: a key fetched from the Server comes from its database too, and the pin is what establishes trust. Two policy controls narrow what passes, each failing CHAIN_KEY_POLICY_VIOLATION: requireKeyId / require_key_id refuses an entry signed by any other key, and requireSuppliedKeys / require_supplied_keys refuses an entry whose only key travelled with the export.

const keys = await client.verificationKeys.list()

const result = verifyExport(exportData, {
  trustAnchors: [process.env.AGLEDGER_VAULT_KEY_PIN!],
  publicKeys: keys,
  requireKeyId: 'key-2026-q2',
  requireSuppliedKeys: true,
})
keys = client.verification_keys.list()

result = verify_export(
    export_data,
    trust_anchors=[os.environ["AGLEDGER_VAULT_KEY_PIN"]],
    public_keys=keys,
    require_key_id="key-2026-q2",
    require_supplied_keys=True,
)

The same controls are on both command-line verifiers, so a pipeline that does not import a client library gets the identical posture. --keys takes a saved GET /v1/verification-keys response or the compact map:

npx -y @agledger/verify@2.0.0 audit-export.json --trust-anchor "$AGLEDGER_VAULT_KEY_PIN" \
  --keys verification-keys.json --require-key-id key-2026-q2 --require-supplied-keys
agledger-verify audit-export.json --trust-anchor "$AGLEDGER_VAULT_KEY_PIN" \
  --keys verification-keys.json --require-key-id key-2026-q2 --require-supplied-keys

--trust-anchor (repeatable, one per pin) and --distrusted-key apply to an /audit-export file and a vault dump directory alike. --keys, --require-key-id and --require-supplied-keys apply to an export file only: a dump directory carries its own signed key history. A clean run without --trust-anchor prints [VERIFIED, NOT ANCHORED] and still exits 0, so a gate that needs a trusted verdict passes the pin or reads verdict from --report-format json.

Agent signatures. A record written under an OIDC certificate carries the agent's own signature on each chain entry, sealed by the Server. The export holds each signature and the certificate thumbprint but not the key, so the verifier counts those signatures and checks none of them until you supply the certificates' public keys. Keep the key each credential exchanged with, and pass it back at verification:

const credential = oidcCertCredential({ getOidcToken: async () => mintTokenFromYourIdp() })
// ...after the credential has been used, archive its public key beside your exports
const agentKeys = [credential.publicKeyJwk]

const result = verifyExport(exportData, { trustAnchors: [process.env.AGLEDGER_VAULT_KEY_PIN!], agentKeys })
result.agentSignatures // { present, verified }
credential = oidc_cert_credential(get_oidc_token=mint_token_from_your_idp)
# ...after the credential has been used, archive its public key beside your exports
agent_keys = [credential.public_key_jwk]

result = verify_export(export_data, trust_anchors=[os.environ["AGLEDGER_VAULT_KEY_PIN"]], agent_keys=agent_keys)
agledger-verify audit-export.json --trust-anchor "$AGLEDGER_VAULT_KEY_PIN" --agent-keys agent-keys.json

--agent-keys takes a JWK, a list of JWKs, or a {"keys": [...]} set, and applies to a dump directory as well as an export file. A signature that names one of the supplied keys by thumbprint and does not verify fails CHAIN_AGENT_SIGNATURE_INVALID. The CLI and the MCP server discard their certificate key on exit, so signatures they sealed can be re-checked only from a vault dump that is not scoped to one org: each certificate's issuance entry signs its key, and the dump verifier uses those keys without --agent-keys.

Handle errors

API errors raise typed exceptions carrying the API's own guidance fields. The exception's text is the body's detail (err.message in TypeScript, err.detail or str(err) in Python), and err.code is the body's machine-readable error. Branch on the type, and on a state rejection read recoveryHint / refreshUrl to find the corrective call rather than blindly retrying.

import { UnprocessableError, RateLimitError } from '@agledger/sdk'

try {
  await client.records.transition(record.id, 'activate')
} catch (err) {
  if (err instanceof UnprocessableError) {
    console.error(err.recoveryHint, err.refreshUrl) // wrong state: re-fetch and follow the named action
  } else if (err instanceof RateLimitError) {
    console.error(`retry after ${err.retryAfter}ms`)
  } else {
    throw err
  }
}
from agledger import UnprocessableError, RateLimitError

try:
    client.records.transition(record.id, "activate")
except UnprocessableError as err:
    print(err.recovery_hint, err.refresh_url)  # wrong state: re-fetch and follow the named action
except RateLimitError as err:
    print(f"retry after {err.retry_after}s")

retryAfter is in milliseconds in TypeScript; retry_after is in seconds in Python.

Both clients retry 429, 5xx and network failures with exponential backoff up to maxRetries / max_retries (default 3), waiting out a Retry-After in full, before raising, so the exception you catch is the final outcome, not a transient blip.

Every write carries an auto-generated Idempotency-Key, reused across the SDK's own retries. When the retries run out on a timeout or a dropped connection, the write may still have reached the Server, so the exception carries the key it sent (ConnectionError / TimeoutError .idempotencyKey in TypeScript, APIConnectionError / APITimeoutError .idempotency_key in Python). Re-send the same call with that key - the idempotencyKey request option in TypeScript, client.request(..., headers={"Idempotency-Key": key}) in Python - and the Server answers with the original result instead of writing twice. Reusing a key with a different body is a 400 (ValidationError in TypeScript, BadRequestError in Python), and a key whose first request is still in flight is a 409 ConflictError.

Webhooks

When an agent both notarizes and receives the event stream, the SDK ships webhook verifiers under a dedicated entry point (@agledger/sdk/webhooks, agledger.webhooks) - HMAC for receiver-only integrity and Ed25519 (RFC 9421) for non-repudiable Settlement Signal webhooks. The verifier methods (verifySignature, verifyRfc9421 in TypeScript; verify_signature, verify_rfc9421 in Python) are documented end to end on the Webhooks guide; that page owns webhook mechanics.