FR EN

CLI

@agreely/cli is the consent gate as a command-line tool. One binary, two modes:

  • Humans get colored output and an interactive wizard (at a TTY).
  • Agents get pure JSON on stdout and stable exit codes (a pipe or --json).

It is a thin shell over @agreely/sdk: it never reimplements the HTTP, decision, or normalization logic. It resolves auth, picks a mode, calls the SDK, and maps the result to an exit code.

Package @agreely/cli 0.5.1 (the published version this page documents), binary agreely, ESM, Node >=18, MIT license. It depends on @agreely/sdk ^0.6.1, so it shares the limits of SDK 0.6.1: see What 0.5.1 does not cover.

Install

npm install -g @agreely/cli
agreely --help
agreely --version    # -> 0.5.1

Agent-native: one env var + --json

export AGREELY_API_KEY=agr_live_xxx          # the only setup an agent needs

agreely check cust-42 "Email Address" "Marketing Outreach" --json
# -> {"decision":"allow","status":"active","consentRef":"0x…","assurance":"citizen_signed","tier":"full"}   exit 0

Set the key once via AGREELY_API_KEY (no prompt, no keychain needed), pass --json (or just pipe: a non-TTY stdout auto-triggers agent mode), and branch on the exit code. category / purpose are sent raw; the server matches them.

Global flags

Accepted before or after the subcommand:

  • --json: force agent mode (pure JSON on stdout).
  • --api-key <key>: discouraged, visible in ps.
  • --base-url <url>: override the API base URL.
  • -v, --version: print the version.

Environment variables

  • AGREELY_API_KEY: the agent auth path.
  • AGREELY_BASE_URL: the API base URL.
  • AGREELY_RPC_URL: the RPC URL for verify --onchain.
  • XDG_CONFIG_HOME: relocates the config directory.

The exit-code table (the agent contract)

The exit code is the contract. It cleanly separates an outage from a denial and a verification failure from an outage, which is the whole point of an agent-native consent gate.

code meaning
0 success, a check ALLOW, or a verify that reads verified (a bundle or a paper receipt) or partial (a telephone receipt); with --onchain, only when the chain also reads the consent in force (withdrawal: "active"). Never for a bare passkey receipt
1 an unexpected / uncategorized failure
2 usage or validation: bad or missing arguments, invalid input (a 422, a 413), no credentials, a config error, a 404, or a 409 state conflict
3 auth: the key is missing, invalid, revoked, or lacks the required scope
4 unavailable: an Agreely outage or a timeout (distinct from a deny), a request wait that ran out of time, or a verify with a check that could not complete: an unresolvable DID, a check asked for with --onchain or --ipfs, or a withdrawal --onchain could not read (a paper or telephone receipt, or a bundle that does not name every accepted consent)
5 rate-limited: the per-company per-minute window was exceeded (retry after the delay)
6 verification failed: a bundle or a receipt was checked and did not verify, or the file is malformed (no check ran). A verdict, not an error; a file that is not JSON exits 2
7 billing inactive: the company's Agreely subscription lapsed (HTTP 402, envelope code billing_inactive). Fail-closed like a deny, but actionable and distinct from an outage
8 daily cap (HTTP 429 withdrawal_daily_cap, verbal_daily_cap, hold_budget_exhausted, hold_release_cap_reached). Not a rate window: retrying today cannot succeed, so do not loop on it
9 the write succeeded but its output could not be saved (consent-sheet create: the sheet was minted, the file could not be written). The reference and the claim link were still printed. Do not retry
10 a clean check DENY, an expected negative, not an error
11 verify: incomplete, a bare passkey receipt cannot be tied to the offer it answers. Verify its verification bundle instead. Neither a tamper nor a success
12 verify --onchain: withdrawn. The receipt is authentic, but the chain records the consent's withdrawal, for all of its purposes or some (consentAnchorRecords says which, with revokedAt). Neither a tamper nor a success

check resolves ALLOW to 0 and DENY to 10. The CLI is fail-closed: on an outage the SDK throws and the CLI exits 4, so a caller can always tell "outage" from "denied". A DENY's JSON still goes to stdout; a real error keeps stdout clean and writes an envelope to stderr:

{"error":{"code":"invalid_request","message":"…","reason":"other_consents_running","field":"otherConsents"}}

reason and field appear when the server sent them. Branch on code and reason, never on message. A 409 keeps its specific code (identity_held, already_released, already_minted, citizen_consent_at_gate, ...). Without a code it would fall back to retry, which does not happen today since the API always sends one. A CLI-side usage error has the code usage.

Modes (auto-detected)

condition mode behavior
stdout is a TTY and no --json human colors, the wizard, confirmations
stdout is not a TTY, or --json agent no prompts ever, pure JSON to stdout, logs/errors to stderr

In agent mode, stdout carries pure JSON and nothing else; every log, hint, or error goes to stderr. In human mode the output is colorized with wizards and confirmations. picocolors auto-disables color when not a TTY. A missing required argument in agent mode is a clear error plus a usage exit (2); it never hangs waiting on a prompt.

Commands

agreely check <customerId> <category> <purpose>
agreely check --batch <file.json>
agreely catalog [--document <code>]
agreely whoami
agreely requests list [--customer <ref>] [--status <s>] [--limit <n>] [--cursor <id>]
agreely request create [--customer <id> --to <email> (--document <versionId> | --document-code <code>) --valid-until <YYYY-MM-DD>] [--idempotency-key <k>]
agreely request show <requestId>
agreely request cancel <requestId>
agreely request wait <requestId> [--interval <ms>] [--timeout <ms>]
agreely documents list | show <code> | pdf <documentVersionId> --out <file.pdf> [--locale fr|en] [--force]
agreely manual-consent create --customer <id> --document-version <id> --effective-date <YYYY-MM-DD> --valid-until <YYYY-MM-DD> [--item <item>]... --pdf <path> [--upload] [--sensitive-express-attested] [--version-attested] [--idempotency-key <k>]
agreely manual-consent claim-link --customer <id> [--reference <ref>]
agreely manual-consent revoke <consentRef> [--reason <text>] [--other-consents keep|withdraw]
agreely manual-consent erase <consentRef> [--reason <text>] [--other-consents keep|withdraw]
agreely consent-sheet create <customerRef> --document-version <id> --out <file.pdf> [--locale fr|en] [--idempotency-key <k>] [--force]
agreely verbal-consent record | show <consentId> | paper <consentId> ...
agreely withdraw <customerRef> <consentRef> --channel <c> --operator <id> [--requested-at <instant>] [--reason <text>] [--other-consents withdraw|keep | --also-withdraw <ref,...>] [--idempotency-key <k>]
agreely customer get <customerRef>
agreely customer set <customerRef> [--display-name <n>] [--email <e>] [--notice-locale fr|en]
agreely retention show <customerRef>
agreely retention dispose <customerRef> --disposition <kind> [--reason <text>] [--retention-until <YYYY-MM-DD>] [--schedule-ref <ref>]
agreely holds place <customerRef> --ground <g> [--provision <text>] [--rule <key>]... [--cell <id>]... [--started-on <date>] [--review-on <date>] [--idempotency-key <k>]
agreely holds release <customerRef> <holdId> --reason <text> [--idempotency-key <k>]
agreely holds list [--changed-since <cursor>] [--page-token <t>]
agreely holds sync [--changed-since <cursor>] [--max-pages <n>]
agreely relationship end <customerRef> --reason <text>
agreely relationship revert <customerRef> --reason <text>
agreely verify <file.json> [--did-doc <file>]... [--onchain --rpc-url <url>] [--ipfs]
agreely login
agreely config set [--api-key <k>] [--base-url <url>]

Every command also takes the global flags. A bare agreely requests ... is kept as an alias of agreely requests list.

Writes: timeouts and retries

Write commands get a 15 second budget and reads get 5 seconds, except check, which keeps the SDK's 800 ms sized for the consent gate. These commands send an Idempotency-Key, yours (--idempotency-key) or one the CLI generates: request create (which emails a person), manual-consent create, verbal-consent record and paper, withdraw, holds place and holds release. If one of them ends in a timeout or an outage (exit 4), or in a 409 with code retry, the error envelope carries idempotencyKey: retry with that same key and the server replays the first answer instead of writing twice. A new key is a new write.

consent-sheet create is the exception: its key is a latch, so a repeated key is a 409 already_minted. The other writes (customer set, retention dispose, relationship end|revert, manual-consent claim-link|revoke|erase, request cancel) send no key of their own and print none.

Dates, instants and category:purpose

  • --valid-until is YYYY-MM-DD and means through the end of that day in your company's timezone, at most 10 years ahead (an Agreely product ceiling, not a statutory one). For a telephone consent it may also be an RFC 3339 instant with an offset. Relative phrases such as "+1 year" are refused.
  • Instants (--obtained-at, --signed-at, --requested-at) must carry an offset or Z.
  • --item and --answer write category:purpose, split on the first colon with both sides trimmed. A category that itself contains a colon cannot be written that way: use the catalog id with --item.
  • --out must be a regular file path: a PDF is never written to stdout (/dev/stdout is refused), and an existing file is refused unless --force.

check

Synchronous decision. Exit 0 = ALLOW, 10 = DENY, 4 = outage.

agreely check cust-42 "Email Address" "Marketing Outreach" --json
# {"decision":"allow","status":"active","consentRef":"0x…","assurance":"citizen_signed","tier":"full"}   exit 0
# {"decision":"deny","status":"revoked","consentRef":"0x…","assurance":"company_attested","tier":"manual"}   exit 10
# {"decision":"allow","status":"necessity","basis":"necessary_for_service"}   exit 0

In agent mode the JSON is {decision, status, consentRef?, assurance?, tier?, basis?}.

Labels are bilingual and accent-tolerant. The category and purpose may be given in French or in English, with or without accents, and are matched case- and whitespace-insensitively. English resolves only when the company disclosed an English label for that cell. An ambiguous or undeclared label fails closed (deny none), so pass the label as declared in the catalog when you can.

An allow is not always a consent. status: "necessity" means there is no consent record: the allow rests on a non-consent ground the company declared on the catalog cell, and basis names it. Such a decision has no consentRef and no assurance, so if you pipe this into a report, read basis: without it a necessity allow is indistinguishable from a consented one. Agreely records the declared ground; it does not rule on its legal validity.

Read the proof tier, not just the decision. A record-backed decision carries assurance and tier, the same proof under two names: citizen_signed / full (the person signed with a passkey), company_attested / manual (a hand-signed paper) and company_documented / verbal (a consent given by telephone and documented by your organization, with no signed document). A telephone consent allows exactly like the others; your system decides what each tier may unlock, so treat an assurance or tier you do not recognize as not acceptable.

An informed line is not a consent. A line the document gives for information, acknowledged on a paper sheet or a call, answers like no record at all (necessity with its basis, or the usual deny), with no consentRef and no assurance.

The deny statuses are none, revoked, expired, erased, relationship_ended (the company documented the end of the relationship, P-39.1 s. 12, first paragraph; A-2.1 s. 65.1, first paragraph), requires_depersonalization and basis_not_in_regime. Treat any status you do not recognize as a deny and read decision, which is only ever allow or deny. The statuses are detailed in the API reference.

Batch mode: --batch <file.json> reads a JSON array of {customerRef, category, purpose} objects and sends them in one request. The agent output is a JSON array, one {customerRef, category, purpose, decision, status, consentRef?, assurance?, tier?, basis?} per cell. Exit is 0 when all cells allow, 10 when any denies, 4 on an outage. The server caps a batch at 500 cells: a larger file is refused before the request is sent (exit 2, with the count in the message), so split large files yourself. The API allows 120 requests per minute per company (per company, not per key), and one --batch run is one request.

agreely check --batch ./cells.json --json
# [{"customerRef":"cust-42","category":"Email Address","purpose":"Marketing Outreach","decision":"allow","status":"active","consentRef":"0x…","assurance":"citizen_signed","tier":"full"}]

catalog

Lists the company's active catalog: JSON {catalog: entries}. With --document <code>, only the active cells of one published document, with its documentVersionId. Never decide a consent from the catalog: only check says what a person consented to.

whoami

Verifies the key server-side by calling GET /v1/whoami: it reports the scopes the server actually grants, not a local guess. A bad key exits 3. It never prints the secret (masked: first 8 + last 4 characters). JSON: {authenticated, apiKeyMasked, apiKeySource, baseUrl, scopes}.

requests list

Lists requests (metadata only), cursor pagination. --status accepts pending, approved, asks_declined, refused, expired or revoked_before_action (an invalid value is a usage error). --limit defaults to 50 on the server, at most 100. The JSON is the raw page {items, nextCursor}.

request create

Every request is issued under a published consent document: pass --document <versionId> or --document-code <code> (one, not both; find them with agreely documents list). The requested (category, purpose) items derive from the document server-side: there is no --item flag on this command.

agreely request create \
  --customer cust-42 --to [email protected] \
  --document-code privacy-2026 \
  --valid-until 2030-01-01 --idempotency-key issue-2026-001 --json
# -> the IssuedRequest: {"requestId":"0x…","status":"pending","deepLink":"…","document":{…},…}

Reuse --idempotency-key to make a retry safe: a replay returns the original request, with no double-issue and no double-email.

At a human TTY with no flags, a wizard collects the document reference, customer, recipient email, and valid-until, validates each, and confirms before issuing. In agent mode, or as soon as one scriptable flag is present, every required flag must be given.

A request's status is pending, approved, asks_declined, refused, expired or revoked_before_action. asks_declined means the person confirmed receiving the information but declined every consent ask, so no consent was obtained; it is never listed under --status approved, and approved means at least one ask was accepted.

request show

requestId must match ^0x[0-9a-f]{64}$. JSON: the raw record.

request cancel

Cancels a pending request by its requestId (scope issue). The call is idempotent: an already-terminal request is not an error; the exit stays 0 with cancelled: false. JSON: {requestId, status, cancelled}.

agreely request cancel 0x… --json
# {"requestId":"0x…","status":"revoked_before_action","cancelled":true}   exit 0

request wait

Polls until the request is no longer pending (any other status, asks_declined included). Running out of time exits 4. --interval defaults to 2000 ms, --timeout to 120000 ms. JSON: the raw settled record.

agreely request wait 0x… --timeout 300000 --json

documents

agreely documents list --json
agreely documents show <code> --json                 # the full published disclosure
agreely documents pdf <documentVersionId> --out ./info.pdf --locale en

Read-only discovery of the published consent documents. Pin the stable code, not the version id, which changes on every publication. pdf writes the information document to a file; fetching it records nothing and is not evidence that anyone was informed.

Paper and telephone consents

Scope attest. Records a consent gathered on paper (a signed PDF) under your company's attestation. The result carries assurance: "company_attested" (the online flow yields citizen_signed).

agreely manual-consent create \
  --customer cust-42 --document-version 4b08… \
  --effective-date 2026-06-01 --valid-until 2031-01-01 \
  --item "Email Address:Marketing Outreach" --item 4b082452-… \
  --pdf ./signed-consent.pdf --json
# -> {"consentId":"…","merkleRoot":"0x…","consentRefs":["0x…"],"assurance":"company_attested","anchored":false,"acknowledged":[…],"asksDeclined":false}
  • create: --customer, --document-version, --effective-date, --valid-until and --pdf are required. --item names the consent asks ticked on the sheet (a catalog id or category:purpose); omit it entirely for a sheet that answered "no" to every ask. The server adds every line the document gives for information as an acknowledgement (never a consent); the response lists them in acknowledged and sets asksDeclined when no ask was consented. A document that asks no consent (a collection notice) is refused (exit 2). --valid-until is at most 10 years after --effective-date. --sensitive-express-attested and --version-attested are sent only when given.
  • The PDF is hashed locally (0x + SHA-256); only that commitment is sent. The bytes leave the machine only with --upload, and the server then checks them against the hash. An empty file is refused, and so is an upload that is not a PDF.
  • A 409 (exit 2) means the request contradicts the record: a purpose already held by an active consent the person signed with a passkey, a telephone consent for the same customer and document still awaiting its signed paper, or a relationship that has ended.
  • claim-link: --customer required. JSON: the raw ClaimLink {claimUrl, token, expiresAt}. An unknown customer is a 404, an ended relationship a 409, both exit 2.
  • revoke: withdraws a consent your organization recorded (paper or telephone; a key holding only attest_verbal reaches telephone consents). JSON: {consentRef, revoked, alreadyRevoked, gate}. gate says what check answers now for that purpose: denied (this consent backed it), superseded (another consent had already taken over and is untouched) or unchanged (an idempotent repeat).
  • erase: erases (crypto-shreds) a consent your organization recorded. It cannot be undone, hence the attest scope only. JSON: {consentRef, erased, alreadyErased, gate}. An active retention hold refuses it (409 hold_active, exit 2).

When the person also signed the same purpose online, with a passkey, and that consent is in force, revoke and erase are refused (409 citizen_consent_at_gate, exit 2): these commands never end it, record the withdrawal the person asked for with withdraw instead. --other-consents keep ends only this record and leaves that consent in force, and the output then carries stillInForce. --other-consents withdraw is refused like no declaration.

agreely consent-sheet create cust-42 --document-version 4b08… --out ./sheet-cust-42.pdf

Mints the signature sheet of one published version for one customer (scope attest) with a new claim. The PDF is written to --out (never stdout; an existing file is refused before minting unless --force). The printed reference and the claim link are returned once and stored nowhere, so nothing can recover them later. A replayed --idempotency-key is a 409 already_minted.

  • Never send the claim link in the same envelope as the sheet: the printed reference is its second factor.
  • Never send the blank sheet's hash as the evidence of a paper consent: the evidence is the signed paper once it comes back.

A consent the person gave by telephone, documented by your organization: the weakest tier (tier: "verbal", assurance: "company_documented"). Recording needs the attest_verbal scope, the paper needs attest, show accepts either.

agreely verbal-consent record --customer cust-42 --document-version 4b08… \
  --answer "Email Address:Marketing Outreach=yes" --answer "Phone:Surveys=no" \
  --obtained-at 2026-10-09T10:15:00-04:00 --obtained-by agent-7 --script-version v3 \
  --consented-by self --valid-until 2027-10-09 --paper-expected --json
# -> {"consentId":"…","tier":"verbal","assurance":"company_documented","consentRefs":["0x…"],"acknowledged":[…],"asksDeclined":false,…}

agreely verbal-consent show <consentId> --json     # what was said, what the paper said, what is in force
agreely verbal-consent paper <consentId> --signed-at 2026-10-12T09:00:00-04:00 \
  --answer "Email Address:Marketing Outreach=yes" --pdf ./signed.pdf --json
  • Every --answer is the person's own explicit yes or no, as category:purpose=yes|no.
  • --obtained-at is at most 7 days old and never in the future.
  • --consented-by is self, self_with_assistant or representative. A representative requires --capacity (tutelle, mandat_protection_homologue, representation_temporaire, curatelle, titulaire_autorite_parentale, tuteur_mineur, other or undeclared).
  • For a minor under 14, pass --minor, --consented-by representative, a --capacity of titulaire_autorite_parentale or tuteur_mineur, and the representative's name in --respondent-name.
  • --sensitive-express-attested is required when a "yes" covers a sensitive purpose resting on consent.
  • paper records the signed paper that confirms the call; its PDF is hashed locally exactly as for manual-consent create.
  • Withdraw a telephone consent with manual-consent revoke or withdraw. The daily cap on telephone consents exits 8.

withdraw

Records the withdrawal the person asked for, on the person's behalf (scope withdraw, never on a key by default). --channel is phone, email, mail, in_person or other; --operator is your opaque id of the staff member, never an email. --requested-at is when the person asked; --reason is at most 1,000 characters.

agreely withdraw cust-42 0x… --channel phone --operator agent-7 --reason "asked by phone" --other-consents keep --json
# -> {"consentRef":"0x…","withdrawn":true,"alreadyWithdrawn":false,"recordedOnBehalf":true,"assurance":"company_documented","gate":"denied","alsoWithdrawn":[],"stillInForce":["0x…"]}

The JSON is the server's answer as received: assurance is company_documented, whatever the consent's tier. Read gate before telling anyone the use stopped. The withdrawal is accepted after the relationship ended and during a billing lapse. The daily cap (50 per rolling 24 hours) exits 8: record further withdrawals from the customer page in Agreely.

When another consent of the same purpose is in force for this customer, the withdrawal says what happens to it: --other-consents withdraw withdraws them all, --other-consents keep leaves them in force, and --also-withdraw <ref,...> names the ones to withdraw (comma-separated consentRefs, at most 50, repeatable). Give one of the two flags, never both: the two together, an unknown scope, a ref that is not a consentRef or more than 50 refs exit 2 before any call. Without either, the command exits 2 with the code invalid_request and the reason other_consents_running, nothing is written, and the error envelope lists those consents in otherConsentRefs: ask the person, then retry with a scope. alsoWithdrawn lists the consents withdrawn with this one, stillInForce those still in force.

Customers, retention and holds

customer

Scope registry.

agreely customer get cust-42 --json     # metadata only: booleans, never the name or email
agreely customer set cust-42 --display-name "Ada Lovelace" --email [email protected] --notice-locale fr
agreely customer set cust-42 --email ""  # a merge: an absent flag is untouched, an empty value clears it

409 identity_held or identity_erased means the identity cannot be changed now.

customer set takes neither --legal-basis nor --basis-note: the registry records no reason for holding a customer (the ground is documented per catalog cell), and an unknown flag exits 2.

retention and holds

agreely retention show cust-42 --json
agreely retention dispose cust-42 --disposition legal_hold --reason "Act X s. 12" --retention-until 2031-01-01
agreely holds place cust-42 --ground other_law --provision "Act X s. 12" --rule <ruleKey> --cell <catalogId>
agreely holds place cust-42 --ground rights_request          # no --rule/--cell: covers everything
agreely holds release cust-42 <holdId> --reason "request closed"
agreely holds list [--changed-since <cursor>] [--page-token <t>]   # ONE page
agreely holds sync [--changed-since <cursor>] [--max-pages <n>]    # EVERY page (default bound 1000)
  • retention uses the registry scope. --disposition is destroyed, anonymized or legal_hold; legal_hold requires --reason (the law that imposes the delay). dispose needs the relationship to have ended (409 relationship_active); the result says what happened to Agreely's copy of the identity (agreelyIdentity) and warns with hold_active when a hold stands.
  • An other_law hold requires --provision; a rights_request hold refuses one. --started-on defaults to today; --review-on is a reminder only.
  • holds list and holds sync need the holds scope. The feed pages with pageToken / nextPageToken, and only the last page carries cursor. holds sync walks every page and prints the cursor to keep (in --json: {"mode","holds","cursor"}): persist it and pass it as the next --changed-since. Without --changed-since the result is a snapshot of every active hold (replace your whole set); with it, a delta (upsert by id). Delivery is at least once. Reaching --max-pages fails rather than print a partial feed.

Relationship

relationship end

Scope relationship. Documents that a customer relationship is over, within the meaning of P-39.1 s. 23: the purposes 'are achieved' (A-2.1 s. 73 for a public body: the purposes 'have been achieved'). <customerRef> is your own customer reference (the same one used by check), scoped to the key's company. --reason is required and checked before any network call: a missing or blank reason exits 2, never a silent end. The call is idempotent (re-ending an already-ended relationship is a success; the original endedAt and endedBy stand). It is a lifecycle overlay: it never revokes or erases any consent. JSON: the server's answer.

agreely relationship end cust-42 --reason "Account closed, purposes fulfilled" --json
# {"customerRef":"cust-42","status":"ended","endedAt":"…","endedRecordedAt":"…","endedBy":"company"}   exit 0

CLI 0.5.1 always dates the end by the call: the API's optional past endedAt, for a system that declares the end late, has no flag in 0.5.1.

relationship revert

Scope relationship. Undoes a premature or mistaken end of relationship: a correction of an inaccurate record (P-39.1 s. 11; A-2.1 s. 72), never a resurrection of dead consent. --reason is required.

The undo is eligible only if the end was recorded by the company, nothing was destroyed, and it is within the 30-day window (an Agreely product rule, not a period set by law). An ineligible relationship exits 2 (a clean server 404 with nothing written): a returning person is a new relationship needing a fresh consent request. JSON: {customerRef, status, reverted}.

agreely relationship revert cust-42 --reason "End recorded by mistake" --json
# {"customerRef":"cust-42","status":"active","reverted":true}   exit 0

verify

Offline-first verification of a verification bundle (the receipt, the signed offer and the Merkle root a passkey consent issues) or of a paper or telephone receipt. A thin wrapper around the SDK 0.6.1's static Agreely.verifyReceipt method, exposing the same statuses: it does not re-derive the verdicts. It needs no API key.

agreely verify ./agreely-verification-bundle.json --did-doc ./issuer.did.json --did-doc ./citizen.did.json
agreely verify ./agreely-verification-bundle.json --onchain --rpc-url https://base-rpc.publicnode.com --json

In human mode, verify prints the overall verdict (VERIFIED, PARTIAL, INCOMPLETE, UNAVAILABLE, WITHDRAWN, MALFORMED or FAILED) with the receipt type, then one line per check (companySignature, receiptIntegrity, citizenAssertion, disclosureCopy, documentAnchor, consentAnchor, cellLabelBinding), the withdrawal line, and the notes. The cellLabelBinding line is the one to read before trusting a displayed label. In agent mode, it emits the raw ReceiptVerification, with withdrawal and, when the chain records it, withdrawnAt.

overall exit meaning
verified 0 the checks listed hold, on a bundle or a paper receipt; it is not a legal proof. With --onchain, 0 only when the chain also reads the consent in force, otherwise 4
partial 0 a telephone receipt whose company signature verifies: the organisation's word. partial does not on its own show that this person consented; app.agreely.ca/verify also reads, with its verification bundle, whether a version 4 receipt is still in force. With --onchain, 4
incomplete 11 a bare passkey receipt: verify its verification bundle. Neither a tamper nor a success
withdrawn 12 with --onchain: the receipt is authentic and the chain records the consent's withdrawal, for all of its purposes or some (consentAnchorRecords says which). It is no longer in force; it is not a forgery
unavailable 4 a check could not complete (an unresolvable DID, a subject that is not did:agreely:citizen:, an unknown registry): inconclusive, not a forgery
malformed 6 a file the verifier cannot read: no check ran, so this verdict says nothing of a tamper. A file that is not JSON exits 2
failed 6 a check actively failed: a tamper, a wrong key, or a DID document that does not confirm the key (see below)

Flags:

  • --did-doc <file> (repeatable): installs a local DID-document resolver, making DID resolution network-free. An unknown DID then resolves to unavailable (exit 4) rather than reaching the network. A --did-doc document is trusted as given: whoever supplies it supplies the key. Take it from the issuer and the citizen tier, never out of the file under test; the bundle's own didDocuments are never trusted. Without --did-doc, the signature and assertion checks resolve DIDs over HTTPS.
  • --ipfs: enables the disclosureCopy check (fetches the IPFS copy and compares it to disclosureHash); otherwise skipped.
  • --onchain: also reads the document and consent anchors, and the withdrawal, and requires an RPC URL via --rpc-url or AGREELY_RPC_URL (else a usage error). It reads only the Base mainnet registry. A bundle whose issuer-key delegation names another registry or chain (a bundle anchored on Base Sepolia, for one) is refused before any call when a check would read the chain: those checks read unavailable, a note says unknown registry, and it exits 4. That is not a forgery, and retrying does not change it.

A check you asked for with --onchain or --ipfs that could not complete exits 4, never 0 or 6.

The withdrawal

The verdict says whether the receipt is authentic; withdrawal says whether the consent is still in force: active, withdrawn (exit 12) or not_checked. Without --onchain it always reads not_checked, and a verified consent exits 0 with the line WITHDRAWAL NOT CHECKED: a withdrawn consent verified offline still reads verified. To accept a consent only while it is in force, verify its bundle with --onchain and accept exit 0 alone. This verifier reads the on-chain record of a passkey consent's verification bundle only: under --onchain, a paper or telephone receipt exits 4.

The detail of each check is in Verifying a ConsentReceipt.

Exit 4 (unavailable) vs exit 6 (failed)

This is the honest core of the verifier. A check that could not complete (an unresolvable DID, a failed RPC or gateway, an unknown registry) exits 4 (unavailable), not 6. Exit 6 is reserved for an active failure (a tamper, a wrong key, a chain that contradicts the file) and for a malformed file. A valid receipt, during an outage, must never look like a forgery.

The keys come from the DID documents the verifier reads. A citizen --did-doc without authentication or validFrom, or an issuer document that does not list the key under assertionMethod, makes an authentic receipt exit 6. A failed alone is therefore not evidence of forgery: read the check that failed and the notes, check the --did-doc documents you supplied, or verify the receipt at app.agreely.ca/verify with its verification bundle.

Credentials

login

Interactive only. In agent mode, login errors and points you to config set or AGREELY_API_KEY. It prompts for the key and an optional base URL, verifies the key against the live API before persisting (it never stores a key that does not work), then stores it in the OS keychain, falling back to the 0600 config file.

config set

Non-interactive: it never prompts and never verifies. It requires at least one of --api-key and --base-url, and stores the credentials.

agreely config set --api-key agr_live_xxx --base-url https://api.agreely.ca

Auth precedence

The CLI never prompts for a key outside login. Resolution order:

--api-key flag  (discouraged, visible in `ps`)
  > AGREELY_API_KEY env      (the agent path: one var, no prompt)
    > OS keychain (keytar)
      > ${XDG_CONFIG_HOME|~/.config}/agreely/config.json   (mode 0600)

A missing key is a usage error (exit 2): "No API key. Set AGREELY_API_KEY, run agreely login, or pass --api-key."

Base URL: --base-url > AGREELY_BASE_URL > stored config > the SDK default (https://api.agreely.ca). If the optional keytar native module is unavailable, login / config set fall back to the 0600 config file.

Degrade is omitted (by design)

The CLI is fail-closed and maps an outage to exit 4. The fail-open / two-gate / break-glass degrade policy is intentionally omitted from the CLI: it is a long-lived application/SDK-integration concern (with an audit sink and a bounded window), not something a one-shot CLI invocation can persist or audit sensibly. Configure it where the SDK is embedded.

What 0.5.1 does not cover

The API accepts these members; CLI 0.5.1 has no flag for them. These cases go through the Agreely app or a direct call to the API:

  • A past endedAt on relationship end.
  • On a paper consent: who consented (capacity), isMinor and ageBracket.
  • On a telephone consent: ageBracket (14_to_17 in particular; --minor covers a minor under 14).
  • The privacy policy routes and the inventory.

Open and auditable

MIT-licensed, with no telemetry, no analytics and no phone-home: every request goes through @agreely/sdk to the API base URL you configure. An API key is sent only as the Authorization: Bearer header to that API; it is never logged (it is masked for display). Dependencies: @agreely/sdk, commander, @clack/prompts, picocolors, and an optional keytar.

Next