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 v0.1.0, binary agreely, ESM, Node >=18.

Install

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

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…"}   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 normalizes 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.

Environment variables

  • AGREELY_API_KEY: the agent auth path.
  • AGREELY_BASE_URL: the API base URL.
  • AGREELY_RPC_URL: the RPC fallback 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, or a check ALLOW
10 a clean check DENY, an expected negative, not an error
2 usage error: bad/missing args, invalid input, a server validation error, a config error, and a not-found
3 auth: the key is missing, invalid, revoked, or lacks the required scope
4 unavailable: an Agreely outage (distinct from a deny), and a client-side poll timeout
5 rate-limited: the per-company window was exceeded
6 verification failed: a receipt was checked and did not verify (a tamper). A verdict, not an error
1 an unexpected / uncategorized failure

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 a {"error":{"code","message"}} envelope to stderr.

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, under a {"error":{"code","message"}} envelope on error. In human mode the output is colorized with wizards and confirmations, and an error prints in red as x message. 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
agreely whoami
agreely requests [--status <s>] [--cursor <id>]
agreely request create [--customer <id>] [--to <email>] [--item <item>]... [--valid-until <YYYY-MM-DD>] [--idempotency-key <key>]
agreely request show <requestId>
agreely request cancel <requestId>
agreely request wait <requestId> [--interval <ms>] [--timeout <ms>]
agreely manual-consent create [--customer <id>] [--document-version <id>] [--effective-date <YYYY-MM-DD>] [--valid-until <YYYY-MM-DD>] [--item <item>]... [--pdf <path>] [--upload]
agreely manual-consent claim-link [--customer <id>] [--reference <ref>]
agreely manual-consent revoke <consentRef> [--reason <text>]
agreely manual-consent erase <consentRef> [--reason <text>]
agreely relationship end <customerRef> --reason <text>
agreely verify <receipt.json> [--ipfs] [--onchain] [--rpc-url <url>] [--did-doc <file>]...
agreely login
agreely config set [--api-key <k>] [--base-url <url>]

check

Synchronous decision. category / purpose are sent raw (the server normalizes). Exit 0 = ALLOW, 10 = DENY, 4 = outage.

agreely check cust-42 "Email Address" "Marketing Outreach" --json
# {"decision":"allow","status":"active","consentRef":"0x…"}      exit 0
# {"decision":"deny","status":"revoked","consentRef":"0x…"}      exit 10

In human mode: OK ALLOW … or DENY …. In agent mode the JSON is {decision, status, consentRef?}.

Batch mode: --batch <file.json> reads a JSON array of {customerRef, category, purpose} objects, calls checkBatch() once, and prints a decisions table (human) or a JSON array (agent, one {customerRef, category, purpose, decision, status, consentRef?} per cell). Exit is 0 when all cells allow, 10 when any denies, 4 on an outage. This is the tool for gating a list display (N customers x M fields = N x M checks) in one round-trip.

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

catalog

Lists the company's active catalog. JSON {catalog: entries}.

whoami

Verifies the key server-side by calling GET /v1/whoami: it reports the scopes the server actually verifies, 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

Lists requests, cursor pagination. --status accepts pending|approved|refused|expired|revoked_before_action (an invalid value is a usage error). The JSON emits the raw page {items, nextCursor}.

request create

Dual mode. Non-interactive when agent mode is active or any scriptable flag is present: the CLI then validates (customer required, valid email, YYYY-MM-DD date, at least one item). At a human TTY with no flags, a wizard guides catalog selection, collects the customer, recipient, and valid-until, validates each, and confirms before issuing.

agreely request create \
  --customer cust-42 --to [email protected] \
  --item "Email Address:Marketing Outreach" --item 4b082452-… \
  --valid-until 2030-01-01 --idempotency-key issue-2026-001 --json
# -> the raw IssuedRequest

An --item is either a catalog entry id or category:purpose. Reuse --idempotency-key to make a retry safe: a replay returns the original request, with no double-issue and no double-email.

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 settles (approved|refused|expired|revoked_before_action). A timeout exits 4. --interval defaults to 2000 ms, --timeout defaults to 120000 ms. JSON: the raw settled record.

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

Scope attest. Records a consent obtained offline (paper or signed PDF).

create: the PDF is hashed locally (sha256); by default only pdfSha256 ("0x" + hash) is sent. The raw bytes (base64) leave the machine only with --upload. All of these are required: --customer, --document-version, --effective-date, --valid-until, at least one --item, and --pdf. JSON: the raw ManualConsentResult.

agreely manual-consent create \
  --customer cust-42 --document-version dv-2026-05 \
  --effective-date 2026-05-01 --valid-until 2031-05-01 \
  --item "Email Address:Marketing Outreach" \
  --pdf ./signed.pdf --json
# the hash alone leaves; add --upload to also send the bytes

claim-link: --customer required. JSON: the raw ClaimLink {claimUrl, token, expiresAt}.

revoke: consentRef matches ^0x[0-9a-f]+$ (case-insensitive). JSON: the raw revocation {consentRef, revoked, alreadyRevoked}.

erase: erases (crypto-shreds) a manual consent. JSON: the raw erasure {consentRef, erased, alreadyErased}.

relationship end

Scope relationship. Attests that a customer relationship is over (Law 25 art. 23, "les fins sont accomplies"). <customerRef> is your own customer reference (the same one used by check), scoped to the key's company. --reason is required: a missing or blank reason exits with an error, 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: {customerRef, status, endedAt, endedBy}.

agreely relationship end cust-42 --reason "Account closed, purposes fulfilled" --json
# {"customerRef":"cust-42","status":"ended","endedAt":"2026-06-26T15:04:05Z","endedBy":"company"}   exit 0

verify

Offline-first receipt verification. A thin wrapper over the SDK static Agreely.verifyReceipt, surfacing the same honest statuses: it does not re-derive verdicts.

In human mode, verify prints an honesty matrix with five rows, companySignature, citizenAssertion, disclosureCopy, documentAnchor, and cellLabelBinding, each pass / fail / unavailable / unsupported / skipped, plus a top verdict (VERIFIED / PARTIAL / UNAVAILABLE / FAILED) and notes. The cellLabelBinding row is the field to read before trusting a displayed label. In agent mode, it emits the raw ReceiptVerification.

agreely verify ./receipt.json --json

Flags:

  • --ipfs: enables the disclosureCopy check (fetches the IPFS copy and compares it to disclosureHash); otherwise skipped.
  • --onchain: enables the on-chain documentAnchor check and requires an RPC URL via --rpc-url or AGREELY_RPC_URL (else a usage error).
  • --did-doc <file> (repeatable): installs a local DID-document resolver, making verify fully air-gapped. An unknown DID then resolves to unavailable (exit 4) rather than reaching out. Without --did-doc, the signature and assertion checks do one HTTPS DID resolution by default.

Exit 4 (unavailable) versus exit 6 (failed)

This is the honest core of the verifier. An unresolvable DID (the issuer or citizen DID cannot be resolved) is exit 4 (unavailable), not exit 6. Exit 6 is reserved for an active cryptographic failure: a tamper or a wrong key. A valid receipt, during a DID-resolution outage, must never look like a forgery.

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. Resolution order:

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

A missing key throws a usage error (exit 2): "No API key. Set AGREELY_API_KEY, run 'agreely login', or pass --api-key." The default API base URL shown is https://api.agreely.ca.

Base URL: --base-url > AGREELY_BASE_URL > stored config > the SDK default. 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 CLI v1: 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.

Next