Command-line interface
npm i @allscale/cli
For the latest version, please refer to https://www.npmjs.com/package/@allscale/cli
Command-line interface for AllScale — drives the same actions a user can take in app.allscale.io. Designed for AI agents and humans alike.
Sign in, issue and pay invoices, create claim links, inspect transactions, and withdraw stablecoins from your AllScale wallet — with JSON on stdout and stable exit codes, so scripts and agents can drive it without screen-scraping.
Important legal, risk, security, and privacy notice
This CLI provides command-line access to supported AllScale features. Availability depends on your account, permissions, jurisdiction, applicable law, risk controls, and service version. Use of AllScale services is governed by the Terms of Use, Privacy Policy, and applicable risk disclosures. AllScale's role and the non-custodial features of the services are described in the Terms of Use.
AllScale Services are available only to eligible users in supported jurisdictions. Transactions may be delayed, rejected, suspended, returned, or require additional information to comply with applicable law, sanctions, AML/CTF requirements, fraud controls, network conditions, or service-provider requirements.
Digital assets and stablecoins are not bank deposits, may not be protected by deposit insurance, and may lose value. Blockchain transactions may be irreversible. Verify the recipient, wallet address, network, token, amount, and fees before approving any transaction. AllScale does not provide investment, legal, tax, or accounting advice.
Do not expose passwords, agent keys, OTPs, API secrets, Claim Link tokens, or personal data in shell history, logs, prompts, tickets, or shared files. CLI output and optional event logs may contain personal, confidential, and transaction data.
AI and automation use
Commands submitted through scripts, AI agents, or other automation may create legal, financial, and irreversible consequences. Scope controls limit categories of permitted calls but do not verify an agent's authority, accuracy, business purpose, recipient, legality, or safety. You are responsible for configuring and supervising third-party agents and models. Use least-privilege scopes, short-lived credentials, idempotency controls, testing, and appropriate human review for irreversible actions.
Install
npm install -g @allscale/cli
allscale --helpRequires Node.js >= 20.10.
To upgrade: npm install -g @allscale/cli@latest. To remove: npm uninstall -g @allscale/cli.
Quick start
Windows note. The invoice examples above compute the due date with POSIX command substitution.
sh,bash,zshand PowerShell all expand it;cmd.exepasses the token through literally, so the CLI receives the text itself and rejects it with a validation error. Oncmd.exe, pass an explicit ISO date instead.
Commands
Auth
device-login
Recommended for AllScale Pay Accounts. Opens the browser; use an existing web-app session or sign in there with Passkey or Email. On the Email path, enter the Email OTP on the webpage, not in the terminal, then approve the CLI. The backend mints a scoped agent API key — the credential issued by the current CLI login flows. Scopes chosen on the approval screen are checked by the backend. Scope controls reduce access but do not prevent credential compromise, misuse within a granted scope, configuration errors, or unauthorized actions by an otherwise authenticated caller. The key expires and does not refresh (re-run device-login when it does; the CLI prints the expiry). A bare device-login at a terminal first prints what it is about to do and asks to confirm; declining exits 12 (user.cancelled) having created nothing. The prompt is bare-invocation only — any flag (including --yes) skips it — and never appears for --json, a pipe, a sidecar, ordinary non-tty CI, or a redirected stderr. Automation that allocates a pty for all three streams and runs the command bare is indistinguishable from a human and is prompted; pass --yes. Polling stops at the pairing's expiry or 15 minutes, whichever is earlier; the CLI prints that deadline before it starts waiting. If the window closes with nobody approving, that is auth.device_pairing_timeout / exit 3 — no credential was issued and nothing was written locally; re-run to get a new verification code. (Should you approve in the browser just as the window closes, the pairing can still mint a key that never reaches the CLI, so the message points you at the web dashboard to review and revoke it.) With a sidecar configured, pairing initiation also appends a sidecar-v2 type: "device_authorization" event containing verification_url, verification_code, and polling_deadline_at, so a supervisor need not parse stderr.
otp-login
Terminal-only Email-OTP path for headless / scripted shells. Works with any account email — external domains included, not just @allscale.io (the account must already exist — sign up in the web app first). Sends the code and reads it in the terminal (flag / env / stdin / interactive prompt), then mints the same scoped agent API key with the scopes passed via --scopes (required — there is deliberately no full-permission default).
otp-send
Granular: send the email OTP only (login only, any account email). Returns the otp_id; pair with otp-login --otp-id <id> --otp <code> for scripted flows.
whoami
Show the current authenticated identity. No network call. Agent-key sessions (the normal case) print the key-mode identity from the stored bundle: credential: "agent_key", business_id, user_type, scopes, and the key expiry.
scope
Show the capabilities granted to the current CLI session (chosen on the browser approval screen at device-login, or via --scopes on otp-login). Scopes are checked by the backend for each request. Scope controls reduce access but do not prevent credential compromise, misuse within a granted scope, configuration errors, or unauthorized actions by an otherwise authenticated caller; the key expiry is shown. No network call.
logout
Drop cached session and store credentials for a profile. A bare logout at a terminal first prints what it is about to delete and asks to confirm; declining exits 12 (user.cancelled) having removed nothing. That prompt follows the same rules as device-login's (bare invocation only, --yes or any flag skips it, never for --json / pipe / sidecar / non-tty CI / redirected stderr; a bare run under a full pty is prompted). A bare logout requests a full plaintext + OS-keychain sweep and exits non-zero after file cleanup if the keychain cannot be inspected; --insecure-storage performs an explicit file-only cleanup and never probes keychain. JSON output keeps the legacy session-token-only removed boolean and adds removal_status: removed when any session or store credential was deleted, nothing_to_remove only when a complete sweep found none, or skipped when a file-only sweep deleted nothing because keychain inspection was intentionally skipped.
Authorization model
One page, no archaeology:
The current CLI auth flows issue a scoped agent API key. Both
device-login(browser approval) andotp-login(terminal OTP, for headless/CI) mint one. There is no cookie mode and no full-permission default — every key carries an explicit scope set.Scopes are
<category>:<tier>strings (e.g.invoice:read_only,claim_link:all), chosen on the browser approval screen (device-login) or via--scopes(otp-login). Requested scopes are clipped to your account's role at mint; the granted set is echoed back and recorded locally (allscale scopeshows it without a network call). The backend checks the set on each request; an out-of-scope call is expected to fail with exit 6. Scope controls limit permitted call categories but do not verify the caller's authority, purpose, accuracy, legality, or safety. Both commands check the values locally first: an unrecognised<category>:<tier>string fails withinput.invalid(exit 2) before anything is sent — no OTP, and no pairing session — so a script can rely on one contract for the flag whichever login it uses.Keys expire and do not refresh. The CLI prints the expiry at login and in
scope/whoami; re-rundevice-loginorotp-loginto mint a new key. Revoke keys in the AllScale web dashboard, then log in again for a fresh one — the CLI has no key-management command (see the Keys section).Some actions stay human-only by design. Two of them are not CLI commands at all — key management lives in the dashboard (see the Keys section) and payout authorization lives in Store Settings → Payout Authorization. The browser-bridge flows (
wallet send,invoice pay) work under an agent key: the human approves in the browser — noteinvoice payneeds two scopes,invoice:allto create, report and confirm the pay intent andwallet:allto register the withdrawal (the same broker op aswallet send; it also covers the payer-address lookup, since:allimplies:read_only). Neither LOGIN flow grants either write tier by default: ondevice-loginthe Invoices toggle starts at Read and the Wallets Allow-changes tier starts OFF, so tick both on the approval screen; withotp-login, pass BOTH--scopes invoice:all --scopes wallet:all.Password login is not a public flow. AllScale Pay Accounts are passkey-only and have no usable password;
device-loginandotp-loginare the two ways in. Accounts specially provisioned with a password authenticate through an internal-only flow that mints the same scoped, expiring agent key (--scopesrequired, no full-permission default).
Discovery
The schema-introspection commands below are opt-in (default off) — enable with ALLSCALE_ALLOW_RAW=1.
operations
List every operation in the schema (filter by --kind, --grep).
describe <op>
Full type info for one operation: args, return type, example document.
Ergonomic wrappers
The CLI's user-facing surface matches the AllScale web app: what users call an invoice is internally a Payment in the backend GraphQL. The invoice * commands below wrap payments / payment / sender_payments / recipient_payments / create_payment / update_payment accordingly.
Important: The financial commands below are available only to eligible users in supported jurisdictions. Execution may be delayed, rejected, suspended, returned, or require additional information to comply with applicable law, sanctions, AML/CTF requirements, fraud controls, network conditions, or service-provider requirements.
invoice list
List invoices for a business. Wraps payments. Defaults --business-id from the JWT. Defaults to the first 50 rows — pass --input '{"limit":N,"skip":M}' to paginate, or --all to request the backend's unpaginated result in one response. Read-only responses remain subject to the CLI's 32 MiB safety limit. Filter with --status / --payment-type (both repeatable — repeated values match any of them) and the creation-date range --from / --to. The range is half-open: --from is inclusive, --to is exclusive, so --from 2026-07-01 --to 2026-08-01 is exactly July and consecutive ranges tile without double-counting a boundary row. Both accept a plain date, a naive datetime (read as UTC), or an offset datetime, and are normalized to the backend's required YYYY-MM-DDTHH:MM:SS.ffffffZ. --to-email <email> narrows to the invoices you issued to that contact — an invoice's contact record belongs to the business that issued it, so on this both-directions view the flag selects the issued half. The email is resolved to an existing contact first, and an email with no contact is a structured input.invalid error (exit 2), not an empty list. On an agent key that lookup additionally needs the contact:read_only scope. --input accepts only the documented QueryInput fields.
invoice get <id>
Fetch one invoice by id. Wraps payment.
invoice sent
Invoices where you are the payer — your business owes the money. Wraps sender_payments (the backend's sender is the payer). Invoices you issued with invoice send are not here; see invoice received. Same pagination defaults and the same --status / --payment-type / --from / --to filters as invoice list. No --to-email: an invoice's contact record belongs to the business that issued it, so your own contacts never appear on the invoices you owe — the flag is refused here with input.invalid rather than answering with a misleading empty page.
invoice received
Invoices where you are the payee — the money is owed to your business. Wraps recipient_payments (the backend's recipient is the payee). Includes everything you issued with invoice send. Same pagination defaults and the same --status / --payment-type / --from / --to / --to-email filters as invoice list; this is the command --to-email fits exactly, since every row here is one you issued.
invoice send
Issue an invoice by email. Resolves --to-email to a contact (auto-creates via --auto-create-contact if missing), renders the invoice HTML body locally, and submits create_payment through the authenticated session. The recipient does not need any prior relationship with you — any valid email can be invoiced — but auto-creating its contact record needs contact:all on an agent key, which the approval screen leaves OFF by default (contact defaults to read-only); the CLI detects a key that can't create contacts and fails fast with the re-login remediation instead of relaying the backend's scope refusal. USDT/USDC totals below 0.10 are rejected before contact or wallet lookup, and --payment-type accepts only its documented values (0 fiat / 1 USDT / 2 USDC) — anything else is rejected locally as input.invalid (exit 2) and never sent. Pass --wallet-id to pin receiving wallets (backend-resolved — works with an invoice-scope-only key), or omit it to auto-select from your wallets via the narrow business_wallets query (needs wallet:read_only on an agent key). No browser ceremony — the payer signs when paying; the inviter doesn't sign at issue.
invoice update <id>
Update an existing invoice. Wraps update_payment.
invoice pay <id>
Pay an invoice where you are the payer, from your AllScale wallet — list them with invoice sent, NOT invoice received. Resolves the invoice's destination + amount, moves the funds via the same signing path as wallet send (the transfer is confirmed in the browser at /cli), then reports and confirms the payment. Needs TWO scopes — invoice:all to create, report and confirm the pay intent, and wallet:all to register the withdrawal (the same withdraw_token broker op as wallet send; it also covers the payer-address lookup via the narrow business_wallets query). Neither LOGIN flow grants either write tier for you: on device-login the Invoices toggle starts at Read and the Wallets Allow-changes tier starts OFF, so tick both on the approval screen; on otp-login pass BOTH --scopes invoice:all --scopes wallet:all, because naming any scope turns every unnamed category off.
transaction list
Paginated transactions. --scope mine|business|activities. Wraps my_transactions / business_transaction_records / activities. Defaults to the first 50 rows; pass --input (mine/business) or --limit N (activities) to override, or --all to request one unpaginated response (still capped at 32 MiB by the CLI). Filter by time with --from / --to in every scope — the same half-open range as invoice list (--from inclusive, --to exclusive, same accepted datetime spellings), applied to created_at under mine/business and to transaction_time under activities. Under --scope business, additionally filter with --direction (INFLOW/OUTFLOW), --transaction-type (PAYMENT, WITHDRAWAL, CLAIM_LINK, …) and --status (NEW/SUCCESS/FAILED/CONFIRMING — transaction statuses, not invoice statuses); these map to business_transaction_records' typed GraphQL arguments, each accepting the enum name (case-insensitive) or a raw integer. The other scopes reject them with the typed alternative they do have (activities takes list-valued directions / transaction_types / transaction_statuses via --input).
transaction get <id>
Fetch one transaction by id.
wallet list
Your noncustodial wallets and per-coin balances via the narrow business_wallets query. Works with an agent key holding wallet:read_only; the owner is derived from the authenticated identity.
wallet send
Withdraw USDT/USDC from your AllScale wallet to an external EVM address. Needs wallet:all on an agent key — that scope is what the broker checks before registering the withdrawal (wallet:read_only alone cannot). Registers a withdraw_token op with the broker, opens the browser at /cli for the passkey + Turnkey signing ceremony, and succeeds only after the browser confirms the on-chain receipt — the receipt-aware result comes back through the loopback listener (a mined revert exits 12, an unconfirmable receipt exits 9). The transfer is always confirmed in the browser; over SSH, open the printed URL on any device and paste the full result JSON back.
Claim links
Important: The financial commands below are available only to eligible users in supported jurisdictions. Execution may be delayed, rejected, suspended, returned, or require additional information to comply with applicable law, sanctions, AML/CTF requirements, fraud controls, network conditions, or service-provider requirements.
Sender-side reads (get / list) need a CLI login and claim_link:read_only under an agent-key session. claim-link create needs claim_link:all: the CLI fixes the amount, stablecoin, chain, claim window, optional sender metadata, and caller-stable idempotency key before registering the create_claim_link broker op. The AllScale-hosted browser approval page shows that immutable intent as a read-only review, then performs creation, signing, and funding after the payer authorizes it; verify the exact AllScale origin before approval. It does not ask the payer to re-enter or edit the payment. Before opening the browser and again after any callback, cancellation, invalid receipt, or timeout, the CLI reconciles the complete intent through claim_link_funding_by_idempotency_key; the browser receipt is advisory. status and claim drive the public receiver-facing REST surface — no login; the bearer claim token IS the credential.
claim-link create
Create and fund a fixed-intent Claim Link through an AllScale-hosted browser approval flow. Verify the exact AllScale origin before approval. Requires --idempotency-key (1–128 trimmed characters), --amount (exact decimal string, 0.1–10000, at most 6 effective decimal places), --chain, and agent-key scope claim_link:all; --stable-coin defaults to USDT and --expires to 14d. --no-browser skips only local auto-open: the CLI still prints the /cli URL and waits for callback/paste. A recovered key must match every immutable field; a pre-browser mismatch exits 2 as claim_link.intent_conflict without opening the browser. Success is emitted only when the authoritative lookup returns the same intent, a validated AllScale claim URL, and either LINK_SENT, CLAIMING, or CLAIMED, or returns EXPIRED or CANCELLED with a validated non-empty funding transaction hash. A signed/broadcast/unknown, still-pending, failed-reconciliation, post-browser intent race, or EXPIRED/CANCELLED result without that funding proof exits 9 as claim_link.funding_ambiguous; follow its retry metadata and never substitute a new key. Only an explicit cancellation before creation plus a successful authoritative miss exits 12 as claim_link.browser_cancelled.
claim-link get <id>
Fetch one of your own Claim Links by id. Wraps claim_link; a non-owned id is indistinguishable from an unknown one. Agent-key scope: claim_link:read_only.
claim-link list
Your own Claim Links, newest first. Wraps my_claim_links. Defaults to the first 50 rows; --input paginates and --all requests one unpaginated response (still capped at 32 MiB by the CLI); --status filters. Agent-key scope: claim_link:read_only.
claim-link status
Public receiver-facing status snapshot for a bearer claim token/URL. Canonical URLs are parsed locally; a same-origin /s/ URL is resolved through one credential-free 302. No login — the token is the credential.
claim-link claim
Claim a link from a raw token, canonical URL, or same-origin /s/ short URL: Path A without AllScale account sign-in with --to <0x address> (no login), or --to-wallet into the authenticated business's AllScale wallet (cookie session). This does not make the transaction anonymous: network, request, compliance, and public blockchain data may still be processed or observable. Only the on-chain-proven claimed outcome exits 0; an expired or not-claimable link exits 12, a token the backend will not resolve at all exits 7 (404), while pending/unproven payouts exit 9 and must be reconciled before another claim.
Store
store create
Create a store for the logged-in business (no store key, no browser ceremony). Wraps create_store. Defaults --business-id from the session token — the JWT user_id for cookie sessions, or the owner business id stored at login for agent-key sessions (re-run device-login if an older agent-key login has none stored); --live creates a live store (default is sandbox). Returns the API key and an API secret that is shown only once on stdout (it cannot be retrieved later — capture it now; the credential itself stays valid). This command does not enroll the store for Payout. Business must have checkout enabled; only one sandbox store is allowed per business.
Payout
Important: The financial commands below are available only to eligible users in supported jurisdictions. Execution may be delayed, rejected, suspended, returned, or require additional information to comply with applicable law, sanctions, AML/CTF requirements, fraud controls, network conditions, or service-provider requirements.
Authorization is set up in the dashboard, not here. Enabling auto-payout, changing its limits, and turning it off all complete with a passkey in a human web session — a credential this CLI's logins never mint. Do those in Store Settings → Payout Authorization. The CLI reads the resulting session and spends from it.
payout status
Read the current claim-link auto-payout session, limits, remaining budget, and expiry.
payout send
Create and synchronously fund a claim link using a live, Payout-onboarded store key. --reference-id is required for idempotent retry. Store HMACs go only to the standalone partner API at https://openapi.allscale.io, which is the default — --payout-api-base / ALLSCALE_PAYOUT_API_BASE exist to set it explicitly, not because you need to change it.
Keys
Agent API keys are managed in the AllScale web dashboard: Settings → Security → Sessions · Agents & Devices. It lists every credential and offers per-key revoke plus a revoke-all kill-switch. Creating a key stays where it always was — the login flows below — and rotation is revoke-then-log-in-again; there is no separate rotate control.
The CLI mints a key for its own session when you log in (device-login / otp-login) and prints its scopes and expiry via allscale scope / allscale whoami. It does not provide key administration: those are control-plane actions that the current backend is designed to reject for an agent-key session, which is the credential type issued by the current CLI login flows.
Configuration
Tokens: stored in the OS keychain (macOS Keychain / Windows Credential Manager / libsecret) by default. On POSIX, pass
--insecure-storage(or setALLSCALE_INSECURE_STORAGE=1) to force a plaintext file at~/.allscale/credentials.json(mode 0600) instead of the keychain — it takes precedence and is intended for headless / CI / agent use where a keychain is unavailable. Plaintext credential storage is not supported on Windows in the current implementation because Node's POSIX mode APIs cannot reliably verify owner-only ACL/link safety; use Windows Credential Manager instead. Without the opt-in, the CLI is designed to return an error rather than silently downgrade when the keychain is unavailable.ALLSCALE_NO_KEYCHAIN=1makes the CLI treat the keychain as unavailable at runtime and is designed not to probe it.Store credentials:
store createreturns the one-time secret on stdout and does not persist it. Historical credentials in the OS keychain or~/.allscale/store-credentials.jsonare still removed bylogout; the current implementation does not write them to config.toml.Logout confirmation: a bare
allscale logoutat an interactive terminal states what it will delete and asks for confirmation first; declining exits 12 (user.cancelled) with nothing removed. The prompt is bare-invocation only — any flag (including--yes) skips it — and never appears for--json, a pipe, a sidecar, ordinary non-tty CI, or a redirected stderr. Automation that allocates a pty for all three streams and runs the command bare is indistinguishable from a human and is prompted; pass--yes. Unlikedevice-login, whose prompt shipped with the command,logout's bare form predates this gate, so an existing pty-allocating job that relied on it wiping unattended must add--yes(without stdin it now declines and exits 12, having removed nothing).Logout storage scope:
allscale logoutwith no insecure-storage flag or environment opt-in requests a full sweep of both plaintext credential files and the OS keychain. If keychain access is suppressed, missing, or broken, it still clears the files but exits non-zero because historical keychain credentials may remain.allscale logout --insecure-storage(orALLSCALE_INSECURE_STORAGE=1) is designed as file-only and does not intentionally probe the keychain, to avoid GUI prompts for headless/CI agents. On Windows, an absent plaintext target is treated as a no-op; logout can remove a valid legacy file when the requested profile is its only remaining profile. A shared, malformed, or unverifiable legacy file is expected to remain unchanged and requires deliberate manual cleanup.Profile metadata:
~/.allscale/config.toml(mode 0600) recordsapi_baseanduser_typeper profile. Non-secret.Profiles pin their backend. Logging in saves the backend it used into
~/.allscale/config.toml, and a saved profile beats the built-in default. The CLI does not switch a configured endpoint automatically: stored credentials are bound to the origin used at login.Default backend:
https://app.allscale.io(production). Published builds are configured for their bound AllScale environment. The current implementation accepts a credential-bearing api-base only for exact AllScale origins (plus an explicit loopback origin for development), and validates session credentials against the origin recorded at login.payout senduses a separate approved partner API origin selected with--payout-api-base/ALLSCALE_PAYOUT_API_BASE. Embedded URL userinfo (user:password@host) is rejected. Browser targets must behttps://, orhttp://to loopback only. The current implementation is designed not to follow redirects when a request carries credentials; callers should still verify the destination origin and TLS before approving or transmitting sensitive data.Sidecar event log (opt-in, off by default): set
ALLSCALE_OUTPUT_FILE_PATH=/path/to/run.ndjsonto append a machine-readable NDJSON event stream for the run, orALLSCALE_OUTPUT_FILE_DIRECTORY=/path/to/dirto give every run its own randomly named file in that directory. Events may cover the command, argv fields designed to redact specified authentication secrets, request URLs, responses, refreshes, bridge steps, and errors — intended for agent supervisors and orchestrators that need structured progress without parsing stdout. Redaction is not a guarantee that the log contains no sensitive information.device-loginwrites its short-lived pairing code and URL as the sidecar-v2device_authorizationevent before polling. Treat that event as sensitive and do not publish it to an untrusted log sink.Important: Sidecar logs are designed to redact specified authentication secrets, but may still contain personal, business-confidential, and transaction data. Treat them as sensitive. Do not place them in shared directories or transmit them to third-party agents or support channels without review. Configure appropriate access controls, retention, and secure deletion.
On POSIX systems, the current implementation attempts to create files with mode 0600. A directory selected through
ALLSCALE_OUTPUT_FILE_DIRECTORYis intended to be created owner-only; if it already grants group/other access, the CLI is designed to refuse writing rather than change a potentially shared directory such as/tmp. For an explicit_PATH, the current implementation does not change permissions on an existing parent directory, so choose a private parent yourself; it is designed to refuse an existing output file unless it is owner-only (rotate/remove an older loose file first). Sidecar output is currently disabled on Windows because Node's mode APIs cannot reliably enforce or verify owner-only NTFS ACLs. It is still a file on disk that describes your session: prefer_DIRECTORY, which generates an unpredictable filename, over a fixed_PATHon a shared machine. A_PATHthat is a symlink is designed to be refused rather than written through. The file is append-only with no rotation and is currently capped at 64 MB, after which the sidecar is designed to disable itself for the run with a warning on stderr.
Output contract
JSON to stdout by default. Stable shapes — agents can parse without quoting hacks.
In an agent-key session (no JWT) the shape is key-mode instead:
Errors go to stderr as JSON with stable codes:
Exit codes
Stable contract — branch on these. Every error code the CLI can emit maps to exactly one of them, and a mapping is never renumbered once shipped.
0
success
—
1
generic / unexpected
internal
2
caller-actionable input or local limit — fix the invocation and re-run
input.invalid, storage.unavailable, transport.response_too_large, auth.unknown_profile, claim_link.intent_conflict, signing.key_unavailable
3
network / transport failure, or a confirmation deadline passed (see the retry warning below)
transport.network, transport.timeout, auth.credential_changed, wallet.bridge_timeout, wallet.bridge_op_expired, auth.device_pairing_timeout
4
not authenticated — run allscale device-login
auth.no_token
5
credential expired or malformed — re-authenticate
auth.token_expired, auth.token_invalid
6
not fixable by this invocation: permission denied, or a capability gap
auth.permission_denied, wallet.requires_browser_auth
7
not found — the object you named does not exist
not_found
8
rate limited
rate_limited
9
backend internal, or an ambiguous mutation or transaction result
backend.internal, claim.payout_ambiguous, claim_link.funding_ambiguous, wallet.bridge_invalid_response, wallet.transaction_status_unknown
10
escape hatch disabled — export ALLSCALE_ALLOW_RAW=1
raw.disabled
11
this build's request signature was rejected — upgrade the CLI (re-login does not help)
auth.signature_rejected
12
did not complete; nothing was created, or an on-chain attempt reverted without transferring
claim.not_claimable, claim.expired, claim_link.browser_cancelled, user.cancelled, wallet.transaction_reverted
An exit code is NOT a retry-safety signal. Branch on the
codestring when you need to decide whether re-running is safe.Exit
3is the trap:transport.networknever reached the server, butwallet.bridge_timeoutandwallet.bridge_op_expiredare post-signing — the browser may already have signed and the backend may already have broadcast. Retrying blindly on exit3can double-pay. Exit9mixes a plain 5xx with the*_ambiguouscodes andwallet.transaction_status_unknown(a broadcast whose receipt never confirmed — funds may already have moved), all of which must be reconciled against the original idempotency key rather than retried. Exit12now also carries a post-signing code:wallet.transaction_revertedmeans the transaction was mined and reverted — nothing transferred, but gas was spent — so treat any retry as a new transfer decision.
wallet.bridge_timeoutvswallet.bridge_op_expired. Both mean "no confirmation came back", but they need different follow-ups:
wallet.bridge_timeout— a deadline the CLI itself reached, which is not proof the link died. Which one it was decides what helps, and the message says so: your own--bridge-timeoutrunning out while the link was still valid (a longer one genuinely helps next run); the link's own lifetime running out (a longer one cannot help, and this is the ONLY case the error message names — it tells you the link is expired or about to be, and that a longer wait is useless; the remedy itself comes later in the message, after the check-your-wallet warning); that same lifetime measured against your machine's clock, when the response carried no server timestamp to check it against (same remedy, but approximate — a fast local clock makes the wait short, a slow one makes it long); the fallback used when the server gave no usable expiry at all; or a clamp at Node's ~24.8-day timer limit. Apart from the lifetime case, these share one message, so don't try to tell them apart by parsing it — when the CLI shortens or ignores your value it says which constraint bound the wait in aNote:line on stderr as it starts.
wallet.bridge_op_expired— the server reported the operation behind the link as expired, so the link is definitively dead. Raising--bridge-timeoutcannot help — it is capped at the link's remaining life precisely because the link cannot outlive its operation. A fresh link is the remedy, but check the wallet/transfer first: this error means the browser may already have signed, so the message puts that warning before the retry advice.The link's remaining life is normally measured against the server's clock, not your machine's — the CLI reads the server's own timestamp from the same response — so a wrong local clock cannot shorten your wait or turn a live link into a reported expiry. The CLI's own deadline is set slightly early on purpose, which is why reaching it reports a timeout — saying the link "has expired or is about to" — rather than asserting the link is gone; only the server does that.
One exception, and the CLI tells you when you are in it: if something between you and the server strips the timestamp (some proxies do), the CLI has to fall back to your machine's clock, and says so in the note it prints. That reading is approximate in both directions — a fast clock makes the wait shorter than the link's real life, a slow one can make it longer — so it is treated as a bound, never as a fact, and it is additionally capped at 15 minutes so a badly wrong clock cannot buy an open-ended wait. If your waits look wrong, check the clock (
sntp/timedatectl) before anything else.In both cases check the wallet/invoice first: the browser may have signed just before the deadline.
Exit 7 covers both a REST 404 and a GraphQL response whose backend code means "no such object" — the two paths are deliberately aligned, so allscale transaction get <unknown-id> and a login for an unregistered account both exit 7.
One documented asymmetry, because "not found" does not always describe an object you named: on the login path, "this email has no AllScale account" is exit 7, but a data query failing because the signed-in account has no business is exit 2 — there is no id you could supply to fix the second, so sending you hunting for one would be wrong. Two further backend "not found" conditions are likewise not exit 7 on the GraphQL path: an unknown agent key and an un-provisioned Turnkey wallet sub-org both fall into the generic exit 2 bucket there. (A rejected credential is a different thing and does exit 5, but that comes from a real 401, not from translating a backend "not found" code — so do not branch on exit 5 for these conditions on a GraphQL call.)
Claim links follow the same rule on the sender side: claim-link get exits 7 for a syntactically valid id (24 hex characters) that the backend will not return — unknown, or owned by another business. It answers those two identically on purpose, so a link cannot be probed for existence. An id that is not 24 hex characters is rejected locally as input.invalid (exit 2) before any request is sent, so a malformed argument never reads as a missing link.
claim-link claim, the receiver command, splits along the same seam — the line is "did the backend resolve the token", not sender-versus-receiver:
a token that is empty, or not 16–256 characters of
[A-Za-z0-9_-], is rejected locally asinput.invalid(exit2) and never sent, exactly as a malformed id is onclaim-link get;a syntactically valid token that reaches the backend and resolves to no link the caller may see is a
404and exits7— the same code, and the same anti-oracle reasoning, as the sender side;only a link that did resolve and whose claim attempt was then refused —
expiredornot_claimable, returned as HTTP200with anoutcome— exits12.
So branch on 7 for "no such link" on either command, keep 2 for input you got wrong, and reserve 12 for a real link you were not allowed to claim.
Under --json, stderr is parseable too — for payout send and device-login. On those, human progress prose is suppressed entirely and every stderr line is one complete JSON object: the {error} envelope above is rendered compact in that mode, and notices you may need before the stdout document exists arrive as structured event lines:
Branch on version (currently "1"), ignore an event type you don't recognise, and note that a stderr JSON line is not necessarily an error — check for the error key. Event fields are designed to be scrubbed and length-bounded, so treat them as best-effort: an oversize record degrades to {"version", "event", "output_truncated": true}.
Treat the two commands named above as an allow-list, not a caveat: any other command still writes human prose to stderr, so do not read its stderr as NDJSON. That includes the browser-bridge money paths (wallet send, invoice pay, claim-link create) and the ordinary read and auth commands (scope, whoami, otp-send, otp-login, invoice send, store create). Converting those is tracked separately. The --show-doc debug flag also prints a raw GraphQL document to stderr wherever it is offered; that one is caller-requested and stays that way, so don't combine it with a machine-parsed stderr. stdout stays a single JSON document on every command either way — this rule is about stderr only, and the compact {error} envelope is global.
One deliberate exception to "every failure is an {error} document": a command that cannot run without input, invoked with no arguments at all, prints its help on stdout and exits 0 instead — the same as a bare topic (allscale wallet). Machine callers never take that path. A partial call still exits 2 with input.invalid, and a bare call is treated as machine mode — error document, exit 2 — whenever any of these applies:
stdin and stdout are not both a terminal — a pipe,
$(...), a CI runner, an agent harness, or a terminal script with either stream redirected (allscale wallet send < /dev/null). This is the one signal that needs no opt-in, and it is how most machine callers show up.--jsonALLSCALE_CONTENT_TYPE=jsona configured sidecar (
ALLSCALE_OUTPUT_FILE_PATH/ALLSCALE_OUTPUT_FILE_DIRECTORY)
The last three matter even at a terminal, because none of them appear in argv: --json is an argument so it never reaches a bare call, while oclif resolves JSON mode from its env var before it inspects argv, and the sidecar is pure environment.
The sidecar counts because it is the audit stream a supervisor consumes: a bare call there must record command-end with ok: false, never a success for a command that did not run. Setting the variable is what counts — a sidecar the CLI refuses to open (Windows, a _DIRECTORY that is not owner-only, a stale 0644 file) still means a supervisor is watching, and that is the worst moment to change the outcome, since no stream then exists to contradict the exit code.
Known limit: two terminals are treated as a human, and that is not a proof. A harness that allocates a pty for both streams — expect, a PTY-configured CI job — will get help and exit 0 on a bare invocation. Set any one of the three opt-ins above in those harnesses to restore the {error} / exit 2 contract.
Auto-refresh
Cookie-session tokens are refreshed automatically when the backend returns 401 — single-flight per process (concurrent calls share one refresh round-trip). When the refresh token itself is rejected, the CLI exits with auth.token_expired (code 5) and asks the user to re-login. If the profile's credential changes to an agent key while a refresh is in flight, the refresh is abandoned rather than overwriting it and the CLI exits with auth.credential_changed (code 3) — a valid credential exists, so the command just needs re-running, not a re-login.
Agent keys don't refresh. A 401 in agent-key mode means the key expired or was revoked; the CLI surfaces auth.token_expired with a clear "re-run allscale device-login" message and does not attempt the refresh endpoint in the current implementation.
Upgrade-required signal
Requests include a build identifier and an x-allscale-cli-version header. The service may use these values to enforce minimum supported versions, but they are not proof that a client build, environment, or request is uncompromised. If a release is no longer accepted, the CLI exits with auth.signature_rejected (code 11) and directs the user to install the latest version with npm install -g @allscale/cli@latest. If the latest version is already installed and the same code persists, contact AllScale support. When running from source or an internal build without a baked key for the selected destination, configure ALLSCALE_SIGNING_KEY or ALLSCALE_BUILD_PAIRS; if no signing key resolves for that destination, the CLI exits 2 (signing.key_unavailable). Do not rely on this version-enforcement mechanism as an authentication or integrity boundary.
Credential precedence
Highest priority first:
ALLSCALE_TOKENenv var--profile <name>flag → keychain entry for that profile. A name with no entry in~/.allscale/config.tomlerrors withauth.unknown_profile(exit 2) when no credential is found for it and at least one profile is saved. The error names only the requested profile; saved profile names are not disclosed. A typo'd profile is an input error, not a login prompt. (An empty registry is a fresh machine: that staysauth.no_token.)ALLSCALE_PROFILEenv var → keychain entry (sameauth.unknown_profilerule as the flag).Default profile from
~/.allscale/config.tomlOtherwise →
auth.no_token(exit 4)
auth.unknown_profile deliberately shares exit 2 with other input errors (e.g. input.invalid, storage.unavailable); when an exit-2 branch needs to know which one fired, the error.code field is the load-bearing discriminator, not the exit number.
Capabilities
Codegen-driven
operations/describediscovery commands (opt-in escape hatches).Ergonomic wrappers:
invoice,transaction,claim-link,wallet list.Agent-key invoice creation: explicit, backend-resolved
--wallet-ids, or automatic wallet selection via the narrowbusiness_walletsquery; invoice issuance itself needs no browser ceremony.device-login(device-pairing OAuth) /otp-login(terminal OTP for headless shells), andwallet send(Turnkey-direct EVM withdraw through the/clibridge).Current CLI login flows issue scoped agent-key sessions:
device-loginandotp-loginboth mint an agent API key checked against its granted scopes;scopereports the granted set + key expiry.Agent-key sessions store the owner
business_idat login, so--business-idauto-fills (no JWT needed) andwhoamiprints a key-mode identity instead of erroring.Store creation returns its API secret (shown once, not retrievable later) through the structured stdout result.
Security
Report a vulnerability by email to [email protected] — please do not open a public issue. Include the impact, a minimal reproduction (commands, environment, allscale --version), and whether it is exploitable without an AllScale account. We aim to acknowledge within 3 business days and to ship a fix or mitigation for high-severity issues within 30 days, coordinating disclosure with you.
In scope: this package and its bundled code — the loopback browser bridge, token storage (keychain vs. plaintext-file fallback and its file permissions), and argv/URL redaction in the sidecar event log. Out of scope, and best reported to the AllScale platform team through the same address: the backend API and the web app at https://app.allscale.io.
Two values baked into the shipped bundle are not secrets, by design: the request-signing key and the static API bearer. Both are recoverable from any installed copy and are build-identity markers only — they assert "this request came from an official AllScale client build" and are not intended as an authorization boundary. In the current implementation, the backend is designed to authorize requests using the per-user credential — a session JWT, or an agent key whose granted scopes it checks on each request. Extracting either value from the bundle is expected behavior rather than a finding, unless you can also show the backend making an authorization decision from one of them.
Only the latest published version on npm receives security fixes; pin a recent version in CI.
License
Proprietary — @allscale/cli is licensed, not sold, and is not open source. Your use of it, and of the AllScale services it reaches, is governed by the AllScale User Agreement & Terms of Service:
https://www.allscale.io/agreement
That agreement is the whole of the terms — this package ships no separate licence file, and package.json declares UNLICENSED to say there is no open-source grant. Runtime dependencies are installed separately by your package manager and keep their own licenses.
Last updated