CLI

The AGLedger CLI is a thin cover over the API. Every route the API exposes is reachable through one pass-through command, agledger api <METHOD> <path>, so the CLI never drifts behind the API and the API reference is also the CLI reference. A small set of CLI-local commands handle the things a pass-through cannot: storing credentials, cold-start discovery, and verifying an audit export offline.

Use this guide when you want to notarize from a shell, script a chain in CI, or hand an agent a terminal instead of an SDK. For request and response shapes, follow the API reference - this page covers which command maps to which job and how the offline path works.

The CLI is published as @agledger/cli v2.0.0. It targets an API 2.0 Server and requires Node 24 or later.

Install

npm install -g @agledger/cli
agledger --version

On Linux hosts where the global npm prefix is root-owned (the NodeSource default), npm install -g fails with EACCES - run it with sudo, or point npm at a user-writable prefix first (npm config set prefix ~/.npm-global).

Air-gapped install. The CLI is a single npm package with two runtime dependencies (@oclif/core, @agledger/verify-core) and no network calls of its own beyond the API requests you make. On a connected machine, pack it and carry the tarball across:

npm pack @agledger/cli
# produces agledger-cli-<version>.tgz; copy it to the target host, then:
npm install -g ./agledger-cli-<version>.tgz

The agledger verify command runs entirely offline once installed and never contacts a registry, our website, or any AGLedger Server.

Point the CLI at your Server

Every command resolves its Server URL and key from, in order: explicit --api-url / --api-key flags, the AGLEDGER_API_URL / AGLEDGER_API_KEY environment variables, or a stored login profile. For a single shell session, environment variables are the simplest:

export AGLEDGER_API_URL=https://agledger.example.com
export AGLEDGER_API_KEY=agl_agt_...

There is no default Server - point the CLI at your own instance. The CLI talks only to the URL you give it. A URL that is not an absolute http or https URL exits 2 with CONFIG_ERROR, naming where it came from, before any request is made.

Authenticate

agledger login verifies a key against your Server (it calls GET /v1/auth/me) and, on success, stores it in ~/.agledger/config.json with 0600 permissions. Use named profiles to hold keys for more than one Server or environment on the same machine:

agledger login --api-key agl_agt_... --api-url https://agledger.example.com --profile prod

Switch and inspect profiles with config:

agledger config list             # all stored profiles, marking the active one
agledger config use prod         # set prod as the active profile
agledger config get              # show the active profile (URL only, never the key)
agledger config path             # print the config file path

Check login state without failing - auth returns authenticated: false and exit code 0 when no key is configured, which is convenient for scripts that gate on it:

agledger auth

Remove a stored credential with agledger logout, which removes the active profile, agledger logout --profile prod for a named one, or agledger logout --all to clear every profile. After the active profile is removed no profile is active, so the next call needs a credential from a flag, the environment, or agledger config use. A logout that finds nothing to remove exits 2 with MISSING_INPUT.

An agent whose identity comes from your own IdP needs no stored key. Set AGLEDGER_OIDC_TOKEN_CMD to a command that prints a fresh IdP token, leave AGLEDGER_API_KEY unset, and the CLI exchanges the token at POST /v1/auth/oidc/cert for a short-lived certificate, re-exchanging once half its lifetime has passed and once more if the Server answers 401. The command runs once per exchange and must return a new token each time, because the Server exchanges each token id once. The MCP server reads the same variables.

export AGLEDGER_OIDC_TOKEN_CMD='curl -s -X POST https://idp.example.com/realms/prod/protocol/openid-connect/token -d grant_type=client_credentials -d client_id=claims-adjuster -d client_secret=... | jq -r .access_token'
agledger auth --json      # "credential": "oidc-cert", with the agent the certificate is bound to

In a real fleet the command is whatever your platform already uses to hand a workload its identity token, such as gcloud auth print-identity-token or kubectl create token.

VariableWhat it holds
AGLEDGER_OIDC_TOKEN_CMDA shell command whose stdout is an OIDC JWT, run on every exchange
AGLEDGER_OIDC_TOKEN_FILEA file holding an OIDC JWT, read on every exchange, such as a projected service-account token Kubernetes rotates on disk. One file token serves one run until the file rotates; for repeated runs prefer a command
AGLEDGER_OIDC_AGENT_IDOptional. An assertion of the agent the token binds to, not a choice of agent: the Server binds the certificate to the agent the token names, and an id that names a different agent, or any id on a token bound to none, is refused 403 CERT_AGENT_BINDING_MISMATCH. Bind the agent instead with PATCH /v1/agents/{id} (oidcIss, oidcSub) or claimMapping.agent_id on the trusted issuer

An API key always wins: the order is --api-key or AGLEDGER_API_KEY, then the token command, then the token file, then the stored profile. To keep a token source in a profile instead of the environment, agledger login --oidc --oidc-token-cmd '<command>' --profile work (or --oidc-token-file <path>) stores the source, never a token. The certificate and its Ed25519 key live in memory only, and every request with a body is signed with that key (X-Agent-Signature), so the chain entry records the agent's own signature beside the Server's.

A token command that fails exits 3 with OIDC_TOKEN_SOURCE_FAILED, naming the variable; a refused exchange fails with OIDC_EXCHANGE_FAILED and forwards the Server's recoveryHint. --verbose reports which credential a command used and each exchange on stderr, and never prints a key, token or certificate.

Keys are minted and rotated on the Server, not by the CLI. For how to obtain an agl_ key or an OIDC-bound certificate, see Authentication.

Working on behalf of someone

When the work is done for a person or another party, give the CLI the RFC 8693 delegation token your IdP's token exchange issued (it must carry an act claim naming the agent). The CLI sends it as AGLedger-On-Behalf-Of on every POST, beside its own credential:

export AGLEDGER_ON_BEHALF_OF_CMD='your-idp-token-exchange --subject alice@example.com --actor claims-adjuster'
agledger api POST /v1/records -F type=notarize-generic-v1 -F criteria.summary='expense report for alice'

AGLEDGER_ON_BEHALF_OF_FILE reads the token from a file instead; the command wins when both are set. The token is reused until shortly before its exp and never appears in output. The Server seals the delegation into the chain entry as bound when the CLI authenticates with an OIDC certificate whose subject is the token's act, and unbound on an API key. The Server side, including the trusted issuer the token needs, is on Authentication.

Confirm connectivity

agledger discover is the cold-start call. It checks Server health and your resolved identity, and returns the quickstart workflow. Partial results come back even if one call fails, so you can tell what is reachable:

agledger discover

Notarize a record

Creating a record is POST /v1/records through the pass-through command. For a notarize-only contract type the record terminalizes in this one signed call - this is the on-ramp. A fresh org is seeded with notarize-generic-v1, whose schema requires summary and accepts any other fields:

agledger api POST /v1/records --data '{
  "type": "notarize-generic-v1",
  "criteria": { "summary": "invoice INV-4815 processed", "invoiceId": "INV-4815", "amount": 2400 }
}'

You can build the body from typed -F fields instead of a JSON string. Dots nest, [] appends to an array, and values are typed (true / false / null, numbers, or bracketed JSON):

agledger api POST /v1/records \
  -F type=notarize-generic-v1 \
  -F criteria.summary='invoice INV-4815 processed' \
  -F criteria.amount=2400

-F types its values, which is wrong for an identifier that happens to be all digits. The Server does not coerce the fields of a JSON body, so a field declared string refuses a number and -F externalTaskId=4821 is rejected. Use -f / --raw-field to send a value verbatim as a string:

agledger api POST /v1/records \
  -F type=notarize-generic-v1 \
  -F criteria.summary='invoice 4815 processed' \
  -f externalTaskId=4821

Path syntax is identical and the two forms parse together, so -F a.b=1 -f a.c=2 builds one tree. publisher, platformRef, projectRef, externalTaskId and correlationId are declared strings carrying identifiers minted by other systems, which makes them the fields most likely to arrive all-digit.

For larger payloads, read the body from a file or stdin:

agledger api POST /v1/records --input record.json
cat record.json | agledger api POST /v1/records --input -

Body sources compose in this precedence (low to high): --data, then --input, then -F and -f, then --query. Later sources override overlapping keys, so you can keep a base file and override one field on the command line.

To see exactly what would be sent without calling the Server, add --dry-run:

agledger api POST /v1/records --input record.json --dry-run

Retry a write without recording it twice

Every POST the CLI sends carries an Idempotency-Key, generated per invocation. A fresh invocation therefore mints a fresh key and is a new write. When you are retrying a call that may already have reached the Server, pass the first attempt's key so the Server replays the original result instead of creating a second record:

agledger api POST /v1/records --input record.json \
  --idempotency-key 4f1c8ad2-93e0-4a77-b8d1-6c2e5f0a91b4

The key binds to method, route and body, so a retry that changes the body is rejected rather than replaying the earlier response against a different payload. Only POST is affected: every route that opts into idempotency is a POST, and the header is ignored elsewhere. --dry-run names the key it would send, and --verbose reports the key every POST goes out under.

A POST that times out, or whose connection drops after the request went out, may already have been applied. Its TIMEOUT or NETWORK_ERROR error carries the idempotencyKey it sent, and the suggestion is to rerun with --idempotency-key <that key>; a plain rerun mints a fresh key and can record the work twice. The wait for each response is AGLEDGER_TIMEOUT seconds (default 30).

Submit a completion (gated contract types)

A contract type that declares a completion schema runs the gated lifecycle: create, accept, completion, verdict. The seeded principal-gate-generic-v1 is one. A completion is accepted only on an ACTIVE record, and autoActivate gets it there on create:

agledger api POST /v1/records \
  -F type=principal-gate-generic-v1 \
  -F criteria.summary='deliver 500x copper wire' \
  -F autoActivate=true

The performer submits evidence as a Completion against the record, using the id the create returned:

agledger api POST /v1/records/<record-id>/completions \
  --data '{"evidence":{"summary":"delivered 500x copper wire","evidenceUrl":"https://orders.example.com/CW-500"}}'

The principal then renders the verdict with POST /v1/records/<record-id>/verdict (body {"completionId":"<id>","verdict":"accept"}). The CLI carries these calls because it is a pass-through; the lifecycle, fields, and states are owned by the API reference.

List and stream records

For GET and DELETE, parameters go to the query string rather than the body:

agledger api GET /v1/records -F status=ACTIVE -F limit=50

To walk every page of a paginated route, add --paginate. The CLI follows the cursor and streams each item as newline-delimited JSON (NDJSON), one record per line, which pipes cleanly into jq or a file:

agledger api GET /v1/records --paginate > records.ndjson

Export a chain

A record's full audit chain is exported through the same pass-through command. Write it to a file so you can verify it later, on this host or an air-gapped one:

agledger api GET /v1/records/<record-id>/audit-export > audit-export.json

The export is format 2.0: each entry is a COSE_Sign1 envelope (RFC 9052) over an in-toto v1 Statement, hash-chained and Ed25519-signed. These are the Signed Statements that make the chain verifiable by anyone holding the public keys, with no access to the Server.

Verify an export offline

agledger verify checks an export with no network access and no API key. It recomputes the hash chain, decodes each COSE_Sign1 envelope, and verifies every signature (Ed25519 or ES256). This is the default verification path - it runs the same on an air-gapped host as on a connected one.

The keys an export embeds, and the ones GET /v1/verification-keys serves, come from the Server's own database, so a key written into the database alone would verify against them. Pin a vault key you took out of band with --trust-anchor, the SHA-256 digest of its SPKI (the installer prints the first vault key's; the Server's signing-key-digest.js derives one from any key the operator holds). The CLI walks the signed key statements the export carries from that pin:

agledger verify audit-export.json --trust-anchor sha256:<digest>

The first line of the report is the verdict:

VerdictExitMeaning
PASS0The chain verifies, every key is linked to your anchor, and at least one signature verified under an anchored key. --json carries verdict: "trusted"
VERIFIED, NOT ANCHORED0Nothing failed, but this is not a trusted verdict: no --trust-anchor was given, or the anchor was walked and no signature verified under a key it reaches (an export of unsigned history, for example). --json carries verdict: "unanchored"
FAIL1The chain or a key statement failed. --json carries verdict: "failed"

Run without --trust-anchor, the report names the key the export claims as its Server's (exportMetadata.anchoredFrom). That is the export's own word, not an anchor; ask the operator for the digest instead.

To verify against keys you hold rather than the embedded set, supply them as a JSON map of signingKeyId to base64 SPKI-DER public key, a [{keyId, publicKey}] list, or the GET /v1/verification-keys response saved as it came (its key statements are walked from your anchor too). Add --require-supplied-keys to refuse the embedded keys outright. Where a key came from is not whether it is trusted; that is --trust-anchor:

agledger verify audit-export.json --keys vault-keys.json --require-supplied-keys --trust-anchor sha256:<digest>

When the operator has distrusted a leaked key with VAULT_DISTRUSTED_KEYS, pass the same entry with --distrusted-key sha256:<digest>[@<RFC 3339 instant>]: what that key signed from the instant counts for nothing. It requires --trust-anchor. Both flags take one key each and repeat.

To reject any export signed by an unexpected or retired key, pin the expected key id - every entry must reference it or verification fails:

agledger verify audit-export.json --require-key-id <key-id>

A record written under an OIDC certificate carries the agent's own signature on each chain entry. The export holds the signature and the certificate thumbprint, not the key, so pass the agents' public keys (a JWK, a list of JWKs, or a {"keys": [...]} set) to re-verify those signatures offline. One that does not verify fails CHAIN_AGENT_SIGNATURE_INVALID; without --agent-keys they are counted and not checked:

agledger verify audit-export.json --agent-keys agent-keys.json

Read the export from stdin with -, and emit a machine-readable result with --json:

cat audit-export.json | agledger verify - --json --trust-anchor sha256:<digest>

verify exits non-zero on any failure, so it gates a pipeline cleanly; a VERIFIED, NOT ANCHORED run exits 0, so a gate that needs a trusted verdict reads verdict from --json. When the chain is broken, the result names the first failing position and the failure class - for example a CHAIN_POSITION_GAP, a CHAIN_HASH_MISMATCH, a CHAIN_LINK_BROKEN, a CHAIN_SIGNATURE_MISSING_KEY, a CHAIN_SIGNATURE_INVALID, a CHAIN_ENTRY_UNSIGNED for an unsigned entry written while the vault was signing, a CHAIN_SIGNING_KEY_UNANCHORED for an entry signed by a key your anchor does not reach, or a CHAIN_ACTOR_ATTRIBUTION_MISMATCH when an entry's actorId, actorRole or actorOwnerId disagrees with the signed actor claim - so you can tell tampering from a missing key. A key statement that does not hold is reported as KEY_STATEMENT_INVALID, KEY_CLOSURE_INVALID or CHAIN_KEY_WINDOW_DRIFT under keyTrust.findings. Unsigned entries written before the install registered its first key do not fail; a passing report counts them as covered by the hash chain only. The audit page owns the full code table.

--require-out-of-band-keys from CLI 1.x is now --require-supplied-keys; the old flag is refused with a message naming the new one.

Output, exit codes, and errors

Every command supports --json (the default when stdout is piped, so output is parseable in scripts automatically), --quiet (suppress output, exit code only) and --verbose (which credential was used, as JSON lines on stderr). Errors are written to stderr as a structured object - { "error": true, "code", "message", "suggestion", ... } - and API errors pass through verbatim, including the API's own recoveryHint and nextSteps.

Exit codes are semantic and stable across releases, which lets a script branch on the failure without parsing text. 1 is the catch-all: an API error with no more specific code (a 400, for example) exits 1, as does a chain that fails agledger verify, so read the code field on stderr to tell them apart.

Exit codeMeaning
0Success
1General error
2Usage error (bad path, method, JSON, or field)
3Auth error (401, or no key configured)
4Forbidden (403, missing scope or role)
5Not found (404)
6Conflict (409)
7Rate limited (429)
8Server error (5xx)
9Network error
10Timeout

Discover the rest

Because the CLI is a thin cover, the API reference is the command reference. To explore the surface from the terminal:

agledger list-commands              # the ten CLI-local commands
agledger help-json api              # arguments and flags for one command
agledger docs                       # the Server's llms.txt narrative (--full for llms-full.txt)
agledger api GET /openapi.json      # the full route catalog from your Server

Everything beyond the CLI-local commands - records, completions, schemas, webhooks, federation, admin - is reached with agledger api <METHOD> <path> using the exact path and body shape in the API reference.