Skip to content

Auth, billing, sign-in, doctor, logs

This slice serves customer people working within their own projects and organizations. Human sign-in, project administration and staff administration are distinct authorities. staff commands require separate platform credentials; they are not a repair shortcut for a customer permission denial.

Manage project user authentication: passwordless email links/codes, trusted invites, passwords, passkeys, and auth settings.

  • run402 auth magic-link --email <addr> [--delivery link|code|both] [--redirect <url>] [--intent signin|invite|claim|recovery] [--state <value>] [--locale <tag>] [--project <project_id>] — request passwordless email auth. Link is the wire default; redirect is required for link/both. --locale picks the email language (en, he right to left, ru; anything else English). JSON says accepted:true, preserves message/warnings, and returns opaque challenge_id for code/both without claiming delivery.
  • run402 auth verify --token <token> [--project <project_id>] — exchange a magic-link token
  • run402 auth verify --challenge-id <challenge_id> --code <six-digits> [--project <project_id>] — exchange an email code. Token and challenge/code modes are mutually exclusive; the code is secret and is never retried or persisted by the CLI.
  • run402 auth create-user --email <addr> [--admin <true|false>] [--invite] [--redirect <url>] [--project <project_id>] — service-key create/update auth user
  • run402 auth invite-user --email <addr> --redirect <url> [--admin <true|false>] [--project <project_id>] — create/update user and send trusted invite
  • run402 auth users get --email <addr> [--project <project_id>] — look up a project user (a member or a test user) by email and print { id, email, is_admin, is_test, email_verified_at, created_at }; read-only, service key. Use the id to seed a role table keyed by user id. No match fails AUTH_USER_NOT_FOUND, whose next_actions name how to create the user
  • run402 auth set-password --token <bearer> --new <password> [--current <password>] — change, reset, or set password
  • run402 auth settings [--allow-password-set <true|false>] [--preferred <method|null>] [--public-signup <policy>] [--require-admin-passkey <true|false>] [--allowed-email-domains <csv|none>] [--test-mode <true|false|null>] [--project <project_id>] — update auth settings (requires service_key). --test-mode allows or forbids test sessions (run402 dev --as, r.auth.createTestSession); null returns to the tier default, on for prototype and off for hobby and team. --allowed-email-domains is a comma-separated list restricting hosted Google sign-in to those domains (enforced at token issuance); none clears it; empty = unrestricted.
  • run402 auth passkey-register-options --token <bearer> --app-origin <origin> [--project <project_id>] — create WebAuthn registration options
  • run402 auth passkey-register-verify --token <bearer> --challenge <challenge_id> --response <json> [--label <text>] [--project <project_id>] — verify and store passkey registration
  • run402 auth passkey-login-options --app-origin <origin> [--email <addr>] [--project <project_id>] — create WebAuthn login options
  • run402 auth passkey-login-verify --challenge <challenge_id> --response <json> [--project <project_id>] — verify passkey login and return session tokens
  • run402 auth passkeys --token <bearer> [--project <project_id>] — list authenticated user’s passkeys
  • run402 auth delete-passkey --token <bearer> --id <passkey_id> [--project <project_id>] — delete one passkey
  • run402 auth providers [--project <project_id>] — list available auth providers
  • run402 auth scaffold-roles [--table <name>] [--user-col <col>] [--role-col <col>] [--roles <csv>] [--cache-ttl <secs>] — offline generator: emits a role-table migration + requireRole gate snippet + first-owner bootstrap (JSON out; no project/network). Pipe through jq (e.g. | jq -r .migration).

Machine email-code flow: request --delivery both → retain challenge_id → read the code from email → verify the handle/code pair. The link in the same email remains a one-tap fallback. Credentials expire in 15 minutes and are single-use. challenge_id is public; never put the code, link token, cookie, or returned session tokens in logs/files. A request is accepted, not proven delivered.

Email orgs + Stripe checkouts; pay by card or scale email beyond tier caps.

  • run402 billing create-email <email> — create an email-based organization (Stripe-only, no wallet). Sends a verification email. Idempotent.
  • run402 billing link-wallet <org_id> <wallet> — link a wallet to an email organization for hybrid Stripe + x402 access. Response includes a pool_implications block (organization tier, projects_in_pool_count, org_api_calls_current, org_storage_bytes_current, tier_limits, over_limit) — inspect over_limit before linking a wallet whose usage might push the merged pool past the tier cap.
  • run402 billing checkout <org-id | wallet | email> --product tier --tier <prototype|hobby|team> — start/renew/upgrade a tier lease via Stripe (hobby $5 / team $20; prototype is free on testnet — use run402 tier set prototype, not a Stripe charge). Returns a checkout URL.
  • run402 billing checkout <org-id | wallet | email> --product email-pack — buy a $5 email pack (10,000 emails, never expire). Returns a Stripe checkout URL.
  • run402 billing checkout <org-id | wallet | email> --product balance-topup --amount <usd_micros> — add allowance to the organization. Returns a Stripe checkout URL.
  • run402 billing topup <org-id | wallet | email> --sats <n> [--wait] [--qr <file.png>] [--timeout <seconds>] [--idempotency-key <k>] — top up the allowance over Lightning (lightning-cash-topup): mints a bolt11 invoice for n sats (100–1,000,000) through the platform’s hosted wallet provider and prints it (lightning:<bolt11>, pay from any Lightning wallet). --qr <file.png> also writes the invoice as a scannable PNG (uppercase LIGHTNING: URI) for posting into a chat or showing on a screen. The dollar value is quoted at mint and credited as quoted, exactly once, when the payment lands (also up to an hour after the invoice expires — paid_late). --wait blocks until paid, paid_late, or expired (exit 2 on expired/timeout; a late payment still credits) and prints the receipt. No Stripe, no node; the gateway answers LIGHTNING_TOPUP_NOT_CONFIGURED (503) when no provider is wired — use billing checkout then.
  • run402 billing auto-recharge <org_id> <on|off> [--threshold <n>] — auto-repurchase $5 packs when the allowance drops below threshold. Requires saved Stripe payment method.
  • run402 billing metering [<org_id>] [--enable --cap <usd> | --disable] [--standing-order <on|off>] [--threshold <usd>] [--recharge <usd>] — metered usage under one monthly cap (PATCH /orgs/v1/:org_id/billing/metering); with no flags it prints the metering block (enabled, cap_usd_micros, suspended_reason, month_spent_usd_micros, included, headroom, prices, standing_order). Dollar flags are whole or decimal US dollars (--cap 50 is 50,000,000 micro-dollars); --cap is required the first time (at least $1), --threshold / --recharge at least $5 (defaults $10 / $20). Needs a live hobby or team lease (403 METERING_REQUIRES_LEASE, next action renew_tier) and the billing role or higher; a grant key is refused. --standing-order on needs a saved card (403 STANDING_ORDER_REQUIRES_CARD, next action save_card).
  • run402 billing card setup [<org_id>] [--return-url <url>] — a one-time link to save a card for the standing order (POST /orgs/v1/:org_id/billing/payment-methods/setup). Prints { org_id, setup_url, expires_at }; a person opens setup_url in a browser, and the card is stored by the payment processor, never by Run402 or the CLI. --return-url is a run402.com or localhost URL.
  • run402 billing balance <org-id | wallet | email> — allowance_usd_micros + email_credits_remaining + tier + lease + auto_recharge state (response includes org_id). A wallet/email is resolved to its organization via GET /orgs/v1/lookup?wallet=|?email=; an organization id (UUID) reads GET /orgs/v1/:org_id/billing directly.
  • run402 billing history <org-id | wallet | email> [--limit <n>] — ledger history. Keyed by organization id: a wallet/email is resolved to its org_id first, then GET /orgs/v1/:org_id/billing/history.

Auth: balance/history require SIWX from linked wallet or admin; wallet lookup requires matching SIWX (email lookup staff-only). link-wallet requires body wallet SIWX/admin; checkout/auto-recharge require linked wallet SIWX/admin. CLI signs from the local wallet, so that wallet must belong to the queried/linked/billed org. Non-member/guessed org id -> 403 no existence leak. create-email unauthenticated.

Email packs only activate when the tier daily limit is exhausted AND the project has a ProjectDomain with verified custom email sending (spam protection for Run402-managed mail reputation).

Metered usage. An organization on a paid lease (hobby or team) can turn on metered usage under ONE monthly cap: storage and vault bytes above the lease’s included pools cost a published price per GB-month (prorated by the UTC day), API calls above the included amount a price per million (run402 projects quote, the metered block), charged daily from the allowance and never past the cap in a UTC month. The same cap bounds what the card standing order may charge: when the allowance falls below the threshold the card refills it by the recharge amount, under the cap. When the allowance or the cap cannot cover a day, metering suspends: nothing is deleted, everything keeps serving, the flat pool applies again and new writes past it are refused with fund_allowance / raise_metering_cap / save_card next actions. Under metering the function and secret count ceilings apply per project instead of pooling. run402 tier status carries the block as metering, and on a metered organization pool_usage.*_limit is the effective headroom while pool_usage.*_included carries the tier’s amounts. Four owner events: metering_cap_nearing (80% of the cap, once a month), metering_suspended (suspended_reason: allowance | cap | lease), metering_resumed, standing_order_failed (three declines disable the order). Ledger kinds: metered_usage (debit, metadata: { dimension, quantity, unit, usage_date }) and standing_order_charge (credit).

Signers — an Ethereum wallet Run402 keeps for your project, for sending money and signing smart-contract transactions. Its key is created in and never leaves the platform’s key custody. Pricing: $0.04/day rental + $0.000005 per call. Signer creation requires $1.20 in allowance (30 days of rent). Non-custodial — see https://run402.com/humans/terms.html#non-custodial-kms-wallets.

  • run402 contracts provision-signer --chain <base-mainnet|base-sepolia> [--recovery-address 0x...] — provision a signer ($0.04/day wallet rent, requires $1.20 in allowance at creation). Prompts for confirmation if the project already has ≥1 active signer.
  • run402 contracts get-signer <signer_id> — get signer metadata + live native balance + USD value (free)
  • run402 contracts list-signers — list all signers owned by the project, including deleted ones (free)
  • run402 contracts set-recovery <signer_id> [--address 0x... | --clear] — set/clear the optional recovery address used for auto-drain on day-90 deletion (free)
  • run402 contracts transfers create <signer_id> --to 0x... --amount <base units> [--asset usdc] [--chain <base-mainnet|base-sepolia>] [--idempotency-key <k>] — send a token from the signer with no gas on it: Run402 signs the EIP-3009 authorization with the signer’s key and its facilitator settles it and pays the gas (one signing fee, no gas). --idempotency-key works as on call; a transfer that failed before settlement re-executes under its key, one that died in settlement never does; status: "unknown" is finished by the reconciler, poll it.
  • run402 contracts transfers get <signer_id> --transfer <transfer_id> — one transfer (status, tx hash, nonce, failure phase)
  • run402 contracts transfers list <signer_id> — the signer’s transfers, newest first
  • run402 contracts set-alert <signer_id> --threshold-wei <n> — set the low-balance alert threshold (free; alerts billed via existing email infra)
  • run402 contracts call <project_id> <signer_id> --to 0x... --abi <json> --fn <name> --args <json> [--value-wei <n>] [--idempotency-key <k>] — submit a contract write call (gas at-cost + $0.000005 signing fee). Returns { call_id, tx_hash, status }.
  • run402 contracts deploy <project_id> <signer_id> --bytecode 0x... [--chain <base-mainnet|base-sepolia>] [--value-wei <n>] [--idempotency-key <k>] — deploy a contract from the signer (gas at-cost + $0.000005 signing fee). --bytecode is the full creation calldata (creation bytecode + ABI-encoded constructor args, concatenated client-side via viem/ethers; ≤ 128 KB). Returns { call_id, tx_hash, status, contract_address } — the deterministic CREATE address is known synchronously. run402 does NOT compile Solidity.
  • run402 contracts read --chain <chain> --to 0x... --abi <json> --fn <name> --args <json> — read-only contract call (free, no signing, no billing)
  • run402 contracts status <call_id> — get call status, gas used, gas cost USD-micros, receipt, error (free)
  • run402 contracts drain <signer_id> --to 0x... --confirm — drain native balance to a destination address (gas at-cost + $0.000005 signing fee). Works on suspended signers — the safety valve.
  • run402 contracts delete <signer_id> --confirm — schedule the signer’s key for deletion (7-day window). Refused if balance ≥ dust — drain first.

Stripe payments for your app, on the organization’s OWN Stripe account: the organization that owns the project connects its account to Run402’s platform once, your app takes card and SEPA Direct Debit payments (one-off or recurring) through Checkout Sessions on it, and the money events arrive on the project’s events feed. Run402 charges nothing on the account (zero platform fee), never holds the money, and your app never holds a Stripe key. Every command resolves the project from --project or the active project; auth is control-plane auth (wallet, sign-in session, or grant key with project:write; project:read for the reads) or the project’s service key over HTTP.

  • run402 stripe account connect [--country <iso>] [--business-type company|individual|non_profit] [--business-name <name>] [--support-email <email>] [--statement-descriptor <text>] [--email <email>] [--project <project_id>] — connect the organization’s account, or return the one it already has (idempotent per organization). Prefill is optional; Stripe collects the rest during hosted onboarding. While onboarding is incomplete the output carries a one-time onboarding.url for the account holder, printed exactly once and never cached: hand it to the person, then run status.
  • run402 stripe account status [--project <project_id>] — the account read fresh from Stripe: status (onboarding | active | restricted | disconnected), charges_enabled, payouts_enabled, requirements.currently_due[], capabilities (card_payments, sepa_debit_payments), next_actions[]. Never prints a link.
  • run402 stripe account onboarding-link [--return-url <url>] [--refresh-url <url>] [--project <project_id>] — mint another one-time Stripe-hosted onboarding (or update) link; URLs must be HTTPS on one of the project’s own hosts or the console (422 STRIPE_RETURN_URL_NOT_ALLOWED names details.allowed_hosts[]).
  • run402 stripe account disconnect --confirm [--project <project_id>] — detach the account from Run402. The Stripe account itself is unaffected (stripe_account_unaffected: true); existing subscriptions keep billing on it, but Run402 stops receiving their events. --confirm required.
  • run402 stripe checkout create --mode subscription|payment (--price <price_id> | --price-data <json>) --success-url <url> --cancel-url <url> [--quantity <n>] [--customer <cus_id> | --customer-email <email>] [--client-reference-id <id>] [--metadata <json>] [--trial-days <n>] [--locale <code>] [--idempotency-key <key>] [--project <project_id>] — create a Checkout Session on the organization’s account and print { checkout_session_id, url, deduplicated, next_actions }; send the payer to url. --price-data is Stripe’s shape, e.g. '{"currency":"eur","unit_amount":12000,"product_name":"Annual dues","recurring":{"interval":"year"}}' (recurring required in mode subscription, refused in mode payment; SEPA Direct Debit needs eur). --client-reference-id (member:42) is echoed on every event about the session and its subscription. --idempotency-key: the same key with the same body returns the original session (deduplicated: true); a different body is 409 IDEMPOTENCY_KEY_REUSED.
  • run402 stripe checkout get <checkout_session_id> [--project <project_id>] — the session’s outcome from Stripe: status (open | complete | expired), payment_status (paid | unpaid | no_payment_required), customer_id, subscription_id, payment_intent_id, client_reference_id, your metadata.
  • run402 stripe portal create --customer <cus_id> --return-url <url> [--project <project_id>] — open the customer portal for an existing customer; send them to the returned url to manage their subscription and payment method.

Account-state refusals are 403 with next_actions: STRIPE_ACCOUNT_REQUIRED (connect_stripe_account), STRIPE_ACCOUNT_ONBOARDING_INCOMPLETE (details.requirements, complete_onboarding), STRIPE_ACCOUNT_RESTRICTED (details.disabled_reason). A Stripe refusal the caller can act on is 502 STRIPE_UPSTREAM_ERROR (details.code, details.param); Stripe unreachable is 503 with Retry-After; the platform not configured is 503 STRIPE_CONNECT_UNAVAILABLE. The money events (stripe_checkout_completed, stripe_invoice_paid, stripe_invoice_payment_failed, stripe_subscription_updated, stripe_subscription_deleted) and the account events (stripe_account_updated, stripe_account_disconnected) ride the project’s events feed (run402 events list); route them to a function with a notification rule (channel: webhook) or poll the feed.

  • run402 feedback send "<text>" [--project <project_id>] [--handle <handle>] [--return-address <how to reach you>] — --project + message: "promote: yes" relays a deploy’s promotion consent (see “Finish a deploy: hand your human two links” above), and a promote: message without --project is refused VALIDATION_FAILED; --handle is the human’s X/Twitter handle (≤64 chars), delivered as-is; --return-address (an email, a handle, a URL; at most 256 characters) tells the Run402 team how to contact you back, since there is no inbox to read. The output carries next_actions with register_contact when Run402 holds no verified email for your organization (run402 agent contact --name <name> --email <email>).
  • run402 agent contact --name <name> [--email <email>] [--webhook <url>]
  • run402 agent status
  • run402 agent verify-email
  • run402 agent passkey enroll

contact returns email_verification_status, passkey_binding_status, assurance_level. New/changed emails start reply challenge and remain email_pending until owner replies. passkey enroll requires email_verified, emails enrollment link, never prints token.

Owner notifications (audit log, preferences) plus the self-serve Telegram push channel and its routing rules.

  • run402 deliveries list [--type <event_type>] [--since <iso>] [--limit N] [--after <cursor>] — paginated delivery-attempt audit log.
  • run402 deliveries get <notification_id> — one audit row.
  • run402 contacts preferences / preferences set <key>=<value> ... — read/update cadence, per-class toggles, webhook_url, locale, timezone. Cross-wallet effects need email_verified; webhook_url changes need operator_passkey.
  • run402 contacts test [--source app|platform] [--type <event_type>] [--severity <level>] — fires a real synthetic event through the FULL pipeline (email/webhook AND Telegram routing rules), rate-limited to 1/min. Omit the flags for the default sample event (severity notice), or set them to exercise a specific rule’s filters. The response’s telegram.destinations[] reports one delivered/failed outcome per matched Telegram binding — empty when no rule matches (not an error) — and telegram.skipped_rules[] names each rule that did not send and why: below_min_severity, deferred_to_window (with next_window_closes_at), or escalation_not_exercised (a test never pages the ladder).

Telegram channel (notification-channel-routing-telegram):

  • run402 contacts connect telegram [--label <name>] — creates a PENDING binding, prints connect_url (private chat) and connect_group_url (group chat) — single-use, 15-minute deep links — then POLLS contacts list until the binding flips to active or the code expires, printing progress to stderr. Requires operator_passkey assurance AND a VERIFIED owner email (bindings are addressed to it). Until the platform’s dedicated bot is provisioned on this gateway, this returns 503 TELEGRAM_CHANNEL_NOT_CONFIGURED with a next_actions entry — printed verbatim.
  • run402 contacts list — every channel (email, webhook, and every live Telegram binding) for the authenticated wallet.
  • run402 contacts rm <binding_id> — requires passkey assurance (operator_passkey); a missing/already-revoked/foreign binding id all return the same not-found error (no existence oracle).

Routing rules — one rule routes to exactly ONE Telegram binding; every match dimension you set (--project, --source, --type, --class) is ANDed, and an OMITTED dimension is a wildcard. --type/--class accept comma-separated lists (matches ANY listed value). No rules = no Telegram traffic — Telegram is opt-in per event, per rule; the mandatory email floor (security/recovery/billing_critical/destructive_lifecycle/verification classes) is completely untouched by rules and can never be silenced by one.

  • run402 subscriptions add --binding <binding_id> [--project <project_id>] [--source app|platform] [--type a,b] [--class a,b] [--min-severity info|notice|warn|critical] [--group-by a,b] [--window <seconds>] — requires passkey assurance (operator_passkey); an unusable/foreign telegram_binding_id returns the same 404 as a nonexistent one.
    • --min-severity filters on the severity the emitter declared. Without it, a rule that names no --type carries notice and above (routine traffic stays in the feed) and a rule that names types carries everything of those types.
    • --window <seconds> (60-86400) sends ONE message per closed wall-clock window instead of one per event: the total, a line per --group-by value with its count (ten, then a summary), three samples, and a feed pointer. --group-by needs --window; a rule keeps at most 200 group values per window and counts the rest under (other). The feed still holds every event.
  • run402 subscriptions add --escalate-to-level <n> --project <project_id> [--min-severity critical] [...filters] — instead of a chat, a matching event raises on the organization’s escalation ladder (mandatory pages, one-tap ack, deadman climb); a repeat while it is open joins it (attached_count) instead of paging again. Needs owner authority on the project’s organization (403 OWNER_REQUIRED). Bounds: 20 raises per rule per UTC day; refusals are counted on the rule (escalation_refused_count).
  • There is no count or rate condition: the rule filters on severity and reduces with a window, and a field the rule shape does not have is refused (400 INVALID_RULE_FIELD).
  • run402 subscriptions list — the person’s routing rules.
  • run402 subscriptions rm <rule_id> — requires passkey assurance (operator_passkey).

Full rule-model explanation: run402 contacts --help / run402 subscriptions --help.

buzz notifications — route project events into a Buzz channel

Section titled “buzz notifications — route project events into a Buzz channel”

Push selected project events into a Buzz community channel as signed NIP-29 messages. A route is an explicit, owner-declared destination: one ACTIVE community installation, 1–50 named projects (future or transferred-away projects never join automatically), reviewed event filters, one channel. The workflow is configure → authorize → test → live, and every command here has zero spend impact.

  • run402 buzz notifications scope <buzzper_id> --org | --listed [--project <project_id> ...] — switch the route between the whole organization (project_scope: org: every project it owns, present and future, read fresh per event so a transferred project drops out; no list to maintain) and an explicit list. The stored list survives a switch to --org; --listed on a route with nothing stored needs --project. configure --all-projects creates a route org-wide from the start.
  • run402 buzz notifications projects <buzzper_id> --add <project_id> | --remove <project_id> — widen or narrow a listed route’s explicit project scope (a PATCH at the route’s current revision). A listed route names its projects: a project an agent provisions later is not routed, and its deploys, crashes, and pages do not reach the channel, until it is added — or the route is switched to --org. Only events occurring from then on are delivered; nothing is replayed.
  • run402 buzz notifications on-call <buzzper_id> --agent <hex-pubkey> [--name <display>] | --clear — set or clear the Buzz agent this route pages. A crash (error_fingerprints_observed) or platform_incident on a routed project is posted by the project bot as @<name> please investigate: … with a p mention of that agent: the @name text is what Buzz renders as a mention chip, the tag is what wakes a managed Buzz agent. --name supplies the name; without it the gateway reads the agent’s Buzz profile (best effort — a silent relay leaves the page tag-only). The failing release’s deployer is addressed instead when it holds a public Buzz identity link. --clear empties it (the route pages nobody). The command reads the route’s current revision and PATCHes at it, then prints the one pubkey the agent must allow: a page is signed by the installation identity (not the project bot, which keeps deploy credits and receipts), and a managed Buzz agent answers only its owner by default, so add that pubkey to the agent’s respond-to allowlist (or set respond-to to anyone) and restart it. One entry covers every routed project.
  • run402 buzz notifications configure --org <uuid> --installation <buzzci_id> --name <route_name> --channel <nip29-channel-id> --project <project_id> [--project <project_id> ...] [--event-type <t> ...] [--event-class <c> ...] [--include-org-events] [--on-call <hex-pubkey> [--on-call-name <display>]] — create the route. --on-call names the agent the route pages from the start, --on-call-name the @name it is addressed by (see on-call above). --include-org-events also delivers the organization’s own events into the channel — the platform_payment_received receipt after a Lightning top-up or tier purchase — on top of the named projects’ events; without it a route is strictly project-scoped. The response’s authorization block is either authorized (live now) or pending_buzz_authorization with the ONE exact, non-secret step, printed loudly to stderr: a Buzz community owner or admin adds the notification_pubkey as a relay member (the approving human’s own Buzz key lives in Buzz Desktop → Settings → Profile → Identity). Only three reviewed event types are routable (deploy_activated, error_fingerprints_observed, platform_incident); the classes security / billing_critical / destructive_lifecycle / verification / recovery may NEVER be routed. Omitting a filter routes everything registered; an explicit empty filter is rejected — never a wildcard, never a silent match-nothing.
  • run402 buzz notifications status [--org <uuid> | <buzzper_id>] — the org’s routes, or one route’s honest health (derived from route + credential state, never queue emptiness) with per-status delivery counts and the revision an update must echo.
  • run402 buzz notifications test <buzzper_id> [--wait] — re-checks the Buzz-side membership (activating a pending_authorization route when the member-add landed — the test doubles as the authorization poll) and queues ONE signed test message (202, queued-not-delivered). --wait polls until it settles; on timeout it prints the still-queued delivery and exits 2 — the publisher tick runs ~every 60s, so silence is cadence, not failure.
  • run402 buzz notifications deliveries <buzzper_id> [--limit <n>] [--cursor <c>] [--delivery <buzzped_id>] — keyset newest-first history, dead letters included, the signed envelope never. Retries back off 1m/5m/30m/2h/12h to 8 attempts or 48h, then dead_letter; retried publishes are byte-identical, so the relay converges on one Nostr event id.
  • run402 buzz notifications pause|resume <buzzper_id> — pause stops matching NEW events (nothing is retroactively delivered on resume); resume re-arms and resets the hard-failure counter (ten consecutive hard failures auto-pause with pause_reason: delivery_failures and fire the mandatory buzz_route_auto_paused owner notification).
  • run402 buzz notifications rotate <buzzper_id> — stages the NEXT signing generation; the current key keeps signing until the next pubkey’s own Buzz-side membership verifies. No command in this group ever accepts or prints a signing secret — notification_pubkey + signing_generation are the only credential material on the wire.
  • run402 buzz notifications revoke <buzzper_id> — cancel queued deliveries and keep sanitized history readable; the installation’s notification credential is destroyed only when this was its LAST live route.

Routes deliver NEW events only (the outbox floor is captured at creation), and Buzz is never a deadman channel: mandatory owner notifications keep their human paths (email, Telegram) regardless of route state, and a Buzz delivery acknowledges nothing. Auth: org owner + fresh step-up (a SIWX wallet is inherently fresh); every mutation is idempotent (--idempotency-key, auto-generated when omitted).

  • run402 webhook-secret rotate — new HMAC signing secret for the notification webhook, returned EXACTLY once (store it immediately). Previous secret remains valid for 24h (dual-secret grace window). Requires passkey assurance (operator_passkey; a run402 login session satisfies it).

A person signs in; an agent’s wallet is a separate identity (run402 status shows the wallet’s own organization state). There is one sign-in session, bound to the person’s principal and graded by how it was minted: loopback (run402 login, full and step-up-able), device (run402 login --device, read-only), browser (the console). A device-grade session reads everything a member may read; every write answers 403 SESSION_READ_ONLY naming run402 login.

Wallet-less writes (write approval): wallet agents use SIWX. A person without a wallet uses the sign-in session; provisioning (org.project.create), deploying (project.deploy), and secret writes (project.secret.write) also need a passkey-signed write approval for one (action, target), sent as X-Run402-Write-Approval. Missing or mismatched approval → WRITE_APPROVAL_REQUIRED / WRITE_APPROVAL_SCOPE_MISMATCH / WRITE_APPROVAL_BINDING_MISMATCH (403) or WRITE_APPROVAL_SESSION_INVALID (401); the SDK raises WriteApprovalRequiredError (isWriteApprovalRequired()) with the exact run402 approve … command and an approve_write next action. Approval is never ambient for MCP/CI/non-TTY; only an interactive TTY auto-opens the browser and retries once. A write approval never satisfies step-up.

  • run402 login [--no-open] — loopback-PKCE (RFC 8252) sign-in. Starts a 127.0.0.1 listener, opens the browser (TTY) for the passkey ceremony, exchanges the code at POST /agent/v1/control-plane/cli/token, and caches the loopback-grade session at {base}/control-plane-session.json (0600; the token is never printed). Signing in again is the step-up for STEP_UP_REQUIRED. Stdout { logged_in, grade, read_only, principal_id, amr, expires_at, expires_in_seconds, memberships? } — memberships[] includes invites auto-redeemed at sign-in when the lookup succeeds. Browser sign-in flows are SDK/console-side (r.session.*).
  • run402 login --device [--no-open] — RFC 8628 device authorization for a terminal with no browser: POST /agent/v1/control-plane/cli/device prints a URL and user code to stderr; approve it in the console (which needs a sign-in within the last 5 minutes, else RECENT_SIGN_IN_REQUIRED); the CLI polls …/cli/device/token and caches a device-grade (read-only) session. Errors: LOGIN_DENIED, LOGIN_EXPIRED, LOGIN_TIMEOUT.
  • run402 whoami — GET /agent/v1/whoami: the principal, memberships, and session ({ grade, amr, amr_times }, or null when the active wallet reads), plus write_approvals[] cached on this machine (action, org_id, project_id, expires_at; never the token). With a live cached session it reads as that session; a session the gateway no longer honours is cleared and answers SESSION_INVALID.
  • run402 logout — revokes the session (POST /agent/v1/control-plane/session/revoke), then deletes it and every cached write approval. Idempotent; the local cache is cleared even when the revoke fails. Stdout { revoked, cleared }.
  • run402 approve --action <capability> (--org <org_id> | --project <project_id>) [--no-open] — mint a write approval for one (action, target): org.project.create + --org, project.deploy + --project, project.secret.write + --project. Requires run402 login (a device-grade session cannot approve: SESSION_READ_ONLY). Opens POST /agent/v1/control-plane/write-approval/challenges, the passkey confirm page redirects to the loopback, and …/write-approval/cli/token mints the token, cached at {base}/write-approvals.json (0600) bound to (api_origin, sign-in session, action, target); 30 minutes idle, 4 hours at most. Approvals for different targets coexist; login and logout clear them. projects provision and deploy run it for you on an interactive TTY. Errors: LOGIN_REQUIRED, APPROVE_FAILED, APPROVE_BAD_CONFIRM_URL.
  • run402 orgs list — your orgs joined with the account overview (GET /agent/v1/me/overview: tier, lifecycle, lease, quotas, allowance, wallets, advisories); see the orgs slice.

The session is principal-scoped and cached once at the base config dir, independent of --wallet. No MCP login by design: an MCP host holds no browser, and a person’s session must not become the agent’s ambient authority; MCP whoami reports the grade.

Public service-level status. No wallet, no auth, no keystore required — works on a fresh install. Reports on the Run402 service (uptime, capabilities, operator); for your organization state use run402 status.

  • run402 service status — public availability report (24h/7d/30d uptime per capability, operator, deployment, schema run402-status-v1)
  • run402 service health — liveness check with per-dependency results (postgres, postgrest, s3, cloudfront) and service version

SSR origin-cache inspection + invalidation. Capability ssr-isr-cache (paired with @run402/astro v1.0+). Cache is scoped per-project; cross-project hosts return R402_CACHE_INVALIDATION_HOST_FORBIDDEN.

  • run402 cache inspect <url> [--locale <code>] [--release-id <release_id>] — read the cache row state for a URL. Stdout is JSON: { status: "HIT" | "MISS", host, path, locale, releaseId, cachedAt, expiresAt, writtenUnderGeneration, contentSha256, headers }. status is NEVER BYPASS — inspect does not issue a request, so it cannot evaluate runtime bypass conditions. Defaults to active release + default locale; pass --locale / --release-id to inspect non-default rows. (The legacy --json flag is removed — JSON is the default.)
  • run402 cache invalidate <url> — invalidate a single absolute URL. Stdout: { deleted, host, path, generation }.
  • run402 cache invalidate --prefix <p> --host <h> — invalidate all rows under a path prefix on a specific host.
  • run402 cache invalidate --all --host <h> — entire-host purge (catastrophic content changes; nav restructure; layout-wide updates).

Every invalidate returns { deleted, generation, host, path? }. generation is the post-increment per-(project, host) counter — it gates in-flight MISS renders from overwriting after invalidation completed.

Health + config diagnostics. Agent-DX entrypoint — agents run this first to verify environment before attempting anything else.

  • run402 doctor [--verbose] [--refresh] [--no-scan] [--dir <app>] [--manifest <path>] [--scan-dir <D>] [--project <project_id>] [--only <check> ...] — checks: config dir presence, installed CLI update state (cli_update: latest_known, checked_at, comparison, and install_confidence (confidence in detecting the installation method, explicitly labeled by install_confidence_basis: "installation_method_detection"; independent of whether registry versions match); confidence concerns install detection, not version truth), local wallet (wallet) + rail, keystore wallet count, API base reachability, active tier + lifecycle state, notification health snapshot (binding state + per-attempt verification failure detail), recovery posture (per vault-owning org: human-owner login + member source-key backup evidence), vault, source scan (auth-aware-ssr). the cli_update cache has a real 24h TTL (kychee-com/run402#561): a plain doctor call reuses a cache within that window only for the same installed version, and refreshes a MISSING or EXPIRED cache or one recorded before an installation change with exactly one bounded live check automatically — --refresh simply forces that same check even when the cache is still fresh. A failed live check (offline) falls back to the last known-good value rather than reporting a bare unknown, and it is labeled as an estimate, not presented as current: cli_update.value.cache always carries fresh, freshness_basis: "observation_age", age_ms, refresh_attempted, refresh_failed, and last_attempt_at, and a stale fallback’s hint names the estimate’s age and the failure reason. An installed version ahead of the observation has status unknown, with no downgrade advice; --refresh can retry the registry. A within-TTL observation is not proof that an ahead-of-observation installation is current. Stdout is { ok, blocking[], warnings[], checks[] }. ok answers one question — can this agent ship from here — and is true exactly when blocking[] is empty; advisory findings never affect it or the exit code (0 when ok, 1 otherwise), so an unbound passkey or a degraded recovery posture on another org is reported in warnings[] while ok stays true. Every check carries severity: "blocking" | "advisory" | "info" (blocking: config dir or wallet missing/error, API unreachable, tier inactive/frozen/past_due/dormant/missing/error, error-severity source-scan findings; advisory: account_health, recovery_posture, vault, runtime_staleness, retiring_routes, cli_update gaps; info: ok/skipped/unknown). warnings[] is [{ check, code?, message, hint? }], one entry per gap; blocking[] is [{ check, status, message, hint? }]; checks[] is [{ name, status, severity, value?, hint?, message? }]. Agents branch on ok and read warnings[] for non-blocking gaps. The tier check’s status is a fixed vocabulary (ok | inactive | frozen | past_due | dormant | purged | missing | unknown | error, never a tier name) with the tier name and raw lifecycle in value.tier / value.lifecycle; a wallet whose own organization holds no tier but can reach projects on another organization reports missing as advisory (TIER_MISSING_ON_OWN_ORG), not blocking. --only and --refresh produce the same shape. When stale, cli_update.value.next_actions[] includes an upgrade_client action with command, argv, install context, confidence, cwd, and mutation flags. When owner email verification is pending, the doctor surfaces the per-reason hint from email_verification.last_challenge.hint along with attempt_count / remaining_attempts so the person sees what to fix. When the gateway reports reachability.reachable: false on GET /agent/v1/me/status (recovery-event-reachability — the org resolves to zero verified notification recipients across both the wallet-contact chain and org-membership verified emails), the notification-health check warns that mandatory recovery/security notifications currently reach nobody, including how many were already skipped in the last 90 days, with the run402 agent contact --email ... remedy. The recovery_posture check (vault-recovery-custody; rides the same status read) renders one entry per vault-owning org: control_plane_configured: false warns “no human owner with a working control-plane login” with the run402 orgs invite create <org_id> --email … --role owner remedy; source_backup_configured: false warns “no human member holds a working source-access key” pointing at console.run402.com/account source enrollment; custody_legacy_present: true warns that a member key is still single-credential legacy custody (one passkey, no recovery code). These are EVIDENCE levels — “configured” is what the platform verified, never proof an off-platform passkey or saved code still exists — and the same events arrive as org_recovery_posture_degraded / org_recovery_posture_recovered feed events. A gateway without the block reports skipped, and an account with no vault-owning org reports ok with an empty orgs list. The source scan selects only the application at --dir/cwd or the explicit --manifest; without a selected manifest it reports unscoped/skipped. It scans the selected app’s src and explicit referenced source files, excludes sibling apps and nested repositories, and shares its gate with SDK up and deploy. It flags hallucinated SDK auth names (R402_AUTH_UNKNOWN_EXPORT), state-changing GET handlers (R402_AUTH_STATE_CHANGING_GET), auth.* calls in export const prerender = true pages (R402_AUTH_PRERENDERED), and direct mutation of internal.sessions.authz_version (R402_AUTH_AUTHZ_VERSION_PROHIBITED), and warns when adminDb() (the service key, bypassing RLS) appears in a file that reads the current user (SERVICE_KEY_IN_USER_REQUEST; use db(req), or silence with // run402-allow-admin-db: <reason>). The retiring_routes check (same status read, retiring_routes[]) lists each of your projects’ calls to a route being retired in the last 7 days, with the fix: run402 functions rebuild --all --project <project_id> for deployed functions, npm i -g run402@latest for an old CLI or SDK, or the successor URL for your own code; a gateway without the block reports skipped. --no-scan skips the scan (config-only checks). --scan-dir <D> is an advanced arbitrary scan and reports advisory findings without claiming application deploy refusal. --project <project_id> (kychee-com/run402#566) targets the vault check at that project, outranking the repo-standing pin/remote/env/active-project default — scoped to the vault check only, since every other check is wallet/machine-wide. Any flag doctor does not recognize is rejected (UNKNOWN_FLAG/BAD_USAGE), never silently ignored. run402 deploy runs the scan as pre-flight and refuses to deploy on any error-severity finding (bypass with RUN402_DEPLOY_SKIP_SCAN=1); with --dir <build> it scans the Astro app that build belongs to, never the build output. Patterns match code only: names inside comments are not findings, and allow-comments are read from your source. (The legacy --json flag is removed — JSON is the default.)

--only <check> (repeatable, kychee-com/run402#566 remaining half). Runs ONLY the named check(s) and suppresses every other one — INCLUDING the source-tree scan, which on a monorepo can produce thousands of findings that bury a single check’s diagnosis (the original dogfood complaint). A skipped check’s work never runs at all: run402 doctor --only vault costs one vault read, not the full config/tier/account/scan sweep. Composes with --project, which still scopes only the vault check. The valid check names are config_dir, cli_update, wallet, projects, api_reachable, tier, account_health, runtime_staleness, retiring_routes, recovery_posture, vault, source_scan (run402 doctor --help lists them); an unknown name is BAD_USAGE listing the valid set. --only is not used together with --buzz — buzz mode is its own separate, always-complete check set, and the combination is rejected rather than silently ignoring one of the two flags.

Terminal window
run402 doctor --only vault # just the vault diagnosis — no source scan, no monorepo noise
run402 doctor --only vault --project prj_1a2b3c
run402 doctor --only config_dir --only wallet --no-scan

Astro dev wrapper. Loads .env.local, verifies RUN402_PROJECT_ID + RUN402_SERVICE_KEY, then spawns astro dev with the env inherited.

  • run402 dev [--port <n>] [--host <h>] [--project <project_id>] [--as <label>]

SDK calls (db, auth.user, cache.invalidate, assets.put) hit the LIVE Run402 project at https://api.run402.com — no local database, storage, or key-custody setup required. This is shape-parity with production. Offline emulator mode is deferred to v1.5.

Without --as, auth.user() is null in astro dev, so a members-only site renders signed out. --as <label> previews it signed in: before starting Astro it mints a 2-hour test session (r.auth.createTestSession) for the test user <label>@<project_id>.test.invalid and passes it to the dev server in RUN402_DEV_TEST_SESSION; the @run402/astro adapter then runs every request under astro dev as that user, so auth.user() (with is_test: true), auth.requireUser(), auth.role({ from }) and db() (under the user’s row-level security) behave as on the deployed site. Needs RUN402_ANON_KEY in .env.local (print the keys with run402 credentials project-keys export --project <project_id> --reveal) and the project’s test mode (on by default for prototype; otherwise TEST_MODE_DISABLED names run402 auth settings --test-mode true). The label is a test-user label, never an email or user id: no real member can be impersonated. The token stays in the dev server (the browser never holds it or the service key), astro build never includes the middleware, and when the session expires requests are served signed out until you restart run402 dev --as <label> (same label, same user). A dev server Astro is already running for the project keeps the environment it started with, so run402 dev stops it (astro dev stop) before starting a new one and says so; under an AI agent Astro runs astro dev in the background, so stop it with npx astro dev stop when you are done. The banner prints the test user’s id beside its email with a role-row hint; give it a role in your own role table with run402 projects sql like any member (any user’s id: run402 auth users get --email <addr>).

Project scaffolder. Subroute of init (alongside init rail setup).

  • run402 init astro [<dir>] [--force] — creates a deployable Astro project with package.json (dev/deploy scripts), astro.config.mjs (one-line @run402/astro preset), src/pages/{index,[slug]}.astro (the latter demonstrates the full DB-backed dynamic page pattern with cache directive), src/layouts/Layout.astro (title + og + canonical), src/pages/api/save-page.ts (admin save endpoint with db().upsert() → cache.invalidate()), .env.example, .gitignore. Refuses non-empty dirs without --force. Stdout is JSON { dir, files_created, created, next_steps }; progress lines go to stderr. (The legacy --json flag is removed — JSON is the default.)

Fetch function logs by request id. Top-level shortcut — for fine-grained per-function control, run402 functions logs <project> <name> still works.

  • run402 logs --request-id <request_id> [<function> | --function <name>] [--project <project_id>] [--tail <n>] [--since <ts>] [--app|--platform|--all] — fetch the log lines correlated to one request across every function in the project. Reads with the project’s cached service key, or with your wallet / session / grant key (project.read) when none is cached, so an org member or teammate agent can follow an error fingerprint’s logs drill-down without a key. The id is the x-run402-request-id header on every function response (req_…); fnrun_… / fnatt_… durable-run ids work too. No function name is needed: the SDK fans the read out to each deployed function and merges the matches oldest-first, each entry tagged with its function and origin. By default only the function’s own output is shown (--app); Lambda’s runtime lines (INIT_START, START/END/REPORT RequestId, billed duration) are origin: "platform" and hidden — hidden.platform counts them and a hint appears when hiding them left nothing, --platform shows only them, --all shows the raw stream. --function <name> (or a leading positional) narrows the scan; --function alone is that function’s recent tail. --tail (default 100, max 1000) bounds the read per function before the filter. Unknown flags are rejected. Stdout is JSON { ok, request_id, project_id, scanned, entries, errors?, origin, hidden?, hint? }; ok is false only when a function’s read failed (named in errors[] with its code). (The legacy --json flag is removed — JSON is the default.)

When an SSR response returns a 5xx, the response headers include x-run402-error-code: R402_SSR_RUNTIME_ERROR and x-run402-request-id: req_.... Copy the request id and run run402 logs --request-id req_... to fetch the full stack trace. Scans every function in the project in parallel and aggregates entries timestamp-ascending unless --function narrows the scope.


Neither the CLI nor the SDK ships a TypeScript loader. On Node 22.18 or later a TypeScript deploy config that uses only erasable types loads through Node’s own type stripping. A config that needs a transpiler (an enum, an extensionless or .js-spelled import of another .ts file) loads through tsx installed in the config’s own project (npm install --save-dev tsx); without it the command fails with EXECUTABLE_CONFIG_TS_LOADER_UNAVAILABLE and that install as its next action. tsx depends on esbuild, and some npm configurations then warn that esbuild is not covered by allowScripts: its postinstall only verifies its platform binary, which it finds through its optional platform package anyway, so a skipped postinstall does not stop configs from loading. Inspect the install exit code rather than the warning, and keep the user’s script policy intact; do not enable scripts globally. Optional fsevents failures are separate from this warning.