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 inps.--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 forverify --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-untilisYYYY-MM-DDand 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 orZ. --itemand--answerwritecategory: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.--outmust be a regular file path: a PDF is never written to stdout (/dev/stdoutis refused), and an existing file is refused unless--force.
The consent gate
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}.
Consent requests and documents
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
manual-consent
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-untiland--pdfare required.--itemnames the consent asks ticked on the sheet (a catalog id orcategory: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 inacknowledgedand setsasksDeclinedwhen no ask was consented. A document that asks no consent (a collection notice) is refused (exit2).--valid-untilis at most 10 years after--effective-date.--sensitive-express-attestedand--version-attestedare 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(exit2) 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:
--customerrequired. JSON: the rawClaimLink{claimUrl, token, expiresAt}. An unknown customer is a404, an ended relationship a409, both exit2. - revoke: withdraws a consent your organization recorded (paper or
telephone; a key holding only
attest_verbalreaches telephone consents). JSON:{consentRef, revoked, alreadyRevoked, gate}.gatesays whatcheckanswers now for that purpose:denied(this consent backed it),superseded(another consent had already taken over and is untouched) orunchanged(an idempotent repeat). - erase: erases (crypto-shreds) a consent your organization recorded. It
cannot be undone, hence the
attestscope only. JSON:{consentRef, erased, alreadyErased, gate}. An active retention hold refuses it (409 hold_active, exit2).
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.
consent-sheet
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.
verbal-consent
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
--answeris the person's own explicityesorno, ascategory:purpose=yes|no. --obtained-atis at most 7 days old and never in the future.--consented-byisself,self_with_assistantorrepresentative. A representative requires--capacity(tutelle,mandat_protection_homologue,representation_temporaire,curatelle,titulaire_autorite_parentale,tuteur_mineur,otherorundeclared).- For a minor under 14, pass
--minor,--consented-by representative, a--capacityoftitulaire_autorite_parentaleortuteur_mineur, and the representative's name in--respondent-name. --sensitive-express-attestedis required when a "yes" covers a sensitive purpose resting on consent.paperrecords the signed paper that confirms the call; its PDF is hashed locally exactly as formanual-consent create.- Withdraw a telephone consent with
manual-consent revokeorwithdraw. The daily cap on telephone consents exits8.
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)
retentionuses theregistryscope.--dispositionisdestroyed,anonymizedorlegal_hold;legal_holdrequires--reason(the law that imposes the delay).disposeneeds the relationship to have ended (409 relationship_active); the result says what happened to Agreely's copy of the identity (agreelyIdentity) and warns withhold_activewhen a hold stands.- An
other_lawhold requires--provision; arights_requesthold refuses one.--started-ondefaults to today;--review-onis a reminder only. holds listandholds syncneed theholdsscope. The feed pages withpageToken/nextPageToken, and only the last page carriescursor.holds syncwalks 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-sincethe result is a snapshot of every active hold (replace your whole set); with it, a delta (upsert byid). Delivery is at least once. Reaching--max-pagesfails 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 tounavailable(exit4) rather than reaching the network. A--did-docdocument 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 owndidDocumentsare never trusted. Without--did-doc, the signature and assertion checks resolve DIDs over HTTPS.--ipfs: enables thedisclosureCopycheck (fetches the IPFS copy and compares it todisclosureHash); otherwiseskipped.--onchain: also reads the document and consent anchors, and the withdrawal, and requires an RPC URL via--rpc-urlorAGREELY_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 readunavailable, a note saysunknown registry, and it exits4. 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
endedAtonrelationship end. - On a paper consent: who consented (
capacity),isMinorandageBracket. - On a telephone consent:
ageBracket(14_to_17in particular;--minorcovers 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.