Skip to content

CLI reference

CLI: npm install -g run402@latest SDK: npm install @run402/sdk (typed TS client; same namespaces; Node/Deno/Bun/V8 isolates) Docs URL: https://docs.run402.com/llms-cli.txt API Docs: https://run402.com/llms.txt Operator: Kychee, Inc. Terms: https://run402.com/humans/terms.html Contact: run402 feedback send "your message" (requires active tier)

Run402 = Postgres + REST + Auth + Storage + static & Astro-SSR site hosting + same-origin routes + Node 22 functions + email + image generation behind one CLI.

Run402 is agent-first because agents are first-class participants, not because people disappear. Use your own principal and authenticator rather than a borrowed human account. Identity records who invoked the command; organization roles, grants, delegates, freshness, and spend policy determine what that principal may do. A founder agent may remain owner of its org-of-one.

Agent-critical facts:

  • Atomic full-stack apply: run402 deploy apply --manifest app.json ships DB migrations, site files, function code, secrets, assets, subdomains, i18n, and routes as one transaction; partial failures roll back.
  • No platform token: local allowance (~/.config/run402/allowance.json) signs requests. Per-project anon_key / service_key are runtime data-plane keys (PostgREST/Storage/Functions), permanent, and embeddable/server-side respectively.
  • Agent-paid usage: x402 USDC on Base or MPP pathUSD on Tempo, signed by allowance. Humans may fund via Stripe credits; CLI behavior is unchanged.

Install + deploy:

Terminal window
npm install -g run402@latest
run402 up --name "my-app" -y # validates manifest, bootstraps prerequisites, deploys
run402 apply --manifest app.json --rehearse --json
run402 redeem <code> # only if you were given a promo code — see Promo codes below

up is the agent-first path when the repo has run402.deploy.json or app.json. It is a thin CLI shim over the SDK action runner; the SDK owns manifest validation, project resolution, recursive prerequisites, idempotency-key derivation, and deploy apply. Provision before writing frontend code when you need the real anon_key embedded. prototype is free with the testnet faucet; use hobby / team for mainnet.

For database-bearing deploys, rehearse before commit. run402 apply --manifest app.json --rehearse --json plans, uploads missing CAS bytes, creates a contained branch, applies migrations and checks there, and prints a rehearsal report without touching the source project. If the report passes, re-run with --commit or commit the reviewed plan with run402 deploy apply --require-plan <plan_id>. Manual restore points live under run402 snapshots; contained, expiring data branches live under run402 branches.

CLI update awareness is advisory and fail-open. Normal commands never wait for npm; they use cached update state and keep success stdout as the command payload. Stale notices appear as structured JSON on stderr, or as {"type":"cli.update_available",...} in --json-stream. RUN402_NO_UPDATE_CHECK=1 suppresses notices/checks; CI skips live checks unless RUN402_UPDATE_CHECK=1; run402 doctor --refresh is the explicit bounded live check.

Self-hosted Run402 Core target:

Terminal window
npm install -g run402@latest
run402 init --api-base=http://my-core:4020
run402 projects provision --name "my-app" # returns anon_key, service_key, project_id
run402 deploy apply --manifest app.json # uses the active Core project

init --api-base stores the API base in the active profile (target.json) so the CLI, Node SDK, and MCP use the same target by default. Against Core, projects provision and deploy apply do not require Cloud tier, allowance, or x402 setup. Unsupported Cloud-only manifest slices fail as Core capability errors; they are not silently deployed to Run402 Cloud.

App build scripts should read the same target/profile store through resolveRun402TargetProfile() from @run402/sdk/node, not by parsing target.json or local project-key cache files themselves.

  • Allowance: ~/.config/run402/allowance.json (0600); active project state: profile state.json; local project-key cache: profile credentials/project-keys.v1.json (0600). Legacy projects.json is migration input only.
  • Project keys are cached automatically after provision or fork for operations that truly need anon/service keys. They are not project inventory.
  • <id> in commands = project_id from run402 projects list
  • Output: JSON stdout on success; JSON stderr on failure; exit 0 success, non-zero error. See Output Contract.
  • CLI handles x402 signing; do not request private keys or payment libraries.
  • run402 up is the only compound CLI command. It emits natural JSON with steps[] (no top-level success status). Use --check for local-only validation, --plan for gateway-reviewed intent, and --require-plan for exact reviewed apply.
  • GitHub Actions deploys use OIDC: link once with run402 ci link github; generated workflow calls run402 deploy apply with permissions: id-token: write.
  • Projects, sites, subdomains, forks, functions, secrets, blob storage: free with active tier. Only image generation ($0.03) is per-call
  • Env overrides: RUN402_API_BASE (overrides stored target; default https://api.run402.com), RUN402_CONFIG_DIR (base credential dir, default ~/.config/run402), RUN402_WALLET (active named wallet/profile, default default; alias RUN402_PROFILE), RUN402_ALLOWANCE_PATH (custom allowance file path, default {config_dir}/allowance.json), RUN402_TRACE (any non-empty value: one stderr trace line per SDK request — see Observability below). run402 init --api-base=<url> persists the active target in {config_dir}/target.json or {config_dir}/profiles/<name>/target.json.
  • Wallets: run402 wallets manages named profiles. Select via --wallet <name> (--profile), RUN402_WALLET, or nearest .run402.json binding (commit-safe name only). Precedence: flag > env > .run402.json/.run402.local.json > wallets use default > default. Env/binding conflict hard-fails unless flag passed. default stays at config root; named wallets live under {base}/profiles/<name>/. Non-default active wallet is echoed on stderr and shown in status / wallets current.

Human and agent principals use one common public identity-link resource with a discriminated proof protocol. A principal may have multiple active Nostr subjects; one active subject belongs to only one principal. Links are attribution only. They never change authentication, organization ownership, grants, delegates, spending, deployment authority, or transfer targeting.

For a human account, open https://console.run402.com/identity-links/connect. That normal browser flow requires the direct Run402 session, fresh passkey, explicit public-correlation disclosure, and released Buzz approval UI. It never asks the human to paste a raw event, handle an idlnk_…, or provide a passkey/session/private key. Human link revocation is also browser-canonical. Revoking a public link never removes an organization membership, and removing membership never revokes the link.

The CLI begin/complete ceremony below is for an agent’s Run402 EOA:

Terminal window
run402 identity link nostr begin \
--pubkey <canonical-npub-or-64-lowercase-hex> \
--visibility public > challenge.json
# Publish challenge.json's proof_content as a standalone Buzz kind-1 message.
# Fetch the raw event, preserving exactly id,pubkey,created_at,kind,tags,content,sig.
run402 identity link nostr complete --event-file raw-event.json
# Or: buzz social event --event <event-id> | run402 identity link nostr complete --event-stdin
run402 identity link list
run402 identity link show idlnk_...
run402 identity link revoke idlnk_...

begin uses the active Run402 wallet to EIP-191-sign the exact server payload and prints proof_content; it never handles a Nostr secret. Sign and publish that content through Buzz with buzz social publish --content, then recover the raw seven-field envelope with buzz social event --event. identity link list uses the active CLI identity (agent wallet when present, otherwise the signed-in human control-plane session) and preserves every active/revoked record plus proof_protocol. Do not use the desktop buzz://nostr-bind owner flow for an agent link: it signs as the human Buzz principal.

Terminal window
run402 buzz status
run402 buzz adopt offer --org <org_id> --identity-link <idlnk_id> [--deployment-context-file <json>]
run402 buzz adopt offer show <buzzhao_id>
run402 buzz adopt offer cancel <buzzhao_id>
run402 buzz install --org <org_id> --community <buzz:community:host> --authority <hex-pubkey>
run402 buzz enroll --installation <buzzci_id> --identity-link <idlnk_id> --grants-file <json> --expires-at <ISO-8601>

The status response preserves independent inert skill installation, durable human-adoption offers, completed/attempted human adoption, Buzz-community ↔ Run402-organization installation, and this distinct agent’s enrollment. buzz adopt offer capability-checks before mutation and creates no challenge or authority; its handoff_url is the normal browser/passkey path. --org takes the Run402 organization id exactly as run402 org whoami and run402 projects get return it — a UUID, never transformed. --deployment-context-file takes a JSON object of exactly these five non-empty strings and no others: project_id, release_id, live_url (public HTTPS origin, no credentials or fragment), source_revision, verified_at (ISO-8601, not in the future); the gateway checks them against the org’s active release and its claimed subdomain, custom domain, or deployment host, and a rejection names the offending fields. Poll authoritative state with offer show; a click is not completion. A completed poll reports a terminal consent receipt, public human idlnk_…, and ordinary owner membership separately. The membership alone grants organization authority; link and membership revocation are independent and the receipt remains completed. run402 buzz adopt direct --org … --identity-link …, raw complete, and clipboard/event handling are advanced compatibility paths. Other consent/decision commands are buzz install activate|update|revoke and buzz approve|deny|revoke; buzz install discover --community <buzz:community:host> is the unauthenticated descriptor index. MCP intentionally omits Buzz signing/passkey mutations and renders exact HTTPS/CLI handoffs. JSON is stdout, advice is stderr, every action has zero spend impact, and secret-shaped request fields fail locally. Older gateways fail the offer capability check without mutation and name the advanced direct fallback. Enrollment grants only finite named existing-project scopes and never agent org membership, future-project creation, owner role, delegates, or payment authority. On failure, branch on the stable code and preserve the exact repair field and complete next_actions; retry an unchanged command only when safe_to_retry: true, never through a generic edit fallback.

The CLI rejects --nostr-key, --nsec, private-key, mnemonic, seed, derivation, display-name, label, and signed-label inputs locally before network access. It accepts raw events only through --event-file or --event-stdin, verifies the event id and BIP-340 signature locally, and sends no workspace/channel context. The event must be standalone kind 1 with either no tags or exactly one valid NIP-OA auth tag. Public proof bytes remain available after revocation.

Uniform contract:

  • Success: stdout emits the natural payload, never wrapped; no top-level status.
  • Reads/lists: resource directly, e.g. projects get -> { project_id, public_id, name, ... }, projects list -> { projects: [...], scope?, has_more?, next_cursor? }, credentials project-keys status -> local-cache provenance.
  • Mutations without natural payload: affected ids + boolean action field, e.g. { key, project_id, set: true }, { name, project_id, deleted: true }, { domain, project_id, released: true }; never {}.
  • Local-state reads (status, allowance status): nullable typed fields, e.g. { wallet: null, hint: "Run: run402 init" }; absence exits 0.
  • Raw/text stdout is opt-in only (functions invoke --raw, file-output commands, help/version/dev human surfaces). Machine-readable command defaults emit parseable JSON; for example, allowance export emits { "address": "0x..." }.
  • Failure: stderr JSON envelope with top-level status: "error" + non-zero exit. That sentinel appears on stderr only.
  • Validation commands may exit 0 with payload issues, e.g. validate-expose prints has_errors: true; branch on payload fields.
  • Payload-internal status fields are not envelopes, e.g. doctor.checks[].status.
  • JSON is ALWAYS the default on stdout. --json is a universally-accepted NO-OP: every command takes it, and passing it never changes stdout. Never pass it to “get JSON” — you already have JSON. The one exception is assets put --json, a deprecated alias for --stream (NDJSON progress).
  • Human-readable rendering is an explicit opt-out, spelled --human (run402 up, run402 errors). --human combined with --json is a BAD_USAGE error.
  • cli-output-contract.test.mjs and cli-json-noop-contract.test.mjs guard this; violations are regressions.
  • v3.0 breaking change: success wrapper { status: "ok", ...payload } removed; gate on exit code. Stderr error envelope unchanged.

The SDK’s request kernel (sdk/src/kernel.ts — the one place that touches fetch) carries two always-available diagnostics, for every SDK caller (CLI, MCP, git-remote-run402):

  • RUN402_TRACE — set it to any non-empty value and every request writes one line to stderr: r402 <METHOD> <path> -> <status> <ms>ms attempt=<n>. <path> never carries its query string, and the line never carries headers, bodies, or tokens (the same redaction posture as the payment-attempt journal). <status> is ERR for a request that never got a response (network failure).
  • Per-instance stats — every SDK instance accumulates round_trips, wire_ms (summed), bytes_up, and bytes_down (Content-Length when present, measured otherwise), monotonic for the instance’s lifetime, read via sdk.stats(). Every run402 repos <verb> result and run402 deploy apply’s final result carry this as a stats: { round_trips, wire_ms, bytes_up, bytes_down } block, always — no flag needed. It reflects only the calls that one command’s own SDK instance made; a helper that resolves its own SDK internally (e.g. org/wallet context resolution shared across command families) is not reflected in that command’s stats.
  • -v / --verbose — on repos <verb> and deploy apply, prints one extra stderr summary line with the same numbers (stats: round_trips=… wire_ms=… bytes_up=… bytes_down=…). Coexists with --human.

MCP tool output is markdown, not the stats envelope field — this is a CLI/SDK-edge feature by design.

run402 up [repo-or-path] [--name <name>] [--project <id>] [--manifest <path>] [--dir <path>] [--tier <prototype|hobby|team>] [-y|--yes] [--check|--print-spec|--plan|--require-plan <id>] [--verify] [--propagation-budget-s <seconds>] [--no-propagation-wait] [--json|--json-stream|--human] [--quiet] [--allow-warning <code> ...] [--allow-warnings]

run402 up verify [repo-or-path] [--project <id>] [--manifest <path>] [--dir <path>] [--propagation-budget-s <seconds>] [--no-propagation-wait] [--json|--json-stream|--human] [--quiet]

Use up for a repo-level app deploy when the workspace has a deploy manifest. It discovers run402.json, run402.deploy.json, then app.json under --dir / cwd. A run402.json with the app schema or app-specific markers uses the app-install graph; a release-shaped run402.json uses the same ReleaseSpec normalization accepted by deploy apply. Malformed app manifests return APP_SPEC_INVALID with the failing field instead of an internal JavaScript exception. Validation and filesystem-reference checks finish before any mutation, then the SDK action plan executes.

Project resolution order:

  • explicit --project
  • workspace link .run402/project.json (schema_version: "run402.workspace-project.v1", project_id, optional name, target)
  • manifest project_id
  • approved project creation from --name
  • approved active-project fallback

--name is only project creation/link metadata. It is not part of the deploy manifest, does not select a project when another selector already resolved one, and never renames an existing project. The workspace link is a local convenience file; it is written atomically and skipped in local check / reviewed-plan modes.

Approval and recursion:

  • Non-interactive recursive mutations require -y/--yes; without it the command fails before mutating and returns a structured approval-required error.
  • If allowance/tier/project/workspace link are already configured, plain run402 up runs the requested deploy without -y.
  • In a TTY, the CLI prompts for SDK-planned mutations. In SDK code, pass { approval: "yes" }, { approval: "never" }, or an interactive approval callback.
  • --check returns local validation steps[] without allowance creation, faucet request, tier payment, project creation, workspace-link write, upload, gateway plan, or deploy commit.
  • --print-spec performs the same local validation and prints normalized ReleaseSpec JSON.
  • --plan calls the gateway reviewed-plan mode without upload or commit; it does not provision projects or write workspace links. The response includes a require-able plan_id, plan_fingerprint, expiration, warnings, diff, and next_actions[].
  • --require-plan <plan_id> applies only if the reviewed plan still matches; optional --plan-fingerprint <fingerprint> tightens the check.
  • Run402 Cloud up can create/fund an allowance, ensure a prototype tier by default, create a project from --name, write the workspace link, then apply the manifest.
  • Run402 Core up skips Cloud allowance/tier prerequisites and fails closed if no Core project is selected by --project, workspace link, or manifest.
  • The SDK derives child idempotency keys for recursive gateway mutations from the root action key; pass --idempotency-key when you need a stable external key.
  • Deploy warnings use the same review surface as deploy apply: prefer repeatable --allow-warning <code> and reserve broad --allow-warnings for reviewed exceptional cases.
  • App manifests AND deploy manifests can define verify.http[]. After apply, up fetches those URLs and records per-check status in result.app_result.verification.http[] (app manifests) or result.verification.http[] + a result.verify rollup (deploy manifests; a hard verify failure exits 1). Fresh managed-subdomain or custom-domain misses that carry Run402 edge sentinels (x-run402-edge or JSON codes such as SUBDOMAIN_NOT_CONFIGURED) are treated as propagation, not as permanent verify failure, while the deploy binding is fresh or deploy resolve reports edge_propagation.status !== "settled".
  • --propagation-budget-s controls the wall-clock wait for those fresh edge misses (default 120 seconds). --no-propagation-wait returns immediately with app status: "propagation_pending" and verify.status: "propagation_pending"; the result includes propagation_wait_ms, warnings, next_action, and diagnostic edge_propagation / resolve payloads when available.
  • --verify waits after a successful deploy apply for gateway/edge release coherence and attaches result.edge_coherence plus result.deploy.edge_coherence. It uses the same --propagation-budget-s budget (default 120 seconds), emits deploy.verify.poll progress events, and exits 2 if the report is valid but still not coherent.
  • run402 up verify reruns the manifest HTTP verification (app or deploy manifest) without resource mutation, upload, deploy, or project creation. It resolves the project from --project, .run402/project.json, the manifest project id, then active project, and is the recovery command to run after propagation settles. A manifest without verify.http[] fails VERIFY_CHECKS_REQUIRED.

Output: stdout is the action result, e.g. { "action": "up", "dry_run": false, "target": "cloud", "steps": [...], "result": { "project_id": "prj_...", "manifest_path": "...", "deploy": {...} } }. Stderr carries JSON action-step events unless --quiet.

CLI errors: JSON stderr with outer "status": "error". Run402 JSON bodies may merge into the envelope. Branch on code, not message/legacy error.

Canonical fields:

  • code: stable machine-readable reason, e.g. PROJECT_FROZEN, PAYMENT_REQUIRED, MIGRATION_FAILED, MIGRATE_GATE_ACTIVE. Client-side validation failures (missing flag, malformed JSON) default to BAD_USAGE; specific client-side cases use richer codes e.g. UNKNOWN_FLAG, BAD_FLAG, PROJECT_CREDENTIAL_NOT_FOUND (with details.source: "local_cache"), NO_DEPLOYMENT, NO_ALLOWANCE, BAD_JSON_FLAG, CONFIRMATION_REQUIRED.
  • retryable: the same request may succeed later
  • safe_to_retry: repeating the same request should not duplicate or corrupt a mutation
  • mutation_state: one of none, not_started, committed, rolled_back, partial, unknown
  • trace_id: include this when reporting the issue
  • request_id: routed/function handle; diagnose with run402 functions logs <id> <name> --request-id <req_...>. Distinct from gateway trace_id.
  • details: structured route-specific context
  • next_actions: advisory typed suggestions e.g. authenticate, submit_payment, renew_tier, check_usage, retry, resume_deploy, edit_request, edit_migration, create_project, initialize_wallet, deploy, deploy_site_first, or poll. CLI-resolvable entries carry a literal command, e.g. { "type": "create_project", "command": "run402 projects provision" }. Do not execute route-like suggestions without validating method/path/auth/safety.
  • correlated_platform_incident: present ONLY while an OPEN platform incident correlates with this error’s code{ id: "inc_…", subsystem, status: "ongoing" | "resolved" }, with a poll entry appended to next_actions. It is a CORRELATION, not an exoneration: the platform states it was degraded when your call failed and lets you judge (an app can still cause its own throttling). Poll the events feed (run402 events) and check platform_status before debugging your own code; when the incident resolves, the matching platform_incident feed event carries your project’s real failed-invocation count. Absent when no open incident correlates — never a false confession.
  • Cold-start chain: a fresh agent that knows only run402 deploy apply is walked to a deployed result by following next_actions — no allowance -> run402 init, no tier -> run402 tier set prototype, no project -> run402 projects provision — each step idempotent, then retry the deploy. You do not need to memorize the sequence; follow what each failure hands back.
  • Prefer run402 up when starting from a local repo: it plans and runs that same cold-start chain through the SDK instead of executing advisory next_actions[].command strings.

Retry policy:

  • Retry same request only when retryable: true and safe_to_retry: true; reuse idempotency key for mutations when available.
  • safe_to_retry: true alone means duplicate-safe, not likely-to-succeed. Lifecycle-gated writes, auth token exchanges, and passkey verifies need the indicated action first.
  • run402 deploy apply already handles safe BASE_RELEASE_CONFLICT release races for omitted/current-base deploy specs: it re-plans, emits deploy.retry events on stderr, and stops after its bounded SDK retry budget. Exhausted deploy retries include attempts, max_retries, and last_retry_code in the error envelope. Do not hand-roll this specific retry loop around the CLI unless you intentionally disabled SDK retries upstream.
  • For mutating 5xx with safe_to_retry: false, or mutation_state in committed|partial|unknown, inspect/poll/reconcile before retry. For deploys prefer deploy events/deploy resume over duplicate apply.
  • Lifecycle/payment: PROJECT_FROZEN/PROJECT_DORMANT/PROJECT_PAST_DUE -> projects usage <id> or tier set <tier>; PAYMENT_REQUIRED/INSUFFICIENT_FUNDS -> submit payment/fund allowance.
  • NOT_AUTHORIZED (HTTP 403) is an org-owned-control-plane authorization denial, distinct from auth or payment: the wallet authenticated, but its resolved principal lacks the org role or per-project grant the action needs. details carries required_role / required_capability / reason. Not retryable without obtaining a covering org membership/role or grant; high-stakes ops (delete, transfer-of-ownership, membership change) require an active owner membership. The gateway returns 403 even when the project does not exist (so existence isn’t leaked) — re-check the <id> too. The CLI envelope adds an actionable hint.
  • STEP_UP_REQUIRED (HTTP 403) is a freshness/provenance demand for a high-stakes control-plane op: the session is valid but not fresh enough, or was minted by a read/device-flow path that can’t satisfy a passkey step-up. details carries required_amr / max_age_seconds / challenge_url / reason, plus next_actions[]. The SDK raises a typed StepUpRequiredError (isStepUpRequired() guard). Resolve with run402 operator login --step-up on the same client, then retry. Distinct from NOT_AUTHORIZED (a role/grant gap, not a freshness gap).
  • WRITE_AUTH_REQUIRED / WRITE_AUTH_BINDING_MISMATCH / WRITE_AUTH_SESSION_INVALID (HTTP 403) — a wallet-less human’s control-plane session needs a passkey operator approval scoped to this (action, target) (the SIWX wallet path never hits this). The SDK raises a typed OperatorApprovalRequiredError (isOperatorApprovalRequired() guard) carrying capability, target, and a fully-resolved approveCommand / nextActions[] (e.g. run402 operator approve --action project.deploy --project prj_x). BINDING_MISMATCH = a cached approval targeted the wrong org/project; SESSION_INVALID = it’s stale. Resolve by running the surfaced operator approve command (or let an interactive provision/deploy auto-approve).
  • Client-side BAD_JSON_FLAG errors include details.flag (the offending flag, e.g. --abi) and details.value_preview (truncated value) so callers know which flag value to fix.
  • CLI commands reject unknown flags and missing flag values locally with UNKNOWN_FLAG or BAD_FLAG before network work. Numeric and wei-like flags are strict decimal integers: malformed, fractional, negative, and scientific-notation values fail locally instead of being forwarded to the API.
  • Commands with fixed positional shapes also reject extra positional arguments locally. This includes deploy resume/list/events/release subcommands, functions list/delete, and blob get/rm/sign/diagnose.

Examples:

{ "status": "error", "http": 403, "message": "Project is frozen.", "code": "PROJECT_FROZEN", "category": "lifecycle", "retryable": false, "safe_to_retry": true, "mutation_state": "none", "next_actions": [{ "type": "renew_tier" }, { "type": "check_usage" }] }
{ "status": "error", "http": 402, "message": "Payment required.", "code": "PAYMENT_REQUIRED", "category": "payment", "retryable": true, "safe_to_retry": true, "next_actions": [{ "type": "submit_payment" }] }
{ "status": "error", "message": "Migration failed.", "code": "MIGRATION_FAILED", "category": "deploy", "retryable": false, "safe_to_retry": true, "mutation_state": "rolled_back", "trace_id": "trc_...", "details": { "operation_id": "op_...", "phase": "migrate" }, "next_actions": [{ "type": "edit_migration" }] }

Terminal window
npm install -g run402
Terminal window
run402 init # creates allowance if absent; checks balance; faucets testnet USDC if zero; shows tier/projects
run402 allowance create # Generate a new allowance
run402 allowance fund # Get free testnet USDC (Base Sepolia)
run402 allowance balance # Check USDC balance (mainnet + testnet + billing)

Allowance lives at ~/.config/run402/allowance.json (0600). CLI signs x402 automatically; never handle private keys/payment libs manually.

For a self-hosted Run402 Core Gateway, skip Cloud allowance setup and configure the target instead:

Terminal window
run402 init --api-base=http://my-core:4020

After that, the same run402 projects provision and run402 deploy apply commands target Core.

If you were given a promo code — a string like R402-K8F3-Q2W9, sometimes called a voucher or a gift/starter code — redeem it. It credits your organization with run402 prepaid credit, and that credit pays for the next step (a tier) with no on-chain payment.

Terminal window
run402 redeem R402-K8F3-Q2W9 # any time: before or after `run402 init`
run402 init --voucher R402-K8F3-Q2W9 # same redemption, folded into setup
  • Order does not matter. Redeeming works as your very first authenticated call (the organization is created on demand) or long after run402 init.
  • Codes are forgiving. Case-insensitive, hyphens optional — R402-K8F3-Q2W9 and r402k8f3q2w9 are the same code. Send it verbatim; the server normalizes.
  • Retrying is safe. A repeat by the same organization returns the original result with already_redeemed: true and never credits twice.
  • A bad code never blocks setup. init --voucher warns, records voucher_error in its JSON summary, and finishes normally.
  • Failures: 404 VOUCHER_NOT_FOUND (unknown or malformed), 410 VOUCHER_EXPIRED, 409 VOUCHER_ALREADY_REDEEMED (a different organization used it), 403 PROMO_LIMIT_REACHED (this org is at its lifetime ceiling).

Minting codes is not an agent operation — it needs an issuer key no tenant holds.

Terminal window
run402 tier set prototype # FREE on testnet — faucet USDC verifies your x402 setup ($0 real money); 7-day lease
run402 tier set hobby # $5 for 30 days (real money)
run402 tier set team # $20 for 30 days (real money)

Tier is organization-scoped. Subscribe/renew/upgrade applies to every project in the org; api_calls / storage_bytes quota is org-pooled across linked wallets (billing link-wallet). Quota errors include details.scope: "organization" | "project" (project = orphan fallback after org purge before cascade). tier set refetches status and returns status_after with refreshed pool usage.

Retry-safety: tier set and projects provision accept --idempotency-key <key> so a retried subscribe/renew/create collapses onto one charge instead of double-billing. provision auto-derives the key from --name when omitted (re-running provision --name X returns the same project); tier set is caller-supplied only — use a fresh key for a deliberate second renewal.

Server action detection:

  • No tier or expired -> subscribe
  • Same tier, active -> renew (extends from current expiry)
  • Higher tier -> upgrade (prorated refund to billing allowance)
  • Lower tier, active -> downgrade (prorated refund if usage fits)
Terminal window
run402 tier status

tier status pool_usage sums api_calls and storage_bytes across every project on the organization (across every linked wallet), not the requesting wallet’s projects.

With active tier: unlimited projects/sites/forks/functions/secrets/storage subject to org-pooled api_calls/storage_bytes; only image generation is per-call ($0.03/image).


Portable archives are the vendor-lock-in escape hatch: Cloud is the easiest place to start, not the only place the supported application can run. This is separate from allowance/spend-cap financial-risk controls. Archive v1 exports the supported Run402 Core runtime slice of a Cloud project, not an entire Cloud project.

Canonical agent path:

Terminal window
run402 cloud archives create prj_... \
--scope portable-runtime-v1 \
--auth stubs \
--consistency pause-writes \
--wait \
--output ./project.r402ar \
--json
run402 archives inspect ./project.r402ar --json
run402 archives verify ./project.r402ar --json
# Create ./required.env from required_secrets or secrets/required.env.template.
run402 core projects import ./project.r402ar \
--name imported-project \
--env-file ./required.env \
--json

cloud archives create creates an operation-backed Cloud export, waits when --wait or --output is present, downloads bytes when --output is set, and returns archive_id, operation_id, archive_status, sha256, expires_at, portability_report, export_report, verify_command, and import_command. Use --idempotency-key <key> for safe retries, --poll-interval <ms> and --timeout <ms> for waits, and --json-stream for NDJSON progress.

Progress events are one JSON object per line:

{"event":"archive_export_created","stage":"create","resource_type":"project_archive","resource_id":"arc_...","project_id":"prj_...","status":"running","completed_units":0,"total_units":1,"code":null,"message":"Archive export status: running","next_action":{"type":"none"},"retryable":true}

Every event and diagnostic uses stable agent fields: code, severity, resource_type, resource_id, message, next_action, retryable, and safe context.

archives inspect and archives verify are local and offline. They do not require Cloud credentials. verify checks descriptor/blob integrity, format compatibility, required capabilities, size/path safety, required secrets, auth stub counts, and portability diagnostics. Verification means integrity and compatibility, not trust; archives remain untrusted input.

core projects import verifies before import, targets a new Core project only, and calls a local Core gateway (RUN402_CORE_URL or --core-url, default http://127.0.0.1:4020). It supports --dry-run, --require-runnable, --env-file, and repeated --secret KEY=VALUE overrides. Required secret names are reported by inspect/verify and in the archive’s secrets/required.env.template; secret values are never exported.

Expected v1 exclusions: secret values, password hashes, sessions, refresh/access/OAuth tokens, MFA secrets, signed URLs, logs, billing/allowance/spend state, fleet/Aurora/global-routing/provider operations, managed backups, monitoring, abuse/compliance/support metadata, Cloud import, and existing-project merge import.

Stable archive codes include EXPORT_CONSISTENCY_UNAVAILABLE, EXPORT_SCOPE_UNSUPPORTED, ARCHIVE_EXPIRED, ARCHIVE_DIGEST_MISMATCH, ARCHIVE_UNSUPPORTED_VERSION, ARCHIVE_UNSUPPORTED_REQUIRED_CAPABILITY, ARCHIVE_PATH_UNSAFE, ARCHIVE_BLOB_MISSING, SECRET_VALUES_REQUIRED, AUTH_CREDENTIALS_NOT_EXPORTED, AUTH_SUBJECT_STUBS_IMPORTED, CLOUD_ONLY_FEATURE_EXCLUDED, PROJECT_ALREADY_EXISTS, IMPORT_VERIFY_FAILED, and IMPORT_CONFORMANCE_FAILED.


Canonical deploy primitive: CAS bytes (no inline-body cap), per-resource replace/patch, atomic multi-resource activation, resumable failures. SDK: r.project(id).apply(...).

⚠️ You still need the anon_key BEFORE writing your manifest – provision first, then embed the real key in your HTML.

Terminal window
run402 projects provision --name "my-app"
# → copy anon_key from output into your HTML

Manifest format mirrors a v2 ReleaseSpec. For editor autocomplete, use top-level "$schema": "https://run402.com/schemas/release-spec.v1.json"; the CLI accepts that metadata and strips it before planning.

{
"$schema": "https://run402.com/schemas/release-spec.v1.json",
"project_id": "prj_1741340000_42",
"database": {
"migrations": [
{
"id": "001_init",
"sql": "CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL); INSERT INTO items (title) VALUES ('Buy groceries');"
},
{
"name": "seed_items",
"sql": "INSERT INTO items (title) SELECT 'Welcome' WHERE NOT EXISTS (SELECT 1 FROM items WHERE title = 'Welcome');"
}
],
"expose": {
"version": "1",
"tables": [
{ "name": "items", "expose": true, "policy": "public_read_authenticated_write" }
]
}
},
"secrets": { "require": ["OPENAI_API_KEY"], "delete": ["OLD_KEY"] },
"functions": {
"replace": {
"api": {
"runtime": "node22",
"source": { "data": "export default async (req) => new Response('ok')" },
"config": { "timeout_seconds": 30, "memory_mb": 256 },
"triggers": [{
"id": "api_every_15m",
"type": "schedule",
"cron": "*/15 * * * *",
"run": { "event_type": "api.tick", "payload": {} }
}]
}
}
},
"site": {
"replace": {
"index.html": { "data": "<!doctype html><html>...</html>" },
"assets/logo.png": { "data": "iVBORw0KGgo...", "encoding": "base64" }
}
},
"subdomains": { "set": ["my-app"] },
"routes": {
"replace": [
{ "pattern": "/api/*", "methods": ["GET", "POST"], "target": { "type": "function", "name": "api" } }
]
},
"i18n": {
"default_locale": "en",
"locales": ["en", "es", "fr"],
"detect": ["cookie:wl_locale", "accept-language"]
}
}

File entries: bare UTF-8 string; { "data": "...", "encoding": "utf-8" | "base64", "content_type": "..." }; or { "path": "dist/index.html", "content_type": "text/html" }. site.replace / site.patch.put may also be { "__source": "local-dir", "path": "dist/client" } for a static-site directory. Function source may be { "path": "dist/run402/functions/api.js" }. --manifest relative paths resolve from manifest dir; --spec/stdin paths resolve from cwd. Authoring-only local paths and __source markers are stripped/staged before the apply request. Migrations may use "sql_path" / "sql_file" instead of "sql". Each migration declares exactly one of "id" or "name": use id for immutable versioned migrations, and name for generated/idempotent SQL whose compiled id should track content changes. CLI/MCP share SDK normalizeDeployManifest; JSON can become SDK-native ReleaseSpec. Strict adapter: only top-level $schema and app-kit evidence x-run402-omitted_features are ignored before planning; unknown fields/no-op specs fail ("subdomain", "site.replcae", "functions.replace.api.deps", "functions.replace.api.config.schedule").

Function specs: runtime: "node22", exactly one code source (source or files+entrypoint), config.timeout_seconds, config.memory_mb, and optional triggers[]. Schedule triggers require a stable id, type: "schedule", 5-field cron, and nested run: { event_type, payload?, retry?, expires_after_seconds? }; each tick creates a durable function run. Email triggers use { id, type: "email", mailbox, events, run }, where mailbox is a mailbox slug/id and events is any of reply_received, delivery, bounced, complained, mailbox_suspended; each matching email event creates a durable function run with the canonical event payload under payload.event. A mailbox_suspended trigger fires when the mailbox is abuse-suspended (payload event carries suspended_reason, suspended_at, evidence, recovery_actions) — the run executes independently of the suspended mailbox’s send capability, so an app can observe its own outage without polling or a public webhook URL. deps: string[] works under apply-v1-function-deps; gateway installs/bundles. run402 functions deploy --deps builds one functions.patch.set and uses unified apply; legacy standalone deploy route removed.

Deploy preflights literal function caps after normalization before CAS upload/plan: timeout, memory, schedule-trigger cron interval, scheduled-trigger count. Local failures: code: "BAD_FIELD" with details.field/value/tier, limit (tier_max or min_interval_minutes), details.limit_source (tier_status or local_static_fallback). Current caps: prototype 10s/128 MB/1 scheduled trigger/15 min; hobby 30s/256 MB/3/5 min; team 60s/512 MB/10/1 min. tier status shows live caps/usage when returned.

Subdomains: one mode per deploy. "set" replaces release managed subdomains, "add" appends, "remove" deletes. Current gateway supports at most one subdomains.set; multi-set fails locally with SUBDOMAIN_MULTI_NOT_SUPPORTED.

Complete static site + function + route manifest:

{
"project_id": "prj_...",
"site": { "replace": {
"index.html": { "data": "<!doctype html><main id='app'></main><script>fetch('/api/hello')</script>" },
"events.html": { "data": "<!doctype html><h1>Events</h1>" }
}, "public_paths": {
"mode": "explicit",
"replace": {
"/events": { "asset": "events.html", "cache_class": "html" }
}
} },
"functions": {
"replace": {
"api": {
"runtime": "node22",
"source": { "data": "export default async function handler(req) { const url = new URL(req.url); return Response.json({ ok: true, path: url.pathname }); }" }
},
"login": {
"runtime": "node22",
"source": { "data": "export default async function handler(req) { return Response.json({ ok: true }); }" }
}
}
},
"routes": { "replace": [
{ "pattern": "/api/*", "methods": ["GET", "POST", "OPTIONS"], "target": { "type": "function", "name": "api" } },
{ "pattern": "/login", "methods": ["POST"], "target": { "type": "function", "name": "login" } }
] }
}

Static public paths: release asset paths and browser paths differ (events.html asset -> /events public URL). mode: "explicit" exposes only public_paths.replace; /events.html is not public unless declared. mode: "implicit" restores filename-derived reachability and can widen access; review warnings. Known cache_class: "html", "immutable_versioned", "revalidating_asset"; preserve unknown future strings. Public-path-only specs are deploy content: { "site": { "public_paths": { "mode": "explicit", "replace": {} } } } removes direct public static URLs without changing assets.

Route semantics:

  • Omit routes or pass null to carry forward; { "replace": [] } clears; { "replace": [...] } atomically replaces.
  • Entries: pattern, optional non-empty methods (GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS), target. Function target: { "type": "function", "name": "<materialized function name>" }. Prefer site.public_paths for ordinary clean static URLs. Static route target = exact, method-aware alias, e.g. { "pattern": "/events", "methods": ["GET", "HEAD"], "target": { "type": "static", "file": "events.html" } }; file is release asset path, not public path/URL/CAS/rewrite/redirect. Static targets require exact patterns only, methods ["GET"] or ["GET","HEAD"], no leading slash/wildcard/dir shorthand/query/fragment. Path-keyed maps like "routes": { "/api/*": { "function": "api" } } invalid.
  • Exact patterns look like /admin; prefix wildcard patterns use final /*, like /admin/*. /admin/* does not match /admin, /admin/, /admin.css, or /administrator, so deploy both /admin and /admin/* for a dynamic section root.
  • Query ignored for matching but preserved in full public req.url. Exact beats prefix; longest prefix wins; method-compatible dynamic routes beat static assets.
  • POST /login can coexist with static GET /login; unsafe method mismatch returns 405, not SPA HTML. Matched dynamic failures fail closed; no static fallback.
  • Routed ingress uses the Node 22 Fetch Request -> Response contract; req.url is full public URL across managed subdomains/deployment hosts/custom domains. Derive OAuth origins from new URL(req.url).origin. run402.routed_http.v1 envelope is internal. Direct /functions/v1/:name remains API-key protected. Function owns app auth, CSRF, CORS/OPTIONS, cookies, redirects, and forwarding-header hygiene.
  • Anti-patterns: routing every static file, broad method lists by default, wildcard static route targets, leading-slash static files, directory shorthand, one-static-route-target-per-page route-table exhaustion, wildcard function routes shadowing direct public static paths, and confusing omitted/null routes with routes: { "replace": [] }.

Apply it:

Terminal window
run402 deploy apply --manifest app.json

Stdout final result includes release_id, operation_id, urls, etc. Stderr streams JSON-line progress events. --quiet / --final-only silence stderr while preserving stdout.

Recipe — static home page + SPA shell: a SPA site ships index.html as the shell serving every unmatched route (match spa_fallback), so by default GET / serves the shell too. To serve a real static home page at / — real bytes under curl and without JavaScript — while keeping the shell for app routes, ship home.html at the site root alongside index.html, add an exact root static route alias, and run402 deploy apply --manifest app.json:

{
"project_id": "prj_...",
"site": { "replace": {
"index.html": { "data": "<!doctype html><main id='app'></main><script src='/app.js'></script>" },
"home.html": { "data": "<!doctype html><h1>Welcome</h1><a href='/dashboard'>Open the app</a>" },
"app.js": { "data": "/* SPA bootstrap */" }
} },
"routes": { "replace": [
{ "pattern": "/", "target": { "type": "static", "file": "home.html" } }
] }
}

Route matching runs before all static resolution — including the implicit / -> index.html root mapping — and SPA-fallback derivation is independent of the route table. So GET / serves home.html (match route_static_alias), unmatched app routes like /dashboard still serve the index.html shell (match spa_fallback), and named static pages keep serving unchanged (match static_exact). Root placement of home.html keeps its relative asset URLs resolving identically to the direct file and avoids the STATIC_ALIAS_RELATIVE_ASSET_RISK warning. Expect two non-blocking plan lints: STATIC_ALIAS_SHADOWS_STATIC_PATH (warn — the alias overrides what / would otherwise serve; for this recipe that is accurate and expected, and the commit proceeds) and STATIC_ALIAS_DUPLICATE_CANONICAL_URL (info — /home.html stays directly reachable in implicit public-path mode; add <link rel="canonical" href="https://<your-site>/"> to home.html if duplicate-content SEO matters). Omitting routes on later deploys carries the alias forward (informational ROUTE_TARGET_CARRIED_FORWARD); a pipeline that sends routes.replace must include the alias every time because replace is total. Verify with run402 deploy resolve --project prj_123 --url https://<your-site>/ --method GET and confirm match: "route_static_alias" with target_file: "home.html".

Typed deploy configs are an authoring format for the same deploy apply and up verbs, not a separate command family. JSON data manifests (run402.deploy.json, app.json) may be auto-discovered. TypeScript/JavaScript configs are executable local code, so v1 requires explicit trust with --manifest:

Terminal window
run402 up --manifest run402.deploy.ts --check
run402 up --manifest run402.deploy.ts --print-spec
run402 up --manifest run402.deploy.ts --plan
run402 up --manifest run402.deploy.ts --require-plan plan_...

Mode contract:

  • --check: local-only import/normalize/strict field validation plus local file checks. No gateway calls, uploads, tier/project creation, or .run402/project.json writes. Success is raw JSON with mode: "check" / dry_run: true on up, or { ok: true, mode: "check", project_id, manifest_path } on deploy apply.
  • --print-spec: local-only normalized ReleaseSpec JSON to stdout.
  • --plan: gateway-reviewed plan, no upload or commit. Response includes plan_id, plan_fingerprint, plan_expires_at, manifest_digest, diff, warnings, and next_actions[].
  • --require-plan <plan_id>: exact reviewed apply. The SDK recompiles locally, verifies the reviewed plan before upload, then commit verifies again before release mutation. Add --plan-fingerprint <fingerprint> when it was returned by --plan.

run402 up --plan preserves the up surface in next_actions[0].argv, e.g. ["run402","up","--manifest","run402.deploy.ts","--require-plan","plan_..."]. run402 deploy apply --plan returns a deploy apply --require-plan action. --allow-warning / --allow-warnings conflict with --require-plan because reviewed-plan approval already binds the exact warning/destructive sets. If run402 up --check sees only run402.deploy.ts and no JSON manifest, it fails with EXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST and a recovery action to rerun with --manifest run402.deploy.ts --check.

Minimal typed config:

import { defineConfig, dir, nodeFunction, sqlFile } from "@run402/sdk/config";
export default defineConfig(({ env }) => ({
project: env.required("RUN402_PROJECT_ID"),
database: { migrations: [sqlFile("db/001_init.sql")] },
site: { replace: dir("dist"), public_paths: { mode: "implicit" } },
functions: { replace: { api: nodeFunction("dist/functions/api.js") } },
secrets: { require: ["OPENAI_API_KEY"] }
}));

Helper semantics: dir() walks files in stable path order, skips private/dev patterns by default like the existing directory deploy helpers, normalizes / separators, rejects symlinks, and infers content types. file() resolves relative to the config file directory. sqlFile() derives the migration id from the filename unless id is supplied; pass { name: "seed" } for generated/idempotent SQL so the SDK compiles <name>_<sha256(sql)[0:16]> from post-build file bytes. nodeFunction() stages a Node 22 function from built JavaScript; TypeScript function source paths are rejected with TYPESCRIPT_FUNCTION_REQUIRES_BUNDLE until a deterministic bundler path is introduced.

Patch semantics — only the listed file changes:

{
"project_id": "prj_...",
"site": { "patch": { "put": { "index.html": { "data": "<h1>v2</h1>" } } } }
}

Or via --spec for a one-line CLI invocation:

Terminal window
run402 deploy apply --spec '{"project_id":"prj_...","site":{"patch":{"delete":["old.html"]}}}'

Astro builds: --dir <build-output> reads dist/run402/adapter.json and merges build ReleaseSpec slices (site/functions/routes). Combine with --manifest for cross-cutting slices (database, secrets, subdomains, i18n):

Terminal window
# Astro-only: --dir is the whole spec source (requires @run402/astro installed)
run402 deploy apply --dir ./dist --project prj_...
# Astro + cross-cutting slices: --dir owns site/functions/routes, --manifest owns the rest
run402 deploy apply --dir ./dist --manifest run402.config.json --project prj_...

CLI dynamically imports @run402/astro/release-slice from the consuming project. Requires @run402/astro >=1.2.1 + @run402/sdk >=2.18.0; older SDKs reject FunctionSpec.class: 'ssr', helper preflights R402_ASTRO_SDK_VERSION_TOO_OLD with upgrade command. Helper bundles SSR server with esbuild into single source, marks it with class: "ssr" and capabilities: ["astro.ssr.v1"], roots site at build.client (dist/run402/client/, NOT dist/), omits routes so gateway’s SSR catch-all works and base routes carry forward (also CI-safe without route scopes), defaults site.public_paths: { mode: "implicit" }, and colocates _assets-manifest.json inside build.client. Missing/incompatible manifest errors: R402_ASTRO_ADAPTER_MANIFEST_MISSING / R402_ASTRO_ADAPTER_MANIFEST_VERSION_UNSUPPORTED with hint+docs. SDK equivalent: buildAstroReleaseSlice. Do not hand-roll site/public_paths; shipping run402/adapter.json or run402/server/** as site content means source rooted at dist/ instead of dist/run402/client/; SDK rejects ASTRO_ADAPTER_TREE_IN_SITE, gateway warns SITE_NO_REACHABLE_HTML.

Stuck deploys: activation_pending (rare transient between SQL commit and pointer-swap) auto-resumes hourly. Static spec/config activation failures throw structured deploy errors promptly. Explicit resume:

Terminal window
run402 deploy resume <operation_id> [--project prj_...]

Gateway reruns only failed phase forward; SQL is never replayed.

Destructive apply recovery: run402 deploy promote <release-id> re-points live release at a prior ready row without re-running apply (no bytes/bundling/migration), just internal.projects.live_release_id pointer swap + ssr_cache flush.

Terminal window
# rel_old (good) → rel_new (bad, destructive) → promote back
run402 deploy promote rel_old_abc123 --project prj_xyz \
--allow-warning MIGRATIONS_NOT_REVERSIBLE
# Promotion is origin-active when it returns; wait for public edge coherence.
run402 deploy verify --operation op_... --wait

Read operation_id from the promote result and pass it to deploy verify. Promote success means the origin pointer is active; mutable public URLs can still be converging. The additive edge block reports state, expected_max_lag_seconds, and pointer-update status. edge.verify_url is the direct operation-scoped HTTP verification endpoint.

Promote warnings/errors: MIGRATIONS_NOT_REVERSIBLE requires ack when target predates applied migrations; migrations remain applied against current schema. FUNCTION_VERSION_MISMATCH informational when overlapping names have different code_hash (Lambda code = current $LATEST). Rejects: PROMOTE_TARGET_NOT_FOUND, PROMOTE_PROJECT_MISMATCH, PROMOTE_RELEASE_NOT_READY (needs ready|active|superseded), PROMOTE_NO_OP (use cache.invalidateAll), PROMOTE_WARNING_REQUIRES_ACK.

Deploy history/observability:

Terminal window
run402 deploy list --project prj_... --limit 10
run402 deploy events <operation_id> --project prj_...
run402 deploy verify <operation_id> --project prj_... --wait --timeout 120
run402 deploy release active --project prj_... --site-limit 5000
run402 deploy release get rel_... --project prj_...
run402 deploy release diff --from empty --to active --project prj_... --limit 1000
run402 deploy diagnose --project prj_123 https://example.com/events --method GET
run402 deploy resolve --project prj_123 --url https://example.com/events?utm=x#hero --method GET
run402 deploy resolve --project prj_123 --host example.com --path /events --method GET

list -> { operations, cursor }; SDK/MCP accept non-null cursor. events returns same DeployEvent shapes as inline apply events. verify calls the edge-coherence report endpoint and returns { status: "coherent"|"not_coherent", coherent, report }; with --wait, stderr emits per-poll path summaries and exit code 2 means the report was valid but still not coherent before timeout.

release active|get -> { release: ReleaseInventory }: metadata, state_kind (current_live|effective|desired_manifest), site.paths (capped by --site-limit), static_public_paths, functions, secret keys only, subdomains, routes, migrations, release_generation, static_manifest_sha256, nullable static_manifest_metadata, i18n ({ defaultLocale, locales, detect } or null), warnings. static_public_paths[] has public_path, asset_path, reachability_authority, direct, cache class, content type. static_manifest_metadata: null = unavailable; when present has file_count, total_bytes, cache_classes, cache_class_sources, spa_fallback. Verify i18n with jq '.release.i18n'; absent field (older gateway) = unknown, not null.

release diff -> { diff: ReleaseToReleaseDiff }; --from empty|active|release_id, --to active|release_id. Migrations: migrations.applied_between_releases; secrets/subdomains: added/removed; routes: added/removed/changed; static_assets: unchanged/changed/added/removed plus newly_uploaded_cas_bytes, reused_cas_bytes, deployment_copy_bytes_eliminated, legacy_immutable_warnings, previous_immutable_failures, cas_authorization_failures.

deploy diagnose URL-first; deploy resolve lower-level SDK/endpoint parity. Use either --url OR --host + optional --path. Both output status, would_serve, diagnostic_status, match, summary, normalized request, warnings, resolution, edge_propagation, next_steps. URL query/fragment ignored for lookup and reported under request.ignored. asset_path, reachability_authority, direct identify backing release asset and whether implicit, explicit site.public_paths, or route-only alias. Host/path misses exit 0 if resolver succeeded; branch on would_serve: false.

Diagnostics may include authorization_result, cas_object (sha256, exists, expected_size, actual_size), response_variant, allow, route_pattern, target_type, target_name, target_file, and edge_propagation (status, claimed_at, kvs_synced_at, expected_visible_by, hint). Known edge_propagation.status: settled, propagating, sync_pending; non-settled statuses add warnings such as edge_propagating / edge_sync_pending and next steps to retry or run run402 up verify. Known match: host_missing, manifest_missing, active_release_missing, unsupported_manifest_version, path_error, none, static_exact, static_index, spa_fallback, spa_fallback_missing, route_function, route_static_alias, route_method_miss. Known authorization_result: authorized, not_public, not_applicable, manifest_missing, target_missing, active_release_missing, unsupported_manifest_version, path_error, missing_cas_object, unfinalized_or_deleting_cas_object, size_mismatch, unauthorized_cas_object. Known fallback_state: active_release_missing, unsupported_manifest_version, negative_cache_hit. Preserve unknown future strings. result = diagnostic body status, not HTTP transport. Resolve/diagnose is not fetch, purge, or cache-policy oracle.

Route warning guidance:

Code Meaning Recover
PUBLIC_ROUTED_FUNCTION Function becomes public same-origin browser ingress. Review app auth, CSRF, CORS/OPTIONS, and cookies; direct /functions/v1/:name remains API-key protected. Prefer --allow-warning PUBLIC_ROUTED_FUNCTION after review; use --allow-warnings only after every warning was reviewed.
ROUTE_TARGET_CARRIED_FORWARD Carried-forward route still targets a base-release function. Inspect run402 deploy release active and deploy a replacement route table if needed.
ROUTE_SHADOWS_STATIC_PATH / WILDCARD_ROUTE_SHADOWS_STATIC_PATHS Dynamic route shadows direct public static content. Inspect warning details, active routes, static_public_paths, and resolve diagnostics; confirm only when intentional.
METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK Unmatched methods can serve static content. Confirm fallback is intended or add method coverage.
WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS Wildcard function route only allows GET/HEAD. Add mutation methods e.g. POST, omit methods for an API prefix, or set acknowledge_readonly: true on an intentionally read-only GET/HEAD final-wildcard function route. --allow-warning WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS is a reviewed CLI escape hatch; broad --allow-warnings is last resort.
ROUTE_TABLE_NEAR_LIMIT Route table is near a limit. Consolidate or remove routes.
ROUTES_NOT_ENABLED Routes are disabled for the project/environment. Deploy without routes or request enablement; direct function invoke is not a browser-route substitute.
STATIC_ALIAS_SHADOWS_STATIC_PATH / STATIC_ALIAS_RELATIVE_ASSET_RISK Route-only static alias conflicts with a direct public static path or has relative-asset risk. Inspect active routes, static_public_paths, and the backing asset_path; prefer site.public_paths for ordinary clean URLs and confirm only when intentional.
STATIC_ALIAS_DUPLICATE_CANONICAL_URL / STATIC_ALIAS_EXTENSIONLESS_NON_HTML Route-only static alias may duplicate another direct public path or expose extensionless non-HTML. Use one canonical public path per page and reserve exact static route targets for method-aware aliases.
STATIC_ALIAS_TABLE_NEAR_LIMIT Static route targets are near route-table limits. Avoid one-static-route-target-per-page tables; consolidate.

Runtime route failure codes: ROUTE_MANIFEST_LOAD_FAILED (manifest/propagation), ROUTED_INVOKE_WORKER_SECRET_MISSING (custom-domain Worker secret), ROUTED_INVOKE_AUTH_FAILED (internal invoke signature), ROUTED_ROUTE_STALE (release revalidation failed), ROUTE_METHOD_NOT_ALLOWED, ROUTED_RESPONSE_TOO_LARGE (>6 MiB).

Routed functions: locale awareness. spec.i18n negotiates locale per routed-function request and exposes x-run402-locale / x-run402-default-locale headers (omitted when active release lacks i18n). Carry-forward rules are simpler than routes; no { replace } envelope:

{
"i18n": {
"default_locale": "en",
"locales": ["en", "es", "fr", "zh-Hant"],
"detect": ["cookie:wl_locale", "accept-language"]
}
}
  • Omit i18n to carry forward from the base release; pass "i18n": null to clear the slice on the new release; pass { default_locale, locales, detect? } to replace.
  • default_locale must byte-match one locales[] entry; no silent canonicalization; CLI/SDK validate before planning.
  • Locale tags must match /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ and RFC 5646 canonical casing: primary lowercase, script Titlecase, 2-alpha region uppercase, 3-digit region preserved, variants/extensions lowercase. Examples: pt-BR, zh-Hant, zh-Hant-TW, de-1996. Non-canonical deploy error: R402_LOCALE_NOT_CANONICAL (400) with fix: { input, canonical }. locales[] non-empty, max 50. No silent canonicalization because DB translation keys often use literal locale strings.
  • Negotiation returns canonical casing from locales[], NOT the request’s casing.
  • detect[] default ["accept-language"], max 10, [] = always default; first match wins. Sources: "accept-language" (RFC 9110 + RFC 4647 lookup truncation zh-Hant-TW -> zh-Hant -> zh; generic request tag does not match more specific configured tag, e.g. es not es-MX) and "cookie:<name>" (RFC 6265 name regex /^[!#$%&'*+\-.^_|~0-9A-Za-z]+$/`, raw value matched case-insensitively).
  • Static-route hits do NOT receive locale negotiation; only routed HTTP function invocations do.
  • Run402 does NOT inject Vary headers — apps that return public-cacheable responses varying by locale must set their own Vary until per-locale edge caching ships.

Routed-function read pattern: single-arg (req), not (req, ctx); bundled runtime translates envelope to Web Request, so context.locale is not visible.

export default async (req) => {
const locale = req.headers.get('x-run402-locale');
const defaultLocale = req.headers.get('x-run402-default-locale');
if (locale && locale !== defaultLocale) {
return renderWithTranslations({ locale });
}
return renderBase({ locale: defaultLocale ?? 'en' });
};

Language switchers must write a cookie; localStorage only is invisible to server-side negotiation. Mirror locale to cookie and declare cookie source in spec.i18n.detect:

function setLanguage(lang) {
localStorage.setItem('wl_locale', lang);
document.cookie =
`wl_locale=${encodeURIComponent(lang)}; path=/; max-age=31536000; samesite=lax`;
}

Deploy with "detect": ["cookie:wl_locale", "accept-language"].

Migration registry: key = (id, checksum). There are two authoring kinds. Versioned migrations use id: same id+SQL = noop; same id+different SQL = MIGRATION_CHECKSUM_MISMATCH; if you revise one, ship a new id. Content-tracked migrations use name: the SDK compiles <name>_<sha256(sql)[0:16]>, so changed generated SQL applies once under a new id and unchanged re-ups noop. SQL declared with name MUST be idempotent (CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE, upserts, ADD COLUMN IF NOT EXISTS in a DO block) because changed content re-runs against a database where prior versions may already exist. If generated SQL is trapped behind a static id mismatch, replace "id": "seed" with "name": "seed" as the primary recovery path; admin checksum adoption is only for legacy/out-of-band cases.


Use this when the same repo should deploy itself from GitHub Actions without storing Run402 service keys, allowance files, or API keys in GitHub secrets. KISS rule: link once locally, then CI runs the same run402 deploy apply command agents already know.

Local setup:

Terminal window
run402 ci link github --project prj_... --manifest run402.deploy.json
run402 ci link github --project prj_... --manifest run402.deploy.json --route-scope /admin --route-scope /api/*

Full link syntax:

Terminal window
run402 ci link github \
[--project <id>] \
[--manifest <path>] \
[--repo <owner/repo>] \
[--branch <name> | --environment <name>] \
[--repository-id <numeric_id>] \
[--workflow <path>] \
[--expires-at <iso_timestamp>] \
[--route-scope <pattern> ...] \
[--force]

Defaults:

  • --project: active project
  • --manifest: run402.deploy.json
  • --repo: inferred from git remote get-url origin
  • --branch: current branch from git branch --show-current
  • --workflow: .github/workflows/run402-deploy.yml
  • --route-scope: omitted by default, which means no CI route-declaration authority; repeat for exact paths like /admin or final wildcard prefixes like /api/*
  • allowed events: fixed to push and workflow_dispatch
  • allowed action: fixed to deploy

The command fetches GitHub’s numeric repository id using GITHUB_TOKEN or GH_TOKEN when available. If lookup fails, pass --repository-id <numeric_id> explicitly. The subject is generated from --branch as repo:<owner/repo>:ref:refs/heads/<branch>, or from --environment as repo:<owner/repo>:environment:<environment>.

Generated workflow shape:

name: Run402 Deploy
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to run402
run: npx --yes run402@3.7.5 deploy apply --manifest 'run402.deploy.json' --project 'prj_...' < /dev/null

Output on success:

{
"binding_id": "cib_...",
"project_id": "prj_...",
"provider": "github-actions",
"subject_match": "repo:owner/name:ref:refs/heads/main",
"allowed_events": ["push", "workflow_dispatch"],
"allowed_actions": ["deploy"],
"route_scopes": ["/admin", "/api/*"],
"github_repository_id": "123456789",
"github_repository_id_status": "verified",
"workflow_path": ".github/workflows/run402-deploy.yml",
"manifest_path": "run402.deploy.json",
"run402_version": "3.7.5",
"delegation_chain_id": "eip155:84532",
"bootstrap_caveat": "Commit the generated workflow and manifest before expecting GitHub Actions deploys.",
"consent_summary": ["..."],
"revocation_residuals": ["..."]
}

Management:

Terminal window
run402 ci list [--project <id>]
run402 ci revoke <binding_id>

list prints { "project_id": "...", "bindings": [...] }. revoke prints { "binding": {...}, "revoked": true, "revocation_residuals": [...] }.

Intentional omissions in v1: no raw --subject, no wildcard flag, no --allow-event, no PR deploy flags, and no --no-repository-id. Use --branch or --environment; create a follow-up design before broadening trust.

CI deploy restrictions: when run402 deploy apply runs inside GitHub Actions with OIDC env vars present, it uses the GitHub subject token, exchanges it for a Run402 CI session, and skips the local allowance preflight. CI manifests may include only project_id, database, functions, site, absent/current base, and route declarations covered by the binding’s route_scopes. Without --route-scope, CI cannot ship routes. CI cannot ship secrets, subdomains, checks, unknown future top-level fields, non-current base, or oversized manifests that require manifest_ref.

Common CI error codes:

  • invalid_token: check permissions: id-token: write and the workflow’s OIDC environment
  • access_denied: no active binding matched this repo/branch/environment
  • binding_revoked: a matching binding existed but was revoked (most often the project was transferred/handed off, which suspends the prior org’s CI bindings) — re-run run402 ci link github to re-create it; do NOT run set-asset-scopes (it 409s on a revoked binding)
  • event_not_allowed: v1 allows only push and workflow_dispatch
  • repository_id_mismatch: re-link from the current repo or pass the correct numeric --repository-id
  • forbidden_spec_field / forbidden_plan: remove disallowed CI manifest fields or run the deploy locally
  • CI_ROUTE_SCOPE_DENIED: re-link with covering --route-scope patterns e.g. /admin or /api/*, or run the route-changing deploy locally
  • payment_required: renew/upgrade/fund the project tier outside CI, then rerun the workflow

Use run402 deploy apply --manifest app.json for full-stack releases; see the Unified Apply example above. project_id is required unless --project or active project is used. Omitted top-level sections carry forward. Strict adapter: only top-level $schema ignored; typo/no-op fields fail before planning.

Function specs add v1.51+ auth gates:

  • require_auth: true: valid project user JWT required; 401 on anonymous; no DB lookup; independent from require_role.
  • require_role: { table, id_column, role_column, allowed[], cache_ttl? } | null: implies auth; gateway reads project-schema table with RLS bypass; 403 if role not in allowed; null removes gate in patch mode; cache_ttl default 60, max 600, 0 disables cache.
  • Passing gate injects x-run402-user-id (any gate) and x-run402-user-role (require_role) into request; read headers directly or use auth.*.
  • Validation: all require_role blocks in one release share (table,id_column,role_column); schema-qualified identifiers rejected; 0 <= cache_ttl <= 600; empty allowed rejected; missing table/column fails activation with DEPLOY_INVALID_ROLE_GATE (422) before live flip.

Auth-gate fragment:

{
"functions": {
"patch": {
"set": {
"list-my-items": {
"source": { "path": "functions/list.ts" },
"require_auth": true
},
"delete-content": {
"source": { "path": "functions/delete.ts" },
"require_role": {
"table": "members",
"id_column": "user_id",
"role_column": "role",
"allowed": ["admin"],
"cache_ttl": 60
}
},
"moderate-content": {
"source": { "path": "functions/moderate.ts" },
"require_role": {
"table": "members",
"id_column": "user_id",
"role_column": "role",
"allowed": ["admin", "moderator"]
}
}
}
}
}
}

Role reads (@run402/functions 3.4.0+, { from } since 3.5.0): edge gate authenticates Bearer JWT and cookie-session SSR browsers (ssr-aware-role-gate). Choose by topology:

  • Dedicated function/route: prefer deploy-spec require_role; per-function, pre-dispatch, TTL-cached. await auth.requireRole("operator") returns { user, role }; throws distinct RoleGateNotConfiguredError (500) vs InsufficientRoleError (403). Multi-role: await auth.role() and branch. Browser console can set gate on_deny: "redirect" + same-origin sign_in_path for anonymous HTML 303 to sign-in; authenticated wrong-role remains 403 JSON.
  • Catch-all SSR/finer per-path control: use in-function { from } guard; edge gate would also gate public catch-all/404 and /admin/login.
const { user } = await auth.requireRole("operator", { from: { table: "staff", idColumn: "user_id", roleColumn: "role" } });
// or, for an .astro page (a throw in frontmatter renders a 500, not a redirect) use the non-throwing read:
const role = await auth.role({ from: { table: "staff", idColumn: "user_id", roleColumn: "role" } });
if (role !== "operator") return Astro.redirect("/admin/login", 303);

run402 auth scaffold-roles --roles operator emits conventional app_roles(user_id uuid, role text) migration, matching requireRole snippet, and service-role INSERT for FIRST role (table starts empty; first grant bypasses RLS). Gate keys on tenant user id (internal.users.id / JWT sub), not wallet. Applies to routed and direct (POST /functions/v1/:name with API key + user JWT); direct still requires API key before gate.

Binary files (images, fonts, PDFs): Set "encoding": "base64" and provide base64-encoded data. MIME types are auto-detected from the file extension (.pngimage/png, .woff2font/woff2, etc.). Text files use "encoding": "utf-8" (the default — can be omitted).

Assets slice: top-level ReleaseSpec.assets promotes content-addressed asset entries in the same atomic transaction as site/functions/secrets.

"assets": {
"put": [
{ "key": "static/app.css", "sha256": "<64-hex>", "size_bytes": 1234, "content_type": "text/css", "visibility": "public", "immutable": true }
]
}

Additive batch: locally computed sha256; gateway dedupes CAS; only new shas upload through same S3 presign flow as assets put. Defaults visibility: "public", immutable: true; other keys untouched.

"assets": {
"put": [...],
"sync": {
"prefix": "static/",
"prune": true,
"confirm": { "base_revision": "<hex>", "delete_set_digest": "<hex>", "expected_delete_count": 42 }
}
}

Declarative sync: prune: true deletes keys under explicit prefix absent from new put; no implicit project-root prune. First apply without confirm returns asset_sync (base_revision, delete_set_digest, expected_delete_count, sample_keys); re-run with confirm. Activation rechecks and fails ASSET_SYNC_DRIFT if inventory mutates between commit/activation. No run402 assets sync; use manifest + deploy apply or SDK helpers (uploadDir, syncDir, prepareDir, putMany).

Verify block (authoring-only): deploy manifests accept a top-level verify with post-apply HTTP checks — the same verify.http[] shape app manifests use. It is stripped before the wire ReleaseSpec (like $schema); run402 up runs the checks after a successful apply (propagation-tolerant, results in result.verification.http[] + a result.verify rollup) and run402 up verify reruns them on demand. Each check: id (unique, required), path (resolved against the project public origin) or url, expect: { status } (snake alias expected_status), optional retries.

"verify": {
"http": [
{ "id": "home", "path": "/", "expect": { "status": 200 } },
{ "id": "api", "path": "/v1/health", "expected_status": 204 }
]
}

Migrations: inline sql or per-entry sql_path / sql_file. Make re-runnable: CREATE TABLE/INDEX IF NOT EXISTS; new columns need ALTER TABLE ... ADD COLUMN in an idempotent DO block:

CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL);
DO $$ BEGIN
ALTER TABLE items ADD COLUMN priority int DEFAULT 0;
EXCEPTION WHEN duplicate_column THEN NULL;
END $$;

Authorization manifest (https://run402.com/schemas/manifest.v1.json): new tables are dark until declared with expose: true. Prefer database.expose or a manifest.json in bundle files[]; platform validates against migration SQL, applies it, and strips manifest.json before site deploy so it is never public. Success includes manifest_applied: true; missing migration table -> HTTP 400 with structured errors[]. Dry feedback: run402 projects validate-expose [project_id] --file manifest.json [--migration-file setup.sql]; validates auth/expose manifest only, does not execute SQL, exits 0 even with has_errors: true.

Built-in table policies:

  • user_owns_rows — owner column matches auth.uid(); requires owner_column. With force_owner_on_insert: true, per-table trigger fills owner only when NEW.<owner_column> is NULL; explicit different owners still fail WITH CHECK. service_key bypasses RLS but trigger still runs; admin writes should set owner if no JWT. Best for user-scoped data. uuid owner columns get index-friendly policies; other types use ::text cast with warning; btree index auto-created.
  • public_read_authenticated_write — anyone reads; any authenticated user can INSERT/UPDATE/DELETE any row (not just their own). For collaborative content (shared boards, announcements).
  • public_read_write_UNRESTRICTED — ⚠ fully open; anon_key can read AND write any row. For intentionally public tables only (guestbooks, waitlists, feedback forms). Requires "i_understand_this_is_unrestricted": true on the table entry.
  • custom — escape hatch. Provide custom_sql containing CREATE POLICY statements; they run inside the apply transaction after RLS is enabled + forced.
Policy anon SELECT anon writes auth SELECT auth writes
(omitted from manifest)
user_owns_rows own rows own rows
public_read_authenticated_write all all all rows
public_read_write_UNRESTRICTED all yes all yes

= denied. service_key bypasses all policies. Views are always created with security_invoker=true — they inherit the underlying table’s RLS. RPCs require an entry in rpcs[*] with grant_to to be callable as /rest/v1/rpc/<fn> (since v1.30, CREATE FUNCTION revokes PUBLIC EXECUTE automatically).

Worked example covering all three slices (drop in as manifest.json or under database.expose in a deploy manifest):

{
"$schema": "https://run402.com/schemas/manifest.v1.json",
"version": "1",
"tables": [
{ "name": "posts", "expose": true, "policy": "public_read_authenticated_write" },
{ "name": "notes", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true }
],
"views": [
{ "name": "posts_public", "base": "posts", "select": ["id", "title", "published_at"], "expose": true }
],
"rpcs": [
{ "name": "increment_counter", "signature": "(counter_name text)", "grant_to": ["authenticated"] },
{ "name": "now_utc", "signature": "()", "grant_to": ["anon", "authenticated"] }
]
}

rpcs[*].signature: parenthesized args ("()" for none), regex ^\([^;]*\)$; grant_to non-empty roles (anon, authenticated, service_role, project_admin). Function must exist in migration SQL; manifest grants EXECUTE only. Views require base + non-empty select, are security_invoker=true, inherit base RLS. Ad-hoc: projects apply-expose <project_id> --file manifest.json; inspect with projects get-expose <project_id> (source: "applied" | "introspected"). Manifest is convergent; removed items revoke policies/grants/triggers/views, so include full desired exposed surface each apply.

Deploy:

Terminal window
run402 deploy apply --manifest app.json

Deploy runs migrations, applies database.expose, deploys functions/site/assets, claims subdomains, and updates routes atomically. Set secret values first with run402 secrets set; deploy manifests only declare value-free secrets.require / secrets.delete.

If you want more control:

Terminal window
# 1. Provision a database
run402 projects provision --name my-app
# 2. Create tables
run402 projects sql <project_id> "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, done boolean DEFAULT false)"
# 3. Insert seed data
run402 projects sql <project_id> "INSERT INTO items (title) VALUES ('Buy groceries'), ('Read a book')"
# 4. Declare authorization. Write manifest.json first:
# {"version":"1","tables":[{"name":"items","expose":true,"policy":"public_read_authenticated_write"}]}
run402 projects validate-expose <project_id> --file manifest.json
run402 projects apply-expose <project_id> --file manifest.json
# 5. Deploy a static site (uses active project automatically)
run402 sites deploy --manifest site.json
# 6. Claim a subdomain (uses active project + last deployment automatically)
run402 subdomains claim my-app

  • run402 up [repo-or-path] [--name <name>] [--project <id>] [--manifest <path>] [--dir <path>] [--tier <prototype|hobby|team>] [-y|--yes] [--check|--print-spec|--plan|--require-plan <id>] [--verify] [--propagation-budget-s <seconds>] [--no-propagation-wait] [--quiet] — SDK-owned recursive app deploy. Validates run402.deploy.json/app.json, requires explicit --manifest for executable .ts/.js configs, ensures missing Cloud prerequisites when approved, resolves/creates/links a project, then applies the manifest. Output includes steps[]; success has no top-level status. Use --check for local-only validation, --print-spec for normalized ReleaseSpec, --plan for a gateway-reviewed non-deploying plan, and --require-plan for exact reviewed apply. App HTTP verification reports fresh edge misses as propagation_pending, waits up to --propagation-budget-s (default 120), and --no-propagation-wait returns the pending state immediately. Add --verify to wait for gateway/edge release coherence after the deploy and attach edge_coherence; non-coherence exits 2.
  • run402 up verify [repo-or-path] [--project <id>] [--manifest <path>] [--dir <path>] [--propagation-budget-s <seconds>] [--no-propagation-wait] [--quiet] — rerun manifest HTTP verification (app manifest verify.http[] or deploy-manifest top-level verify) without deploying, uploading, creating a project, or mutating resources. Use it after propagation_pending or before declaring a consumer copy healthy.
  • run402 init — set up with x402 (Base Sepolia). Creates allowance, requests faucet, checks tier, lists projects.
  • run402 init --api-base <url> — configure the active profile to target a Run402 Core/API base. For Core, this does not create an allowance, request faucet funds, or require a Cloud tier.
  • run402 init mpp — set up with MPP (Tempo Moderato testnet). Same steps, different payment rail.

run402 pay <url> [--method <M>] [--body <json-or-text>] [--max-usd <amount>] [--idempotency-key <key>] [--require-receipt] calls an arbitrary x402-priced HTTP endpoint through the SDK buyer. The request body goes in --body and nowhere else: --json is the CLI-wide output-format flag, takes no value, and is a no-op here because pay always prints JSON. Writing --json '<payload>' does not send the payload — it fails BAD_USAGE with a hint naming --body. The default ceiling is $0.10; --max-usd accepts up to six decimal places and is converted exactly to USD micros. --require-receipt requires a verified wallet-rooted offer before payment and a matching receipt afterward. JSON output is the complete x402-commerce-result.v1 envelope with settlement, movement/replay, delivery, offer, merchant-receipt, signer-relationship, policy, and portable evidence. Unpriced URLs return payment: null. For PAYMENT_INTENT_PENDING on a trusted Run402 host, wait for Retry-After and repeat the identical command with the same payer, request, and key. Never substitute a fresh key. Custom/arbitrary hosts and other funds_moved: "unknown" outcomes remain ambiguous and require reconciliation.

Terminal window
run402 pay https://seller.example/translate --method POST \
--body '{"text":"hello"}' --max-usd 0.05 \
--idempotency-key translation:1 --require-receipt

run402 status — show full organization state in one shot (wallet, rail, balances, tier, projects, active project). Read-only, JSON output. Includes a wallet: { local_label, server_label, address } object naming the active named wallet (local_label is the local selector, server_label the server-synced display name or null), a top-level rail, and a balances: { on_chain_usd_micros, on_chain_token, prepaid_credit_usd_micros, held_usd_micros } object. The on-chain token tracks the rail (USDC on x402, pathUSD on mpp); prepaid credit is rail-independent.

Manage multiple named wallets (profiles) on one machine. Keys never leave the machine (non-custodial). The default wallet lives at the config-dir root; named wallets live under {config_dir}/profiles/<name>/.

  • run402 wallets list — JSON array of { local_label, server_label, address, address_short, rail, active }. Reads non-secret meta.json; never loads private keys.
  • run402 wallets current — the resolved active wallet { name, source, source_detail, address, label, warnings }. source ∈ flag|env|binding|config|default. warnings surfaces env-vs-binding conflicts and local-name-vs-server-label drift.
  • run402 wallets new <name> [--mpp] — create a new named wallet (generates a key). { name, address, rail, created: true }.
  • run402 wallets use <name> — set the global default wallet (config.json active_wallet). { name, active: true }.
  • run402 wallets rename <old> <new> — rename a wallet; renaming default migrates its root files into profiles/<new>/. { from, to, renamed: true }.
  • run402 wallets bind [<name>] — write ./.run402.json binding this directory to a wallet (defaults to the active one). Safe to commit (holds only a name). { wallet, file, bound: true, safe_to_commit: true }.
  • run402 wallets unbind — remove ./.run402.json. { file, unbound }.
  • run402 wallets import <name> --key <path|-> — adopt an existing 0x-prefixed 64-hex private key (file path or - for stdin) as a named wallet. { name, address, imported: true }.
  • run402 wallets rm <name> --yes — delete a wallet and its keys. Requires --yes (agent-first: no interactive prompt). Refuses to remove default. { name, removed: true }.
  • Server-side display label: new/rename/import push the wallet’s name to a server-side label (signed by the wallet — proof of control) so the same name shows cross-machine and in the operator console (WEB). Best-effort and on by default; RUN402_WALLET_LABEL_SYNC=0 opts out (fully offline wallet ops). The local folder name is the source of truth; the label is a mirror, and wallets current flags any drift.
  • Selection for ANY command: --wallet <name> (alias --profile) > RUN402_WALLET > nearest ./.run402.json/.run402.local.json > wallets use default > default. A conflicting env + binding errors with WALLET_SELECTION_CONFLICT (resolve via --wallet, unset RUN402_WALLET, or wallets unbind). Selecting a non-existent wallet errors with WALLET_NOT_FOUND.
  • run402 allowance <create|status|fund|balance|export>
  • run402 allowance checkout --amount <usd_micros>
  • run402 allowance history [--limit <n>]

Tier and quotas are per organization (not per project) — set is organization-wide, status.pool_usage is the pooled total across every project in the organization. set refetches status after the call and includes it as status_after.

  • run402 tier status
  • run402 tier set <prototype|hobby|team>

Two surfaces under one command. credentials <verb> acts on PROJECT CREDENTIALS (rows on the gateway); credentials project-keys <verb> acts on the LOCAL key cache on this machine. A project credential (r402_…) is named, listable, expiring and individually revocable, and several may be live per kind at once — that overlap is how you rotate with no downtime. It replaces the legacy anon_key/service_key, which are derived from the platform signing key, never expire, cannot be revoked one at a time, and whose signing key is being retired.

Secrets are returned EXACTLY ONCE, from issue, rotate and token. Full JSON goes to stdout so it can be piped (| jq -r .secret); warnings go to stderr. Never write one of these responses to a result cache or tmp file.

  • run402 credentials status [--project <id>] — am I still on the retiring key? Returns state: "legacy"|"rotatable", rotatable_credentials, credentials[], and retirement.gated_on[]. retirement.deadline is ALWAYS null and that is deliberate: retirement is gated on conditions (every tenant migrated, 30 consecutive days of zero legacy-key use, explicit operator approval), never a date — do not plan against one. Needs only project.read, so automation can check its own posture.
  • run402 credentials issue --kind <anon|service> --name <name> [--project <id>] [--expires <iso8601>] — mint one; secret printed ONCE. --name must be unique among LIVE credentials; re-using a live name returns 409 CREDENTIAL_NAME_TAKEN, and that collision IS the idempotency story (a retried create never silently mints a second credential). --expires must be in the future and within one year.
  • run402 credentials list [--project <id>] [--include-revoked] — metadata only; never a secret or a secret hash.
  • run402 credentials rotate <credential_id> [--project <id>] — mint a replacement and revoke the old one in one transaction, keeping the name and recording replacement_of. New secret printed ONCE. For a rotation with NO downtime window, prefer issuing a second credential, deploying it, then revoking the first; use rotate when the old secret is already compromised.
  • run402 credentials revoke <credential_id> [--project <id>] [--reason <text>] — immediate, and frees the name for reuse.
  • run402 credentials token [--project <id>] [--kind <anon|service>] — mint a SHORT-LIVED token (defaults to service). This is the cold-restart recovery path and the ONE credential call an agent can make with no human present: a delegate is accepted. No step-up, because there is nobody to prompt; what it returns expires, so it cannot become a durable root.

Authority split: issue/rotate/revoke require owner membership on the project’s owning org PLUS a fresh step-up, and a delegate can NEVER satisfy them — otherwise a scoped agent credential could escalate itself into a permanent root. Authenticate with a wallet (SIWX) or a control-plane session (run402 operator login --step-up). token is the deliberate exception.

  • run402 credentials project-keys list — LOCAL CACHE read. Lists cached project-key entries with source: "local_cache", cache_path, wallet/profile, key presence, prefixes, fingerprints, and timestamps. Never prints full keys.
  • run402 credentials project-keys status --project <id> — LOCAL CACHE read for one project id. configured: false means this selected wallet/profile lacks cached keys; it does not mean the server project is missing.
  • run402 credentials project-keys import --project <id> --service-key-stdin — import a service key from stdin. Optional anon key comes from --anon-key-env <env>.
  • run402 credentials project-keys import --project <id> --service-key-env <env> — import a service key from an environment variable. Do not pass service keys as argv values.
  • run402 credentials project-keys import --project <id> --anon-key-env <env> — anon-only rotation. Import writes the whole cache entry, so the FIRST import for a project must supply a service key; once an entry exists, --anon-key-env alone rotates the anon key and keeps the cached service key. Rotating the anon key therefore never requires exporting the service key with --reveal and passing it back through a shell. Passing --anon-key-env with no cached service key fails with BAD_USAGE naming that flag.
  • run402 credentials project-keys export --project <id> --reveal — print cached secret key material. Requires --reveal.
  • run402 credentials project-keys remove --project <id> — remove one local cache entry without deleting or changing the server project.
  • run402 projects quote
  • run402 projects list [--org <id>] [--all] — SERVER read of the named, domain-aware inventory (NOT the local project-key cache). Membership-scoped by default: every project owned by an org your wallet is an active member of, each row { project_id, name, site_url, custom_domains, org_id, status, active } (active from local state). --org <id> filters to one org (authorize-before-reveal: non-member/guessed id -> 403, non-UUID -> 400). --all reads the cross-wallet inventory across every wallet controlling your operator email — run run402 operator login first for the union, else it falls back to the current wallet’s slice and echoes scope. Bare run402 projects list is the cold-start path (no login needed). Tier/lifecycle live on the organization — use run402 status / run402 tier status.
  • run402 projects rename <id> --name <label> — rename a project (fix an auto-generated name). Needs org admin+ (or a project:write grant) on the owning org; authorize-before-reveal (unauthorized/guessed id -> 403, never a not-found oracle). Works even if the project isn’t in the local project-key cache. Server-validated name (1-200 chars, no control characters).
  • run402 projects provision [--name <name>] [--org <id>]--org provisions into an EXISTING org (you need developer+ on it); omit for the cold-start path (the wallet’s organization). Tier is org-governed — a client-supplied --tier is ignored when targeting an org. Against a configured Core target, creates a local Core project without Cloud payment and saves it as the active project.
  • run402 projects use <id> — SERVER validation, then stores only the active project id in profile state. It does not require a matching local project-key cache entry.
  • run402 projects current — LOCAL STATE read of the active project pointer with profile-state provenance and validation status.
  • run402 projects get <id> — SERVER read of one project’s authoritative view: { project_id, public_id, name, org_id, tier, effective_status, organization_lifecycle_state, site_url, custom_domains[], last_deploy, mailbox[], usage{api_calls, storage_bytes, api_calls_limit, storage_bytes_limit}, created_at }. Caller-authed (SIWX/control-plane, no project keys) and works even if the project isn’t in the local project-key cache; authorize-before-reveal (unauthorized/guessed id -> 403, never a not-found oracle). Returns NO keys — use run402 credentials project-keys ... for local credential-cache inspection/export.
  • run402 projects info <id> — moved before public launch. Returns structured COMMAND_MOVED; use projects get <id> for server detail or credentials project-keys status --project <id> for local cache status.
  • run402 projects sql <id> "<sql>" [--file <path>] [--params '<json_array>']
  • run402 projects rest <id> <table> "<query>"
  • run402 projects keys <id> — moved before public launch. Returns structured COMMAND_MOVED; use credentials project-keys export --project <id> --reveal for cached key material.
  • run402 projects costs <id> [--window <24h|7d|30d|90d>] — admin-only per-project finance: revenue, direct cost, margin, and cost breakdown. Uses the configured allowance wallet for admin auth; RUN402_ADMIN_COOKIE='run402_admin=...' is an optional browser-session override.
  • run402 projects promote-user <id> <email> — promote a user to project_admin role
  • run402 projects demote-user <id> <email> — demote a user from project_admin role
  • run402 projects <usage|schema> <id>
  • run402 projects delete <id> --confirm — cascade deletes all project resources: Lambda functions, subdomains, S3 site files, deployments, secrets, and published app versions. The schema slot is dropped and recreated. This is irreversible. --confirm is required.
  • run402 projects validate-expose [id] <manifest_json> — validate an auth/expose manifest without applying it
  • run402 projects validate-expose [id] --file manifest.json [--migration-file setup.sql] — validate file input with optional migration-reference SQL
  • run402 projects apply-expose <id> <manifest_json> — apply a declarative authorization manifest
  • run402 projects apply-expose <id> --file manifest.json — apply from a JSON file
  • run402 projects get-expose <id> — print the current manifest (source: applied | introspected)

Provisioning automatically sets the new project as the active project. Other commands that take <id> default to the active project when omitted.

SQL supports DDL + queries, returns JSON. REST uses PostgREST syntax (select=, eq., order=, limit=).

User auth: password + Google OAuth. See “User Auth” section below.

run402 repos is your source, encrypted before it leaves the machine — a Git remote whose contents are encrypted client-side and stored as a chain of signed, admitted heads (wire protocol r402s/v0). One noun, twelve verbs (repo singular resolves identically — say what gh repo already taught you). run402 gitvault <verb> answers a structured COMMAND_MOVED (naming its repos successor) or, for push/reconcile (no behavioral successor), COMMAND_REMOVED. r.gitvault is unchanged in the SDK — gitvault stays the protocol/infrastructure name; repos is what you type.

What Run402 claims about it, and how strong each claim is. These are the entire approved claims vocabulary — three separate promises with three separate strengths:

  1. Run402 cannot decrypt your gitvault or repository history. Deployment artifacts remain a disclosed plaintext custody boundary. Cryptographic, against Run402 itself: source payload and repository-history content are ciphertext-only; the substrate retains only enumerated plaintext metadata and holds zero vault keys. The deploy lane is separate — the platform custodially holds the plaintext artifacts of every deploy, and says so.
  2. Activation requires vault admission by default; an explicit, audited override can bypass it. An operational platform invariant, not a cryptographic one — the platform is the party enforcing it.
  3. Retention is an operational promise of the platform, not a cryptographic guarantee against it (the host controls timestamps and bytes).

Naming law: a verb is either exactly a gh repo verb (create/list/view/rename/delete), exactly a git verb meaning what it means in git (gc, fsck), or a plain-English verb for an operation with no analog (snapshot, policy, mirror, access, recover) — never a git verb reused for a different meaning. That rule is why the offline-restore verb is recover, not restore: git restore already means something else.

Tiered help — three or four verbs loud, the rest occasional or maintenance:

Common: repos create [name], repos view, repos list
Then git: git push, git clone run402::<org>/<repo>
Occasional: repos snapshot, repos mirror, repos recover
Lifecycle: repos rename, repos delete
Maintenance: repos fsck, repos gc, repos access, repos policy
Verb What it does
create [name] Provision a project (or, with --project <id>, ADOPT an existing one), ALLOCATE its vault (mints key material and, on first allocation, a one-shot recovery receipt), and scaffold the remote — origin when free, run402 when taken. [name] is inferred from an existing git remote’s basename or the directory name when unambiguous — never a prompt; if the directory and an existing remote disagree, or nothing usable can be derived, this is a structured error naming exactly one next_action. The response’s next_action is the exact git push to run. Nothing is deployed, ever, unless you separately choose to — a vault-only project is a first-class shape, not a half-configured one.
view [--human] Side-effect-free: what this machine and the control plane each believe — allocation, policy, the local keystore, the authenticated and materialized pins, the mirror summary when one is configured, and where the keystore lives. NEVER materializes refs or advances a local pin — refs reports {known:false, reason:"not_materialized"} with a verify_refs next_action pointing at fsck, which is the one place materialization happens now. --human renders a short summary instead of JSON.
list [--human] The organization’s vault-bearing repos, via the bulk GET /gitvault/v1/vaults?org_id= read when the gateway has it (one round trip) — gracefully falls back to the older per-project walk when that route 404s. Not every project in the org; ones with no vault are omitted. --human renders a compact roster (address, generation, bytes, policy) instead of JSON.
rename <new_name> Claim or rename the repo’s per-org-unique, address-form name — the <name> half of run402::<org-slug>/<name>. Address with --repo <repo_id> or --project <id> (not both).
delete Deletes a REPO-ONLY project: database, functions, subdomains, mailbox, and secrets must all be absent. When any is materialized, this REFUSES with PROJECT_HAS_NON_REPO_RESOURCES, enumerating refused_resources and pointing at run402 projects delete — the verb whose name says what it destroys. --force overrides ONLY the separate vault-history confirmation (the repo holds admitted generations) — it never overrides the non-repo-infra refusal. Success enumerates deleted_resources.
snapshot [--allow-dirty] [--manifest-out <path>] Capture the working tree and publish it. Not gated on a deploy — a vault-only repo snapshots for months without one. Against a project with no vault yet, this ALLOCATES one inline before publishing. Push-to-creates through a slug-form remote (run402::<org-slug>/<name>) the same way git push does. --dry-run previews the real local pipeline without publishing. A dirty tree (any modified/staged tracked path, or any untracked-not-ignored path) refuses by defaultSNAPSHOT_DIRTY_TREE, before any object is created, with details.modified/details.untracked naming every path and next_actions pointing at committing or --allow-dirty. --dry-run surfaces the same refusal (a preview that hid it would lie). --allow-dirty captures the tree as-is; the result discloses exactly what got swept in via modified_captured/untracked_captured (printed to stderr too) — even the override never captures silently. The same rule applies to the deploy-lane capture (deploy apply against a gitvault_policy: required project) — see --allow-dirty there. Both --dry-run and a real snapshot print a SUMMARY by default — files_total/files_changed/files_new counts, total/delta bytes, and up to 200 changed_paths (changed_more names any overflow) — never the full captured-file inventory (which can run to thousands of entries on a real repo). --manifest-out <path> writes the complete, untouched inventory to a private 0600 file and names it in the result’s manifest_path; -v/--verbose inlines the full inventory directly in the JSON, composing with (not replacing) its usual stderr stats line.
mirror [<destination>] ONE flag-driven verb for the client-side, customer-owned ciphertext mirror — Run402 never holds a credential to it. No argument: READ the configured destination plus a keyless freshness check. <destination>: configure (idempotent upsert). --off: remove the config only — never touches the mirror’s own bytes. --backfill: copy every object the mirror is missing. Exactly one of these per call. State also renders inside view; integrity inside fsck --mirror.
fsck [--human] Walks the head chain AND materializes the ref map, advancing BOTH local trust pins — reported explicitly as local_state_changed + pin_before + pin_after, never implied. --no-write is a genuine audit mode: the same real walk and decrypt, persisting neither pin. --budget <n> caps heads walked per call. --mirror additionally runs the keyless mirror integrity probe — proves the mirror’s VALIDITY, never its FRESHNESS. --human renders a short summary instead of JSON.
gc git gc’s own two halves — checkpoint publication and prune planning — in one verb, not described as “exactly git gc”: the deletion ceremony is stricter. Plans and checkpoints by default; nothing is deleted until --submit --intent-core <path> --verifier-receipt <path> supplies both receipts the two-phase protocol requires. The plan’s submit next_action carries destructive: true / requires_approval: true / safe_to_auto_execute: false as additive fields.
access [--human] READ-ONLY: the org’s directory of encryption-key-holding members, which of the vault’s current envelope-recipient fingerprints are covered, per-recipient envelope_state (converged/pending/pending_removal, from the gateway’s desired-recipient-state substrate), and (best-effort, this machine only) each principal’s local TOFU pin. stale_access names removed members whose access was NOT actually revoked yet — pending_removal is honest bookkeeping, not enforcement. States an honest remaining gap rather than inventing one: history_scope (which epochs each recipient can read) has no substrate to report — gitvault protocol v0 pins a single fixed epoch, so there is no per-epoch scope yet. An enrolled teammate’s key envelope is wrapped AUTOMATICALLY — no manual step — by the next git push or repos snapshot any key-holding client runs (best-effort, non-blocking; the retired gitvault reconcile verb did this by hand and is REMOVED). access repair (a general re-key clearing stale_access / a first-ever migration; needs --recipient-state-version/--recipient-revocation-version), access revoke-key <principal_id> (self-contained, no flags), and access declare-exposure are the explicit owner+step-up rotation entry points. --human (read form only) renders a compact roster instead of JSON.
policy <required|grandfathered> Set the activation policy. Owner + step-up, audited. grandfathered needs --reason; returning to required does not. Allocating a repo never sets this.
recover <source> --out <dir> [--human] r402s-recover: rebuild a BARE recovery repository (no working files) straight from a mirrored prefix, with NO SERVER INVOLVED — the offline disaster path. Kept its name (design D10): restore was considered and rejected because git restore already means something else. The result’s layout is "bare" and its next_actions print the exact git clone <out_dir> <out_dir>-worktree to run for a working tree — recover itself never checks files out. Normal retrieval is plain git clone run402::<org>/<repo> — there is no repos clone verb. Cloning needs a Run402 principal on this machine — a wallet with an allowance and a keystore holding an envelope for this vault — this is encrypted git, not a shareable link. A fresh clone installs local refs/r402/retain/<oid> refs for every retained deploy-capture tip no branch reaches, so git fsck is silent — git for-each-ref refs/r402/ lists what is retained. Clones made by a client older than this one (or a checkout whose ref write degraded) may still show dangling commits under git fsck; harmless, not corruption — one run402 repos fsck run installs the missing refs. A retained ref locally pins that history against git gc until the vault prunes the capture, at which point the next fetch retracts it. --human renders a short summary instead of JSON.

Every verb above also accepts -v/--verbose (one stderr summary line of this call’s request stats — round trips, wire time, bytes — coexists with --human) and always carries a stats block in its JSON result. See Observability below.

Terminal window
run402 repos create # name inferred from cwd/remote
run402 repos create my-notes --org org_1a2b3c # explicit name, new project
run402 repos create --project prj_xyz # adopt an existing project (absorbs `gitvault init`)
git push -u origin HEAD # the printed next_action, verbatim
run402 repos view --human
run402 repos list --org org_1a2b3c
run402 repos rename my-notes --project prj_xyz
run402 repos delete --project prj_xyz --force # only once you've read what --force would lose
run402 repos snapshot --dry-run
run402 repos snapshot --dry-run --manifest-out /tmp/snapshot-plan.json
run402 repos mirror s3://acme-vault-mirror --profile acme
run402 repos mirror --backfill
run402 repos fsck --mirror
run402 repos gc
run402 repos access
run402 repos recover s3://acme-vault-mirror --out ./restored

MCP. Three read-only tools carry the SAME one-noun renaming: repos_view, repos_list_heads, repos_fsck. No mutating verb has an MCP tool, and none will — mutating verbs are CLI-only by design (immutable generations with no undo, the one-shot recovery receipt, the once-returned maintenance lease token, destructive gc --submit, owner+step-up policy).

Wallet selection, remote naming, id-pinning, named addressing, lazy allocation, the vault-only track, dry-run preview, --human rendering, the deploy-time capture lane, gc’s two-phase submit ceremony, expiry, terminal loss, the backup reminder, and mirroring to your own storage are all covered in the sections below.

Cost. There is no separate repos price. A repo’s bytes count against the same organization-pooled storage_bytes budget your projects already share, charged once per unique object, with a 4 KiB per-object accounting floor and a 1 MiB per-vault minimum.

One thing to know before you rely on this: V0 is single-principal. Exactly one machine (this keystore) can open the vault until human envelopes ship. This is stated plainly, not buried: back up the keystore directory the moment you have something in it worth keeping.

Terminal loss (protocol §0). In V0-A, whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship. view prints the full statement verbatim on stderr and carries it in its JSON — read it before you rely on this. The vault protects source history from host-side loss while a principal keystore survives.

  • run402 admin lease-perpetual <organization_id> --enable | --disable — toggle the organization-level escape hatch. When enabled, the organization never advances past active regardless of lease expiry; every project on the organization is pinned. Enabling on a grace-state organization (past_due / frozen / dormant) reactivates inline (reactivated: true in the response).
  • run402 admin archive <project_id> [--reason "..."] — operator moderation. Sets projects.archived_at = NOW() on a single project; sibling projects on the same organization keep serving. No-op when already archived (returns note: "already archived").
  • run402 admin reactivate <project_id> — un-archive a project (flips archived_at back to NULL). It does not touch organization-level lifecycle. To reactivate a grace-state organization, run run402 tier set <tier> (the tier flow runs the lifecycle advance inline) or enable run402 admin lease-perpetual <org_id> --enable.

All admin subcommands require a platform-admin allowance wallet (or an admin OAuth session). Project owners with a non-admin wallet receive 403 admin_required.

  • run402 apply --manifest app.json [--project <id>] [--rehearse] [--teardown keep|on_pass|always] [--commit] [--json] — alias for run402 deploy apply; --rehearse runs the reviewed plan on a contained branch first and does not commit unless --commit is present.
  • run402 deploy rehearse <plan_id> [--project <id>] [--teardown keep|on_pass|always] [--json] — rehearse an already-persisted reviewed plan. Source project data and the original plan stay untouched.
  • run402 snapshots create [project-id] [--json] — capture a manual internal restore point.
  • run402 snapshots list [project-id] [--kind manual|pre_migration|pre_restore|scheduled] [--limit <n>] [--after <cursor>] [--json] — keyset-paginated snapshot list.
  • run402 snapshots get [project-id] <snapshot-id> [--json] — inspect one snapshot and its next actions.
  • run402 snapshots restore [project-id] <snapshot-id> [--include-auth] [--json] — plan a restore and print restore_plan.confirm.token; no mutation.
  • run402 snapshots restore [project-id] <snapshot-id> --confirm <token> [--include-auth] [--json] — execute the atomic offline-materialize-then-flip restore. Auth users/passkeys are restored only with --include-auth; sessions and tokens are never restored.
  • run402 snapshots delete [project-id] <snapshot-id> [--json] — delete a snapshot and release its CAS references.
  • run402 branches create [project-id] [--from-snapshot <snapshot-id>] [--name <label>] [--email-mode sandbox|off] [--enable-cron] [--ttl-days <1..30>] [--json] — create a contained branch project. Email defaults to sandboxed, scheduled functions default off, and TTL defaults to 7 days.
  • run402 branches list [project-id] [--json] — list active branches for the parent project.
  • run402 branches renew [project-id] <branch-project-id> [--ttl-days <1..30>] [--json] — extend a branch TTL.
  • run402 branches delete [project-id] <branch-project-id> [--json] — delete a branch project and purge its resources.
  • run402 deploy apply --manifest app.json [--project <id>] [--check|--print-spec|--plan|--rehearse|--require-plan <id>] [--teardown keep|on_pass|always] [--commit] [--json] [--quiet|--final-only] [--allow-warning <code> ...] [--allow-warnings] [--allow-dirty] [-v|--verbose] — unified apply primitive with assets slice support; accepts JSON data manifests and explicit executable typed configs through the same --manifest flag. --rehearse runs migrations/checks against a contained branch first; --json is accepted as a no-op because success output is always JSON. Against a gitvault_policy: required project, the deploy-lane capture applies the SAME dirty-tree rule repos snapshot does: a dirty work tree refuses SNAPSHOT_DIRTY_TREE before any capture, and --allow-dirty captures it as-is with the same modified_captured/untracked_captured disclosure (see the repos section above and Observability). The final result always carries a stats block; -v/--verbose also prints a one-line stderr summary.
  • run402 deploy resume <operation_id> [--project <id>] [--quiet] — re-run a stuck operation forward
  • run402 deploy promote <release-id> [--project <id>] [--allow-warning <code>] [--allow-warnings] — operator pointer-swap (re-point live release without re-running the apply pipeline); v1.58+
  • run402 deploy list [--project <id>] [--limit <n>] — list recent deploy operations
  • run402 deploy events <operation_id> [--project <id>] — fetch the recorded event stream for an operation
  • run402 deploy verify <operation_id>|--operation <operation_id> [--project <id>] [--wait] [--timeout <seconds>] [--json] — verify gateway/edge release coherence; exits 2 when a valid report remains not coherent
  • run402 deploy release get <release_id> [--project <id>] [--site-limit <n>] — fetch release inventory
  • run402 deploy release active [--project <id>] [--site-limit <n>] — fetch current-live release inventory
  • run402 deploy release diff --from <empty|active|release_id> --to <active|release_id> [--project <id>] [--limit <n>] — diff release targets
  • run402 deploy diagnose [--project <id>] <url> [--method GET] — URL-first public deploy diagnostics
  • run402 deploy resolve [--project <id>] (--url <url> | --host <host> [--path /x]) [--method GET] — lower-level resolver parity; --url cannot be combined with --host/--path

Requires active tier and a provisioned project on Run402 Cloud. Against a configured Core target, uses the active Core project and does not require Cloud tier/allowance setup. Deploys to an existing project: runs migrations, applies the authorization manifest (from a manifest.json entry in files[]), deploys functions, deploys static site, and claims subdomain when the target supports that slice. Secret values are write-only: set them with printf %s "$OPENAI_API_KEY" | run402 secrets set <id> OPENAI_API_KEY --stdin or --file <path> before deploy, then use value-free secrets.require in deploy apply manifests. deploy apply stops before upload/commit on confirmation-required warnings unless each warning is covered by repeatable --allow-warning <code> or the broad reviewed --allow-warnings; --require-plan absorbs the warning approval already bound into the reviewed plan and rejects warning flags. The manifest must include project_id (or use --project flag, or omit both to use the active project).

Inside GitHub Actions, deploy apply automatically uses OIDC credentials when GITHUB_ACTIONS=true, ACTIONS_ID_TOKEN_REQUEST_URL, and ACTIONS_ID_TOKEN_REQUEST_TOKEN are present. In that mode, project id resolution is --project, then manifest.project_id, then the local active project if present, then RUN402_PROJECT_ID.

  • run402 ci link github [--project <id>] [--manifest <path>] [--repo <owner/repo>] [--branch <name> | --environment <name>] [--repository-id <id>] [--workflow <path>] [--expires-at <iso>] [--route-scope <pattern> ...] [--force] — create a GitHub Actions OIDC deploy binding and write a workflow
  • run402 ci list [--project <id>] — list CI bindings for a project
  • run402 ci revoke <binding_id> — revoke a binding

link github requires a local allowance because it signs the delegation. The generated workflow does not require an allowance file or service key in GitHub; it uses GitHub’s OIDC token with id-token: write. Use repeatable --route-scope only when CI should deploy route declarations; no scopes means no CI route authority.

transfer (unified project transfer — wallet + email + owned-org recipient v1.96+)

Section titled “transfer (unified project transfer — wallet + email + owned-org recipient v1.96+)”

One noun, three recipient kinds: wallet = two-party SIWX, completed by accept; email = email->org, completed by claim; owned org = --to-org <org_id> immediate same-actor org move. Same /projects/v1/:id/transfers; preview/list/cancel are kind-agnostic; rows carry recipient_kind. Pre-v1.93 /handoffs and --handoff(s) are gone.

  • run402 transfer init (--to <wallet|email> | --to-org <org_id>) [--project <id>] [--billing-policy migrate] [--message <text>] [--kysigned <record_id>] [--retain-collaborator developer] — owner/admin initiate. --to routes by kind. --to-org: caller must actively own source+destination org; success completes immediately with accepted result + project keys. --retain-collaborator developer: email recipients only, only developer, rejected with BAD_FLAG on wallet/org rails; recipient must accept at claim; omit = full severance. --billing-policy/--kysigned wallet-only; email/org reject. Codes: INVALID_RETAIN_ROLE, RETAIN_SUBJECT_REQUIRED.
  • run402 transfer preview <transfer_id> — fetch the preview document (any party; kind-agnostic). Lists custom domains, subdomains, function names, secret NAMES (never values), CI bindings that will be revoked on completion, mailbox summary, billing implications, and — on email transfers — the retain_collaborator offer.
  • run402 transfer list [--incoming | --outgoing] [--limit N] [--offset N]--incoming (default) shows transfers OFFERED TO you; --outgoing shows transfers you initiated. Pending rows are unioned and each entry carries recipient_kind and preview_path.
  • run402 transfer accept <transfer_id> — accept WALLET transfer. Atomically flips ownership, revokes previous owner CI bindings, stamps secrets_rotation_advised. Secret values inherited; response has secret_names_inherited[], new owner anon_key + service_key; SDK/CLI persist keys and set project active.
  • run402 transfer claim <transfer_id> [--into <organization_id>] [--accept-retained-collaborator] — claim EMAIL transfer into owned org; omit --into to create new org. Email analog of accept. --accept-retained-collaborator accepts retained developer offer from preview; omit = severance. Result includes retained_collaborator_principal_id|null, keys persisted + project active. Keys derive from project_id and do not rotate; rotate inherited secrets with run402 secrets set.
  • run402 transfer cancel <transfer_id> [--reason <text>] — cancel a pending transfer of any kind (any authorized party).

Pending transfer: 72h TTL; owner-side mutations return 409 PROJECT_HAS_PENDING_TRANSFER with details.transfer_id + cancel next_actions[]. Data-plane and payment routes keep serving; transfer cancel unblocked. After accept/claim, rotate inherited secrets; secrets_rotation_advised clears after every inherited name is rewritten.

Does not transfer: tier lease (stays with original org; no proration), KMS signers, GitHub repo ownership, on-chain balances. Wallet transfers only support --billing-policy migrate; if recipient lacks active org, accept -> 409 RECIPIENT_ORGANIZATION_NOT_ACTIVE. Email/owned-org always migrate; do not pass --billing-policy.

org / grants (v1.77+ org-owned control plane; first-class orgs v1.82)

Section titled “org / grants (v1.77+ org-owned control plane; first-class orgs v1.82)”

Wallet authenticates; org owns projects. Authorization = org role (owner > admin > developer > billing > viewer) or per-project grant, never wallet == signer. Member/invite changes require active owner; subresources: org member ..., org invite ...; memberships carry org_id + display_name. Create/rename/member/invite are step-up gated for control-plane sessions.

  • run402 org create [--name <label>] — create an empty org on the prototype tier; you become owner. --name is an optional free-text label (non-unique, not an id; no tier input). Response includes tier, lease_started_at, and lease_expires_at. The soft per-owner free-org cap may return FREE_ORG_OWNER_LIMIT_EXCEEDED.
  • run402 org get <org> — read one org: { org_id, display_name, tier, lease_started_at, lease_expires_at, role }. Any active member; a non-member (incl. a guessed id) gets the same non-revealing 403.
  • run402 org rename <org> <display_name> (or --clear) — owner-only; set or clear the org’s free-text label. Response includes tier, lease_started_at, and lease_expires_at.
  • run402 org slug <slug> [--org <org_id>] — owner-only; claim or rename the org’s globally-unique, address-form slug (repo-first-onramp design D6, task 4.1) — the <org-slug> half of run402::<org-slug>/<name>. Grammar: lowercase [a-z0-9-], no leading/trailing/double hyphen, ≤39 chars. A genesis claim (no prior slug) debits a small one-time claim fee; a rename is free but releases the OLD slug into a ~90-day cooldown — it stops resolving with a typed SLUG_RELEASED refusal naming the new slug as successor, never a redirect. Paid + side-effecting: requires Idempotency-Key, generated client-side unless --idempotency-key is passed, so a retried call after a dropped response cannot double-bill. CLI/SDK only — no MCP tool.
  • run402 org whoami — resolve your control-plane principal + org memberships (GET /agent/v1/whoami). The REMOTE identity; for local wallet/profile state use run402 status.
  • run402 org list — orgs you are a member of (org_id, display_name, role, status each).
  • run402 org bind [--org <org_id>] [--room <key>]the bootstrap. Writes this checkout’s org (and room) into .run402.json, which you COMMIT; every later command in that tree resolves flag-free, and worktrees inherit it. Omit --org and it uses your organization when you have exactly one, or lists them and stops when you have several. Omit --room and the room key is your directory name, slugified. This picking-for-you is deliberately NOT the same act as the resolution chain, which never infers: bind runs once because you asked, and WRITES THE ANSWER DOWN — nothing is inferred on any later command. run402 org unbind removes the org/room keys (a wallet key in the same file survives, and vice versa).
  • run402 org use <org_id> / run402 org current / run402 org clear — the CURRENT org, the org tier’s answer to projects use. The selection is per WALLET PROFILE, not global: the chain is wallet -> principal -> memberships, so a global selection would survive wallets use other and then either 403 or silently resolve to a valid-but-wrong org. org current reports the resolved org plus org_source / org_source_detail, and an empty selection as an explicit null rather than a guess.
  • Current-org precedence (one chain, every org-scoped command): flag (--org, else --project’s org) > environment (RUN402_ORG, else RUN402_ROOM’s org half, else RUN402_PROJECT_ID’s org) > binding (org in the nearest .run402.json / .run402.local.json, walking up) > profile state (org use, else projects use). Inside each class a directly named org outranks one derived from a project. projects use also stamps that project’s org as current. Nothing infers your org from having exactly one membership — that is server state that changes without you acting. An RUN402_ORG that disagrees with a binding file is a hard AMBIGUOUS_ORG error naming both, resolved by --org; missing entirely is ORG_REQUIRED listing every way to supply one.
  • run402 org audit <org> [--limit N] [--before <cursor>] — control-plane audit trail for the org (admin+); newest-first, page with --before.
  • run402 org member list <org> — members + roles of an org.
  • run402 org member add <org> <wallet> [--role <role>] — add a member BY WALLET (a new wallet is provisioned as a human principal); --role defaults to developer.
  • run402 org member role <org> <principal_id> <role> — change a member’s role.
  • run402 org member rm <org> <principal_id> — revoke a member.
  • run402 org invite list <org> — pending email invites.
  • run402 org invite create <org> <email> [--role <role>] [--ttl-hours N] — invite a person by email; --role defaults to developer. The invite is claimed automatically when the recipient first signs in via that verified email (run402 operator login --loopback, or any hosted email/OAuth login) — it then surfaces as an active membership in the login output and org member list. There’s no invitee-side “accept” step. Owner/admin invites only claim once the recipient has enrolled a passkey; lower roles claim on any login.
  • run402 org invite rm <org> <principal_id> — revoke a pending invite.
  • run402 grants create <project_id> <wallet> <capability> [--policy <json>] [--expires <iso8601>] — issue a per-project capability grant (e.g. deploy, functions:write) to an agent/CI principal. Requires owner of the project’s org.
  • run402 grants revoke <project_id> <grant_id> — revoke a grant.

org member role/org member rm that would drop the org’s only active owner fail with 409 LAST_OWNER — promote another member to owner first. Principal ids (prn_…) come from run402 org member list.

events — what happened since you last looked

Section titled “events — what happened since you last looked”

The cursored project events feed: deploy activations, mailbox suspensions, transfers, lifecycle cliffs, verification outcomes — one durable, ordered record, each event carrying platform-suggested next_actions[]. The feed also carries app-emitted business facts (a deployed function’s own events.emit(...) calls from @run402/functions) alongside those platform events, source-discriminated.

  • run402 events [--project <id>] [--cursor <cursor>] [--limit <n>] [--source <app|platform>] [--type <name[,name]>] — read a page of the project’s feed (JSON envelope {events, cursor, has_more, reset}, plus platform_incidents / platform_status during an open incident, to stdout). --project defaults to the active project.
  • run402 events --org <org_id> [--cursor <cursor>] [--limit <n>] [--source <app|platform>] [--type <name[,name]>] — every fact the organization owns (members only; a project service_key cannot read sibling feeds). A superset of the project feeds, not a union of them: it also carries organization-level facts, which belong to the org and to no project, arrive with project_id: null, and are unreachable from any project feed.

Cursor contract — an id is not a cursor: the response’s cursor is the high-water mark. Store it (a file in your repo, wherever you keep state) and pass it back as --cursor next time; one call returns everything you missed. Both tokens are opaque (evc_…, never parse), but they mean different things. An event’s id names a fact — the same event carries the same id in the project feed and the org feed, which is how you dedup across both. The page cursor names a position, and a position only means something inside the row set it came from, so it is bound to that view (project vs org, plus any --source / --type filters). Replaying a --project cursor against --org, an unfiltered cursor against a filtered read, or an event id in place of a cursor all return reset: true instead of resuming — resuming would silently skip exactly the rows the other view omitted. Store each cursor against the read shape that produced it.

Events become visible within a couple of seconds of commit; that watermark is a bound rather than a proof (it gives a write’s commit window time to close), and in practice a cursor read misses nothing that committed before it was issued. An unusable cursor returns reset: true + earliest_cursor (restart point) — never a bare error, never a silent skip.

Retention is age and class only: 90 days, 365 for security/recovery/billing-critical classes. Deleting a project does not delete its events — a fact outlives the project it describes, so project_id may name a project that no longer exists. Organization purge is what erases. Read-only and never lifecycle-gated: a frozen project’s feed stays readable.

Event shape: { id, project_id, event_type, class, source, occurred_at, payload, next_actions }. project_id is what the fact is about, and is null for an organization-level fact.

App events vs platform events: every row is source-discriminated. --source platform restricts to the platform’s own operational record (the platform’s internal producers — gateway, email-lambda, … — all collapse under this one value); --source app restricts to business facts a deployed function emitted itself via events.emit(type, payload?, {idempotencyKey?}). --type <name[,name]> further restricts to one or more event types (comma-separated, e.g. signature_completed,booking_created) and composes with --source. Omit --source to read both lanes in one merged, cursor-ordered feed; key on the pair (source, event_type) together, since app-chosen type names are free-form per app and only the pair disambiguates them from the platform’s own vocabulary — e.g. run402 events --source app --type signature_completed.

Platform incidents — my bug or yours? When a platform incident (a debounced CloudWatch-alarm window or a human-declared incident) is attributed to your project, a platform_incident event lands in this feed (class platform_incident, retained 365 days) with a compact-fact payload { incident_id, subsystem, severity, scope, status, started_at, resolved_at, summary, impact: { count } }impact.count is the real count of your invocations the platform, not your code, made fail (may be null for a manually-declared impact); its next_actions[] carry a poll plus a check_usage drill-down into run402 errors. During an OPEN incident the page also carries a sidecar platform_incidents[] overlay (open GLOBAL/unattributed incidents, each with a stable id for dedup — never interleaved into events[]) and a platform_status: "degraded" rider (omitted when clear; shared with run402 operator status and the tier-status read).

After every deploy, the apply/promote response’s next_actions[] includes a poll entry for this feed with a cursor positioned just before your own deploy_activated event — poll once before signing off to establish your cursor.

rooms / claims — coordinate with the other agents on your project

Section titled “rooms / claims — coordinate with the other agents on your project”

Org-scoped coordination rooms for the agents working on the same thing: session presence (“who’s here, doing what”), durable room-visible messages, and advisory work claims (“I’m on src/auth/** until 14:30”). A project id names that project’s default room — the room key IS the project id — so two sessions in the same checkout land in the same room with zero flags, and rooms auto-vivify on first use (there is no create call). The CLI caches your session presence + read cursor per room in ./.run402/messaging.json (gitignore it).

  • run402 rooms join [--name <name>] [--task <text>] [--all] — arrive: register this session’s presence and see who is live in the room (name, task, active claims); registers your session presence on first use (JSON {org_id, room_key, you, …} to stdout; --all includes expired presences).
  • run402 rooms leave [<presence_id>] — you’re done: release this session’s seat so it stops reading as live and stops holding its claims. Takes no argument in the normal case (it uses the presence this checkout cached at join); pass a prs_… only to release a specific one. Scoped to your PRINCIPAL: another principal’s presence is never touched, but a seat held by one of your OWN other sessions can be released — which is how a fresh session clears a crashed predecessor. Idempotent: already expired, already released, or another principal’s reports left: false rather than failing, so a retry after a crash is safe. Without it a finished session lingers for the rest of its ~1h TTL and the next agent reads a room with a phantom colleague holding repo:packages/gateway/**.
  • run402 messages send <body> [--to <a,b>] [--cc <a,b>] [--ack] [--thread <id>] [--importance normal|high] [--idempotency-key <k>] — send a room-visible markdown message (≤32 KiB; over-cap is rejected, never truncated); prints the stored message + your sender_presence. --to routes attention (comma-separated presence names) and --ack asks those recipients to acknowledge; an --idempotency-key replay returns the ORIGINAL message with deduplicated: true, never a double-post.
  • run402 messages list [--unread] [--cursor <mcr_…>] [--before <mcr_…>] [--thread <id>] [--limit <n>] — read messages oldest-first from your stored cursor (auto-saved per room; --limit default 50, max 200). --unread restricts to messages addressed to you that you haven’t read; --before pages OLDER history newest-first (display mode — never advances the stored cursor).
  • run402 messages get <message_id> — one message with its FULL body (lists carry snippets) + ack state.
  • run402 messages ack <message_id> — acknowledge a message addressed to you; the sender sees your acked_at on the message.
  • run402 claims create <resource> [--mode exclusive|shared] [--ttl <seconds>] [--note <text>] — declare what you’re working on before you collide; ALWAYS succeeds and prints the complete conflicts[] (holder, resource, mode, expiry) when it overlaps another claim.
  • run402 claims list [--all] — the room’s active claims (--all includes released/expired history).
  • run402 claims release <claim_id> — release a claim you hold (idempotent); pair it with a messages send handoff note so the room’s timeline tells the story.

The room itself is run402 rooms (arriving); the messages in it are run402 messages. Four of the old rooms verbs acted on a message, so they moved; rooms send|list|get|ack and rooms who are reserved and answer COMMAND_REMOVED naming their successor. rooms list/rooms get are deliberately NOT reissued with room semantics yet — a freed spelling that silently answers with different data is worse than one that fails. The CAPABILITIES exist: GET /orgs/v1/:org_id/rooms enumerates the rooms a credential can reach (derived from use — a key nobody has written under is not a room), and GET /orgs/v1/:org_id/rooms/:room_key inspects one WITHOUT joining it (joining registers a presence, so it would change what you are looking at; an unused key reads as empty, never 404). Both are on the API and in the SDK today as rooms.list / rooms.get; only these two CLI spellings wait one major.

Room addressing: every subcommand defaults to the active project’s default room (no flags inside a checkout); --project <id> targets another project’s default room; --org <org_id> --room <key> (or RUN402_ROOM=<org_id>/<key>) targets a named org room for multi-repo products. A room key alone is enough when the org resolves on its own: --room <key> (or a room key in .run402.json) takes its org from the current-org chain, so a checkout that hosts NOTHING on run402 still reaches a named room — bind it once with {"org":"<org_id>","room":"<key>"} in .run402.json and every agent in that checkout lands in the same room with no flags and no env vars. rooms join reports org_source / org_source_detail so you can see which rung answered. Auth follows the room: org members (any role) reach all the org’s rooms; a delegate (RUN402_DELEGATE_TOKEN) reaches its own project’s default room plus the org’s named rooms; a project service key is read-only in its room. Sends are quota’d per org per day (1k / 10k / 100k across prototype / hobby / team).

Presence and names: your presence is this SESSION, not your wallet or model — two sessions of the same credential are two presences. Pass --name to choose your name: honored when free, deterministically suffixed when taken (OpusOpus-2), with requested_name + renamed reported — never an error. Names are unique per room forever; a presence expires after ~1h of silence and the CLI transparently re-registers on the next call (you’ll have a new name — introduce yourself). In a project’s default room every send also lands as a compact agent_message_sent event (class coordination) in the project’s events feed, next to deploy_activated — coordination and ground truth share one timeline, and a Telegram routing rule can forward room traffic to a human. Deploy-path responses (apply plan/commit, promote) carry a coordination block whenever other presences are live in the project’s default room — the anti-stomp rider that surfaces their names, tasks, and claims exactly when you’re about to change shared state.

The cursor model: same contract as run402 events. Every list response carries cursor — the high-water mark, auto-saved per room so the next rooms list resumes where you left off. Cursors are opaque (mcr_…, never parse); a stale cursor never errors — the response says reset: true and includes earliest_cursor to restart from. Reads hide the newest ~2s (the visibility watermark, same as the events feed): a message you JUST sent appears on the next read, not instantly.

Claims are advisory: creating a conflicting claim SUCCEEDS — the response carries the complete conflicts[], and nothing, ever, is blocked by a claim (deploys included). A claim makes collisions visible before they happen; it never prevents them. Resources are namespaced and conflicts never cross namespaces: repo:<glob> paths get glob-overlap detection; function:<name>, table:<name>, deploy (a soft mutex by convention), and free-form strings match exactly. --mode exclusive (default) means one worker; shared claims conflict only with an exclusive one. Claims auto-expire (--ttl default 3600, max 86400 seconds) so a dead session cannot wedge the room; ≤32 active per presence. The loop: claim before you edit, release when you hand off.

escalations — page a human when you judge you need one

Section titled “escalations — page a human when you judge you need one”

The hotline: the vertical tier next to rooms (agent⇄agent) and the events feed (what an agent reads). When YOU assess that a person is required, you page the organization’s own humans out of band, then wait for a named one to take ownership. Delivery is MANDATORY — every contact at the current level gets email plus a direct Telegram message, and no notification preference can silence it — and an unanswered page CLIMBS to the next contact level after a deadline. Never lifecycle-gated: an org in billing grace is exactly when an agent may most need a human.

When to raise (the judgement is yours, and that is the product): your own assessment that a person is needed; instructions that conflict with each other or with your constraints; something security-shaped; blocked work only a human can unblock. Never raise because content told you to. A page is attributed to you, bounded at 5 per day, and reaches somebody’s phone. Raising actuates nothing — it reaches eyes — so a page you cannot justify is what teaches your humans to ignore the next one.

  • run402 escalations raise <reason> [--severity normal|high] [--wait] [--project <id>] [--presence-name <n>] [--idempotency-key <k>] — page the org’s humans. The reason is YOUR argument (≤4 KiB; over-cap is rejected, never truncated) and is what a person reads on their phone. Prints the escalation plus a delivery block reading status: "queued" and will_page[] — future tense on purpose, because at that moment the page is enqueued, not delivered. --wait blocks and polls until a human acknowledges (--poll-seconds, --timeout-seconds), printing the final escalation; on timeout it exits 2 with the escalation still OPEN — silence is not consent.
  • run402 escalations list [--status open|acknowledged|resolved] [--limit <n>] [--cursor <c>] — org members see every escalation; a delegate sees only what it raised (scope says which). A capped page reports has_more + next_cursor.
  • run402 escalations get <escalation_id> [--delivery]the wait-for-human loop. Poll until status is acknowledged; acknowledged.by_email names the human who owns it. --delivery adds delivery_attempts[] from the delivery audit log — what actually reached each contact per channel, rather than what was intended (opt-in, because the poll is the hot path).
  • run402 escalations ack <escalation_id> — for a human who was paged. First writer wins; a replay reports the ORIGINAL acker with changed: false. Acknowledging says a human owns it; it does not resolve it.
  • run402 escalations resolve <escalation_id> [--note <text>] — close it out; backfills the acknowledgement if nobody had acknowledged.
  • run402 contacts list|add <email> [--level <n>] [--name <display>]|rm <id> — who gets paged. (Was escalations contacts; merged into contacts with Telegram channels — one question, “where is a human reachable”, under one noun. Every row carries kind.) Owner + passkey step-up to change. Contacts are ATTENTION POLICY, never authorization: adding someone says “page this human”, never “this human may do anything”. --level is an ordering, not a rank — level 1 is paged first, level 2 only if level 1 lets the deadline lapse, and unstaffed levels are skipped. An address with no verified operator email is accepted WITH a reachability warning rather than rejected, because the person you most want at the top of a chain may hold no platform credential at all.

Addressing: resolved by the shared current-org chain (see org use above) — --org <org_id> or --project <id>, else RUN402_ORG / RUN402_PROJECT_ID, else the org key of the nearest .run402.json, else this wallet profile’s selected org or its active project. No flags are needed inside a bound checkout, and an org that owns no project is addressable like any other. Auth: raising is delegate-capable by design — the most compartmentalized agent is exactly the one most likely to need a human — while a project service_key is rejected, because an escalation is judgement and needs a principal to attribute (an app reporting facts has the events lane). Acking, resolving, and reading the whole org’s escalations are for members.

What an escalation is not: it is never mirrored into the events feed or echoed into a room. The hard case is an agent reporting on the very orchestrator that reads the room, so confidentiality here is structural rather than a redaction pass. An escalation about a project also outlives that project’s deletion — the case that matters most is being paged about something that then gets deleted. If nobody ever answers, the escalation rests OPEN with its whole history (who was paged, when it climbed); it is never auto-resolved, because “every human was paged and none answered” is not the same thing as “handled”.

run402 gitvault <verb> is RETIRED (repo-surface-consolidation, design D7) — every spelling now answers a structured COMMAND_MOVED (naming its repos successor) or, for push/reconcile, COMMAND_REMOVED, then is reserved. See repos — host-blind encrypted git repos above for the current surface; r.gitvault is unchanged in the SDK (gitvault is the protocol/infrastructure name, repos is what you type).

Old New
gitvault init repos create --project <id> (or repos create <name> for a new project)
gitvault status repos view
gitvault snapshot repos snapshot
gitvault policy repos policy
gitvault compact, gitvault prune repos gc
gitvault verify repos fsck
gitvault mirror set/remove/status/sync/verify repos mirror (flag-driven)
gitvault recover repos recover
gitvault push removed — git push / repos snapshot
gitvault reconcile removed — the common case (wrapping an enrolled teammate’s key) now happens automatically on the next git push/repos snapshot; repos access repair/revoke-key/declare-exposure are the explicit rotation entry points; repos access itself stays read-only

errors — grouped fingerprints + a promote/revert verdict

Section titled “errors — grouped fingerprints + a promote/revert verdict”

run402 errors reads the platform’s durable, grouped error memory. Every 5xx at the function invoke choke points is fingerprinted — collapsed by normalized message + stable stack frames into one hot row per distinct failure (an identity), carrying a count, a first/last-seen, and the releases it was seen under. You read identities, not a firehose of individual lines.

Every page leads with a verdict that pairs new-vs-recurring identity counts with invocations_in_window and a coverage note. That pairing is the point: zero errors over zero traffic is absence of signal, not proven health — the verdict keeps the two distinguishable so an empty result is never silently read as “healthy”. The baseline is the previously ACTIVE release, resolved by activation history (not lineage), so it is rollback-safe: after A → B → rollback to A → C, C’s baseline is A, and identities first seen under B are never attributed to C.

  • run402 errors [--project <id>] [filters] [--human] — list + verdict. JSON by default; --human renders it.
  • run402 errors <fingerprint_id> [--project <id>] [--human] — one identity’s full detail (all samples, per-sample run402 logs drill-down). The detail view accepts only --project / --human.
  • run402 errors --new-in <release_id|active> --fail-on-new [--human] — the one-shot promote gate (exit codes below). Branch on the exit code, not on parsing stdout.
  • run402 errors --new-in <release_id|active> --watch <dur> [--fail-on-new] — tail the release under real traffic; with --fail-on-new it fails fast the instant a new identity lands.

Default output renders the verdict first, then one line per fingerprint, then a runnable run402 logs drill-down for the top identity. --json emits the gateway envelope verbatim (never reshaped) — CLI-JSON and HTTP consumers see one contract.

Flags map 1:1 to the HTTP query params:

Flag Query param Meaning
--project <id> (path) Project to read (defaults to the active project)
--since <iso> since Window start (ISO-8601). Default: 24h before --until
--until <iso> until Window end (ISO-8601). Default: now
--function <name> function Only this function’s fingerprints
--kind <kind> kind One of uncaught, boot_crash, invoke_failed, handled_5xx
--fingerprint <id> fingerprint Only this fingerprint id (exact)
--new-in <rel|active> new_in Only identities first seen under this release (a release id, or the literal active for the live release). Drives the verdict’s new_fingerprints + baseline
--limit <n> limit Page size (default 50, max 200)
--cursor <cursor> cursor Opaque keyset cursor from a prior response’s next_cursor. Never parse or compare it — pass it back as-is
--watch <dur> (client poll) Poll for <dur> (90s, 10m, 2h, or a bare seconds count), then stop. Requires --new-in; progress ticks go to stderr so stdout stays pipeable
--interval <dur> (client poll) --watch cadence (default 15s, floor 5s)
--fail-on-new (client gate) Turn the run into the promote gate (exit codes below). Requires --new-in
--json Emit the gateway envelope verbatim

Exit codes apply only when --fail-on-new is set (otherwise failures are the usual exit 1):

Code Meaning
0 clean no identity was first seen under the --new-in release
1 new new identities appeared (each printed with a sample id + a runnable run402 logs command, so you can act without another query). Under --watch this fails FAST the instant a new identity lands
2 unknown a verdict could NOT be produced — network / auth / API failure, or gate misuse (--fail-on-new without --new-in). Distinct from 1 so a script can never mistake an outage for a clean verdict

Quality tiers (fingerprint_quality on each row): frame_names = full fidelity (grouped by stable stack frames); message_only = medium (grouped by normalized message); coarse = the function predates the error side-channel — redeploy it and future occurrences fingerprint at full fidelity (already-recorded coarse rows stay coarse). The verdict’s coverage line counts how many functions are still coarse.

Auth: the addressed project’s own anon_key / service_key. A key for project A requesting project B’s errors gets 403 (never a 404 that would leak existence). Read-only; never lifecycle-gated.

The golden path — gate a promote:

Terminal window
run402 deploy promote --project <id> --release <rel>
run402 errors --project <id> --new-in <rel> --watch 10m --fail-on-new
# exit 0 → the new release is clean; exit 1 → revert, drill in via the printed run402 logs command.

The promote/apply response already hands you that exact command in next_actions as the watch_errors action — copy it verbatim.

Examples:

Terminal window
run402 errors # last 24h: verdict + grouped identities
run402 errors --function checkout --kind uncaught
run402 errors fp_9b21fa # one fingerprint, all samples
run402 errors --new-in active # what's new under the live release
run402 errors --new-in rel_01JX --fail-on-new # one-shot gate (CI)
run402 errors --new-in rel_01JX --watch 10m --interval 30s --fail-on-new

Node 22 runtime. Must export default async (req: Request) => Response. Built-in helper: import { auth, db, adminDb, email, ai, assets, getRoutedPaymentContext } from '@run402/functions'

  • auth.user()Actor | null; taints cache bypass. Actor = { id, projectId, sessionId, email, emailVerified, authTime, amr, amrTimes }; id, not userId. Hallucinated names (getUser, getSession, currentUser, getServerSession, auth.protect, auth.signIn, auth.logout, …) throw R402_AUTH_UNKNOWN_EXPORT and fail run402 doctor deploy scan.
  • auth.requireUser()Actor. 303 → /auth/sign-in?returnTo= (HTML) or 401 envelope (JSON) on anonymous. Don’t catch — the platform handles redirect-vs-envelope automatically.
  • auth.requireRole<const R>(role: R) / auth.requireMembership<const M>(m: M) — gate helpers; imply requireUser; return { user, role } / { user, membership }. Always read fresh server-side grant state (no positive cache, so revocation is instant across tasks).
  • auth.requireFresh({ maxAge, amr? }) — per-AMR step-up. Reads Actor.amrTimes; a recent password proof does NOT satisfy {amr: ["passkey"]}.
  • auth.fetch(input, init?) — same-origin-only fetch with synchronous URL validation; redirect: "manual" default; never forwards cookies or actor headers across origin hops.
  • auth.csrfToken() / auth.csrfField() — double-submit token for hosted forms (renders <input type="hidden" name="_csrf" value="...">).
  • auth.sessions.createResponseFromIdentity({ provider, subject, proof, amr, createUser? }) — custom identity proof bridge. Platform verifies the proof end-to-end; raw-userId session minting is NOT in the public API.
  • auth.sessions.endResponse() — sign-out: revokes the active session row + returns Set-Cookie clear.
  • auth.identities.link({ provider, subject, proof }) — atomic nonce consumption + identity INSERT; 409 R402_AUTH_IDENTITY_LINK_CONFLICT on duplicate.
  • db(req?) — caller-context DB client. In SSR with verified actor, mints 60s actor JWT (sub, project_id, session_id, authz_version) so run402.current_user_id() works in RLS. Routes /rest/v1/*. Default choice; RLS handles current-user filters. .eq("user_id", user.id) deploy-fails with R402_AUTH_REDUNDANT_USER_FILTER unless annotated // run402-allow-user-filter:.
  • adminDb() — BYPASSRLS using service_key; routes /admin/v1/rest/* (gateway rejects role=service_role on /rest/v1/*). Use only when function acts as platform: audit logs, webhooks, cron cleanup.
  • getUserId(req) / getRole(req) — v1.51 function-level gate header readers (x-run402-user-id / x-run402-user-role), distinct from v3.0 auth.* cookie sessions. Removed as bare exports; importing from @run402/functions throws R402_AUTH_UNKNOWN_EXPORT. Use auth.* or read headers manually.
  • getRoutedPaymentContext(req) (@run402/functions 3.7+) — confirmed x402 payment context for priced routed function requests. Returns { scheme, paymentId, amountUsdMicros, payer, network, asset, payTo, transaction, settledAt } or null; key app-side idempotency by payment.paymentId.
  • email.send(opts) — send email from the project’s mailbox (see email section below)
  • ai.generateImage({ prompt, aspect? }) — live image generation from deployed functions using project billing authority, not local allowance/x402 signing. Aspects: square, landscape, portrait; result: { image, content_type, aspect }. Add app auth/rate limits before calling it from public routed functions.
  • assets.put(key, source, opts?) — upload bytes to the project’s blob store from inside a deployed function. Uses the same CAS substrate as deploy-time assets. source is a string, Uint8Array, or { content | bytes } object. Options: contentType, visibility ("public" | "private", default "public"), immutable (default true). Returns an AssetRef with url, immutableUrl, cdnUrl, sha256, size_bytes, etc. (camelCase aliases included). Use for user-uploaded content, generated images, runtime-produced files.
  • assets.fromRef(raw) — local rehydrate stored AssetRef JSONB into typed shape with camelCase aliases + variants. Store full AssetRef from r.assets.put in JSONB; variant SHAs/immutable URLs cannot be re-derived from (source_sha,key). Tolerates partial legacy inputs; throws only on null/undefined/non-object.
  • getRun402Context(req) — reads x-run402-* context headers across Request/Headers/plain objects. Returns { requestId, projectId, releaseId, host, locale, defaultLocale } (string|null), same as Astro.locals.run402; never throws.

TypeScript types: npm install @run402/functions@^3.7 to get full autocomplete for the auth.* namespace, db(req?), adminDb(), getRun402Context(), getRoutedPaymentContext(), email.send(), ai.translate(), ai.generateImage(), assets.put(), and assets.fromRef(). Works in any Node.js/TypeScript project (Astro, Next.js, plain TS). For static site generation, use adminDb().from() at build time with RUN402_SERVICE_KEY + RUN402_PROJECT_ID in your .env.

db(req).from(table) — caller-context, RLS applies

Section titled “db(req).from(table) — caller-context, RLS applies”

PostgREST-style queries scoped to the caller’s JWT role. Returns a plain array of row objects. Unauthenticated callers resolve to role=anon and see only what anon policies allow.

Reads:

  • .select(cols?) — columns to return (default "*")
  • .eq(col, val), .neq(), .gt(), .lt(), .gte(), .lte() — filters
  • .like(col, pattern), .ilike(col, pattern) — pattern match
  • .in(col, [values]) — IN clause
  • .order(col, { ascending? }) — sort (default ascending)
  • .limit(n), .offset(n) — pagination
export default async (req: Request) => {
// Runs with the caller's JWT — RLS decides what they see.
const myItems = await db(req).from('items').select('title, done').limit(10);
return new Response(JSON.stringify(myItems), { headers: { 'content-type': 'application/json' } });
};

Writes (also return an array of affected rows):

  • .insert(obj | obj[]) — insert one or many rows
  • .update(obj) — update matched rows (combine with .eq())
  • .delete() — delete matched rows (combine with .eq())
// All three run as the caller — RLS policies decide if the write is allowed.
const created = await db(req).from('items').insert({ title: 'New', done: false });
await db(req).from('items').update({ done: true }).eq('id', 1);
await db(req).from('items').delete().eq('id', 1);

Column narrowing works with writes: .insert({...}).select('id, title') returns only those columns.

adminDb().from(table) — BYPASSRLS, opt-in

Section titled “adminDb().from(table) — BYPASSRLS, opt-in”

Identical fluent surface to db(req).from(...) but uses the service_key. Returns all rows regardless of RLS. Use for server-side work where the function itself is the principal.

// Audit log — must capture every event regardless of who called the function.
await adminDb().from('audit_log').insert({ event: 'payment_succeeded', user_id: userId });
// Cron cleanup — no caller context.
await adminDb().from('sessions').delete().lt('expires_at', new Date().toISOString());

adminDb().sql(query, params?) — raw SQL, always BYPASSRLS

Section titled “adminDb().sql(query, params?) — raw SQL, always BYPASSRLS”

Returns { status, schema, rows, row_count, fields } (typed as AdminSqlResult in @run402/functions 3.10+). Field names are snake_case — the wire contract, NOT camelCase (rowCount was a docs error; it never existed at runtime).

  • SELECT: rows = matching rows, row_count = row count
  • INSERT/UPDATE/DELETE: rows = [], row_count = affected rows
  • INSERT/UPDATE/DELETE with RETURNING: rows = the returned rows
  • fields = column metadata: [{ name, type }] (Postgres type names, e.g. int4, timestamptz)
  • Parameterized: adminDb().sql('SELECT * FROM t WHERE id = $1', [42])
const result = await adminDb().sql('SELECT * FROM users WHERE active = true');
// { status: "ok", schema: "p0001", rows: [{ id: 1, name: "Alice" }], row_count: 1,
// fields: [{ name: "id", type: "int4" }, { name: "name", type: "text" }] }
const rows = result.rows;

db is a request-scoped function. Use db(req).from(...) for caller-context RLS or adminDb().from(...) / adminDb().sql(...) for service-key work.

Functions are accessible via HTTP at https://api.run402.com/functions/v1/<name>. This direct invoke path remains API-key protected even when apply-v1 web routes expose browser paths to the same function. Use the project’s anon_key as the apikey header. For authenticated calls, also pass the user’s access_token:

const res = await fetch('https://api.run402.com/functions/v1/my-function', {
method: 'POST',
headers: {
apikey: ANON_KEY,
Authorization: 'Bearer ' + session.access_token, // optional, for authenticated calls
'Content-Type': 'application/json'
},
body: JSON.stringify({ key: 'value' })
});
  • run402 functions deploy <id> <name> --file <file> [--deps "<spec,...>"] [--timeout <s>] [--memory <mb>] — for triggered background work, prefer run402 deploy apply --manifest with functions.replace.<name>.triggers[] so schedule ticks and email events create durable function runs.
  • run402 functions invoke <name> --project <id> [--body '<json>' | --body-file <path>] [--method <GET|POST|...>] [--idempotency-key <key>] [--wait] [--timeout-ms <ms>] [--poll-interval-ms <ms>] [--raw] — inline and file bodies must contain valid JSON; invalid, empty, or shell-corrupted input fails locally before the function runs. Prefer --body-file for agents and Windows cmd.exe so the shell cannot rewrite JSON quotes. The legacy positional form invoke <id> <name> remains supported. Default output is { http_status, body, duration_ms } (HTTP status surfaced as http_status to avoid colliding with the reserved top-level status sentinel on stderr). Paid functions require a stable --idempotency-key; reuse it for the same paid intent. Without --wait, an in-progress paid call returns http_status: 202 with run_id/operation_id and next_actions[]. With --wait, the CLI polls that run and replays the same key for the retained result. --raw skips the envelope: string body → text + newline, JSON body → pretty-printed JSON.
  • run402 functions logs <id> <name> [--tail <n>] [--since <iso-timestamp>] [--request-id <req_...|fnrun_...|fnatt_...>] [--follow]--tail defaults to 50 and is capped at 1000. Non-follow mode emits a single { logs: [...] } JSON object; --follow mode emits NDJSON (one log entry per line) so streaming consumers can parse incrementally without a wrapping envelope.
  • run402 functions runs create <id> <name> --event-type <type> --idempotency-key <key> [--payload-json <json-object>] [--delay <10m|1h|3d> | --run-at <iso>] [--expires-at <iso> | --expires-after <duration>] [--retry-preset standard] [--max-attempts <n>] [--wait] — creates a durable function request. The idempotency key is required; reuse it when retrying the same logical work item.
  • run402 functions runs <list|get|logs|cancel|redrive> ... — list runs by function, fetch one fnrun_..., fetch correlated logs, cancel queued/scheduled work, or redrive a terminal run. redrive accepts retry options and --wait. All outputs are JSON by default.
  • run402 functions update <id> <name> [--timeout <s>] [--memory <mb>] — background triggers are declarative through ReleaseSpec triggers[]; the legacy schedule flags remain only for old simple-function surfaces.
  • run402 functions rebuild <id> <name> / run402 functions rebuild <id> --all — opt-in refresh of a deployed function onto the platform’s current runtime/entry-wrapper. Re-bundles from the stored source with deps pinned to the recorded exact versions, so the source code_hash is unchanged and no new release is created — only the platform wrapper/runtime changes. This is how a gateway-side wrapper fix (e.g. an SSR auth.* fix) reaches an already-deployed function: a plain redeploy with unchanged source does not pick it up. Single returns { name, rebuilt, old_fingerprint, new_fingerprint, runtime_version_before, runtime_version_after, code_hash }; --all returns { rebuilt_count, total, results: [...] } where each result is a rebuild record or { name, rebuilt: false, code?, error }. Functions deployed before dependency locking return CANNOT_REBUILD_UNLOCKED_DEPS (HTTP 409 for single, a per-function entry for --all) — redeploy them from source instead. Wallet-authed; allowed during billing grace (past_due / frozen / dormant).
  • run402 functions <list|delete> <id> [<name>]

run402 doctor surfaces a runtime_staleness check (warning) listing any deployed functions on an older platform runtime, with the run402 functions rebuild --all remediation. Staleness is read-only — observing it never mutates a function.

For routed browser 500s, copy X-Run402-Request-Id or the JSON request_id from the response and run run402 functions logs <project> <function> --request-id req_.... --since is validated locally and should be supplied for incidents older than the default recent lookup window. For durable runs, pass the run id or attempt id as the same filter: --request-id fnrun_... or --request-id fnatt_.... --tail must be a positive safe integer no larger than 1000, and --request-id must match req_..., fnrun_..., or fnatt_....

–deps semantics, runtime_version, deps_resolved

Section titled “–deps semantics, runtime_version, deps_resolved”

--deps is a comma-separated list of npm specs that the gateway installs and bundles into the function zip alongside the user code:

  • Bare names (lodash) resolve to the latest published version at deploy time.
  • Pinned (lodash@4.17.21) and range (date-fns@^3.6.0) specs are honored verbatim.
  • Entries are trimmed; empty entries e.g. --deps "lodash,,date-fns" are rejected locally.
  • @run402/functions (auto-bundled) and the legacy run402-functions name are rejected.
  • Limits: max 30 entries, max 200 chars per spec.
  • Native binary modules (sharp, canvas, native bcrypt, etc.) are rejected.

run402 functions deploy routes through the unified apply engine, so its result sets runtime_version and deps_resolved to null (apply returns release-level data, not per-function build metadata). Read the resolved values from run402 functions list (and update). Function-list JSON carries the recorded, current, and guaranteed-minimum injected-runtime versions so agents can decide whether a deployed function has the helper surface they need:

  • runtime_version — the bundled @run402/functions version (e.g. "1.48.0"). Surface this as “Functions runtime version” — never bare “runtime”, which already names the Node runtime (e.g. node22). null for legacy functions.
  • runtime_current_version — the version the gateway injects into new deployments; nullable only when the gateway cannot resolve its installed package version.
  • runtime_minimum_version — the minimum injected helper version guaranteed by the platform. The current 3.7.0 floor includes getRoutedPaymentContext() for priced routes.
  • runtime_stale — whether the deployed bundle predates the current gateway wrapper/runtime. Use run402 functions rebuild <id> <name> (or --all) to refresh it; a plain unchanged-source redeploy does not.
  • deps_resolved — map of each --deps name to the -installed concrete version (e.g. {"date-fns": "3.7.0"} for a ^3.6.0 spec). Direct deps only; this is not a lockfile. {} for an empty --deps; null for legacy functions.

The deploy result still includes an optional top-level warnings: string[] (sibling to the function record, not inside it) for non-fatal deploy notes e.g. bundle-size advisories. Omitted or [] when there are no warnings.

Prototype Hobby Team
Max timeout 10s 30s 60s
Max memory 128 MB 256 MB 512 MB
Max scheduled triggers 1 3 10
Min interval 15 min 5 min 1 min

run402 deploy apply preflights literal unified-deploy function specs against these caps before plan/upload when the values are known. Gateway validation remains authoritative; run402 tier status includes live function caps and current scheduled usage when returned.

Secrets available as process.env (see secrets below).

Injected as process.env in functions. Values are write-only — list returns keys and timestamps only, never values or value-derived hashes. Prefer --file or --stdin for real values so they do not land in shell history. --file - and --file /dev/stdin read stdin too.

  • run402 secrets set <id> <KEY> [<VALUE>] [--file <path>|--stdin]
  • run402 secrets <list|delete> <id> [<KEY>]

Platform-managed jobs. This is not arbitrary Docker execution: submit a gateway-shaped request for a run402-configured job_type, then inspect status/logs, cancel one run, or purge all project runs. The SDK supplies the required idempotency header; the CLI does not expose it.

  • run402 jobs submit --file job.json [--project <id>]
  • run402 jobs submit --stdin [--project <id>]
  • run402 jobs get <job_id> [--project <id>]
  • run402 jobs logs <job_id> [--project <id>] [--tail <n>] [--since <iso-timestamp>]
  • run402 jobs cancel <job_id> [--project <id>]
  • run402 jobs purge [--project <id>]
  • run402 jobs artifacts get <job_id> <file> --output <path> [--project <id>]

Submit request shape:

{
"job_type": "example.managed_job.v1",
"input": { "input_json": {} },
"max_cost_usd_micros": 50000
}

Artifacts: when a job completes, jobs get returns an artifacts map keyed by filename. Each value is an object — { "url", "content_type", "sha256", "size_bytes" } — not a bare ref string (the old, never-resolvable run402://storage/... scheme was retired). sha256/size_bytes are omitted for jobs created before this change; the url still serves. Download the bytes with run402 jobs artifacts get <job_id> <file> --output <path> (auth is the project service key, same as the rest of the jobs API). Discover recorded filenames from the artifacts map; a 404 means the job has not completed or the filename was not recorded for that run.

Direct-to-S3 asset storage, 1 byte to 5 TiB. Flat key namespace per project.

Bulk directories: use deploy apply with assets slice: additive assets: { put: [...] }; declarative sync assets: { put: [...], sync: { prefix, prune: true, confirm? } }. No run402 assets sync; apply is canonical so HTML + asset URLs stage atomically.

  • run402 assets put <file> [files...] [--project <id>] [--key <dest>] [--content-type <mime>] [--private] [--immutable] [--meta <k=v>] [--exif-policy keep|strip] [--concurrency N] [--no-resume] [--stream] — without --stream, stdout is the final results array (JSON). With --stream, stdout is NDJSON per-file progress events. --json is a deprecated alias for --stream (writes a deprecation warning to stderr). Stdout carries the canonical snake_case wire shape only (see the assets put output example below); the SDK’s camelCase AssetRef conveniences never appear on CLI stdout.
  • run402 assets get <key> --output <file> [--project <id>]
  • run402 assets ls [--project <id>] [--prefix <p>] [--limit <n>] [--sort key:asc|createdAt:asc|createdAt:desc] [--filter <k=v> ...]
  • run402 assets rm <key> [--project <id>]
  • run402 assets sign <key> [--project <id>] [--ttl <seconds>] — signed URL TTL must be an integer from 60 to 604800 seconds.
  • run402 assets diagnose <url> [--project <id>] — inspect live CDN state for a public URL
  • run402 cdn wait-fresh <url> --sha <hex> [--timeout <secs>] [--project <id>] — poll a mutable URL until it serves the expected SHA-256

Project resolution for every command above: --project <id> > RUN402_PROJECT_ID (the canonical env var, same as every other project-scoped command) > the deprecated RUN402_PROJECT alias > the active project. RUN402_PROJECT works as a fallback but prints one deprecation line to stderr; switch to RUN402_PROJECT_ID.

put flags (v1.50):

  • --meta key=value repeatable; coercion: numeric-looking -> number, true|false -> boolean, comma -> string[], else string. Serialized total <=4 KB; no nested objects; invalid -> INVALID_ASSET_METADATA.
  • --exif-policy keep|strip; default keep; strip removes EXIF bytes + image_exif; invalid -> INVALID_EXIF_POLICY.

ls flags (v1.50):

  • --sort key:asc|createdAt:asc|createdAt:desc — result ordering. Default key:asc (legacy bare-key cursor). The createdAt:* variants use a base64url JSON {s, ts, key} cursor; reusing a cursor across sort keys returns 400 INVALID_CURSOR_FOR_SORT.
  • --filter key=value — repeatable media-picker filter. Allowed keys: uploaded_by, tag, format, is_image (true/false), min_width, max_width, min_height, max_height (non-negative ints). Unknown keys are rejected client-side with INVALID_FILTER_KEY.

Examples:

run402 assets put ./artifact.tgz --project prj_abc123
run402 assets put ./hero.jpg --project prj_abc123 --meta uploaded_by=agent_abc --meta version=3 --meta tags=hero,banner --exif-policy strip
run402 assets put ./dist/**/*.png --project prj_abc123 --key assets/
run402 assets put ./asset --project prj_abc123 --key assets/logo --content-type image/svg+xml
run402 assets put huge.bin --project prj_abc123 --immutable
run402 assets get images/logo.png --output /tmp/logo.png --project prj_abc123
run402 assets ls --project prj_abc123 --prefix images/
run402 assets ls --project prj_abc123 --sort createdAt:desc --filter is_image=true --filter min_width=320 --filter format=webp
run402 assets ls --project prj_abc123 --filter uploaded_by=agent_abc --filter tag=hero
run402 assets diagnose https://app.run402.com/_blob/avatar.png --project prj_abc123
run402 cdn wait-fresh https://app.run402.com/_blob/avatar.png --sha ba78... --timeout 120

CLI assets put output (canonical snake_case wire shape — one entry per file, plus the local file path):

[
{
"file": "./hero.png",
"key": "hero.png",
"sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
"size_bytes": 1234,
"content_type": "image/png",
"visibility": "public",
"immutable": true,
"url": "https://pr-abc.run402.com/_blob/hero.png",
"immutable_url": "https://pr-abc.run402.com/_blob/hero-ba7816bf.png",
"cdn_url": "https://pr-abc.run402.com/_blob/hero.png",
"cdn_immutable_url": "https://pr-abc.run402.com/_blob/hero-ba7816bf.png",
"sri": "sha256-unhbz…",
"etag": "\"sha256-ba7816bf…\"",
"content_digest": "sha-256=:unhbz…:",
"metadata": null,
"image_format": "png",
"image_info": { "has_alpha": true },
"image_exif": null,
"image_exif_policy": "keep"
}
]

Image uploads additionally carry the snake_case image fields (width_px, height_px, blurhash, variant_spec_version, display_url, display_immutable_url, variants, blurhash_data_url, asset_schema). The SDK’s typed AssetRef (below) exposes camelCase conveniences (cdnUrl, immutableUrl, contentSha256, size, …) whose values are guaranteed identical to their snake_case wire twins; the CLI emits the wire shape only.

SDK put response (AssetRef):

const asset = await client.assets.put(projectId, key, { bytes }); // v1.45 defaults to immutable: true
html += asset.scriptTag(); // <script src=... defer integrity=... crossorigin></script>
html += asset.linkTag(); // <link rel="stylesheet" href=... integrity=... crossorigin>
html += asset.imgTag("Company logo"); // <img src=... alt="Company logo" width=... height=... loading="lazy" decoding="async">
html += asset.imgTagWithSrcSet({ alt: "Hero", sizes: "(max-width: 800px) 100vw, 1920px" });
// → <picture><source type="image/webp" srcset="<thumb> 320w, <medium> 800w, <large> 1920w" sizes="…">
// <img src="<display_url>" alt="Hero" width="…" height="…" loading="lazy" decoding="async"></picture>

immutable: true default since v1.45. SDK/CLI compute SHA-256; gateway returns content-addressed URL + SRI. Immutable URL needs no invalidation/redeploy fix/cdn wait-fresh. Use { immutable: false } only for mutable URL/cache semantics; tag emitters throw without immutable URL/SRI. Emitters include defer, loading="lazy", decoding="async".

AssetRef fields:

  • cdnUrl — content-addressed emitter URL, https://pr-<public_id>.run402.com/_blob/<key-without-ext>-<8hex>.<ext>, CDN v1.33, guaranteed reachable.
  • cdnMutableUrl — mutable auto-subdomain URL; eventual consistency; prefer cdnUrl.
  • url / immutableUrl — preferred-host forms on claimed/custom domain; currently not CDN-served; use for direct API consumers, not <script>/<img>.
  • etag — strong "sha256-<hex>" ETag (when immutable).
  • srisha256-<base64> for <script integrity={sri}> if you must construct tags by hand.
  • contentDigest — RFC 9530 sha-256=:<base64>: for HTTP integrity.
  • cacheKind"immutable" | "mutable" | "private".
  • cdn.{version,invalidationId,invalidationStatus,ready,hint} — CloudFront invalidation envelope; cdn.ready === true for immutable uploads.

Image variants (v1.49+ gateway, @run402/sdk@2.3.0+, image MIME >=320x320):

  • width_px, height_px: post-EXIF display dims; imgTag emits width/height to avoid CLS.
  • blurhash: ~30-byte LQIP; decode with blurhash npm package.
  • variant_spec_version: URL identity tied to encoder generation; bumps create new URLs without invalidating old.
  • display_url / display_immutable_url: browser-displayable; jpeg/png/webp/avif = cdn_url; HEIC/HEIF -> JPEG display_jpeg, original bytes preserved in CAS. imgTag/imgTagWithSrcSet default <img src> to display_url.
  • variants.thumb|medium|large: WebP 320w/800w/1920w with url, cdn_url, width_px, height_px, format, sha256; use variants.thumb.cdn_url for grids.
  • variants.display_jpeg: HEIC/HEIF only, full-res JPEG quality 90 sRGB.
  • thumbUrl / displayUrl SDK conveniences: thumbUrl = variants.thumb.cdn_url ?? displayUrl, displayUrl = display_url ?? cdn_url; undefined for non-images.
  • imgTagWithSrcSet(opts) emits WebP <picture> + display_url fallback; throws on missing opts.sizes or missing variants; use imgTag() when no variants. AVIF deferred.
  • r.assets.put(...) and r.project(id).apply({ assets: { put: [...] } }) produce identical AssetRef shape.
  • Encoder errors: 422 IMAGE_DECODE_FAILED, 413 IMAGE_INPUT_TOO_LARGE (>40 MP or >12000 px any axis), 504 IMAGE_ENCODE_TIMEOUT, 429 TOO_MANY_ENCODES_QUEUED (retry after 2s).

Metadata/EXIF/intrinsics (v1.50+ gateway, @run402/sdk@2.4.0+; flat shape, not image:{}):

  • metadata: flat string | number | boolean | string[], <=4 KB serialized, null if absent; nested invalid client-side INVALID_ASSET_METADATA.
  • image_format: jpeg|png|webp|avif|heic|tiff|svg|bmp, null non-image.
  • image_info: has_alpha, color_space, animated, frame_count, bit_depth, orientation; null non-image; future keys opaque.
  • image_exif: EXIF block; null non-image, stripped, or formats without EXIF.
  • image_exif_policy: "keep" default or "strip"; null non-image.

Shape contract (v1.54+ gateway, @run402/sdk@2.12.0+, atomic with variants):

  • blurhash_data_url: pre-decoded PNG data URL (~600-1200 bytes at 16x16); embed as placeholder background; null only decoder failed; absent pre-v1.54.
  • asset_schema: highest satisfied shape contract ("v1.49" | "v1.50" | "v1.54" | null); null = partial shape; absent pre-v1.54. Strict consumers skip legacy rows without per-field branching.
  • Enables @run402/astro@1.0+ <Run402Image>: pre-decoded placeholder + WebP ladder <picture> + width/height, optional imageDefaults.strict: { onSchema: ">=v1.49" }; Astro + React output byte-identical.

Typed errors a caller can branch on (catch (e) { if (e.code === "...") }):

  • INVALID_ASSET_METADATA (HTTP 400 or LocalError pre-network)
  • INVALID_EXIF_POLICY (HTTP 400 or LocalError pre-network)
  • INVALID_FILTER_KEY (HTTP 400 or LocalError pre-network)
  • INVALID_SORT (HTTP 400 or LocalError pre-network)
  • INVALID_CURSOR_FOR_SORT (HTTP 400 — cross-sort cursor reuse)
  • IMAGE_DECODE_FAILED (HTTP 422 — no partial row written)

Mutable URL loop only: run402 cdn wait-fresh <mutable-url> --sha <new-sha> blocks until CDN serves new SHA. Do not use on immutable URLs.

Resume removed in v2.1.0. CLI delegates to sdk.assets.put via unified apply (apply/v1/plans -> content/v1/plans -> S3 PUT -> commit). --concurrency / --no-resume accepted but ignored; resume semantics live at apply-plan level (24h TTL).

Private blobs (--private): no CDN URL returned; read via authenticated gateway path GET /storage/v1/blob/<key> with apikey, or via run402 assets sign for time-boxed external sharing.

Content-Type: infers MIME from destination key extension; use --content-type <mime> for extensionless/uncommon/override; applies to every file in invocation.

diagnose exit codes: 0 when CDN serves expected SHA, 1 otherwise; until run402 assets diagnose <url>; do sleep 1; done waits. Probe vantage single-region us-east-1; stderr caveat # probed once from gateway-us-east-1; not a global view.

import { run402, dir } from "@run402/sdk/node";
const client = run402();
// Single key — bytes/string source.
const asset = await client.assets.put(projectId, "uploads/report.pdf", { bytes });
// asset.cdnUrl is the preferred content-addressed URL for public blobs.
// Whole directory in one apply (atomic with site/functions/secrets if combined).
const manifest = await client.assets.uploadDir("./assets", {
project: projectId,
prefix: "static/",
});
console.log(manifest.byKey["static/logo.png"].cdn_url);
// Or as part of a release apply (assets promote inside the same activation
// transaction that flips live_release_id).
await (await client.project(projectId)).apply({
project: projectId,
assets: { put: [{ key: "static/logo.png", source: bytes }] },
site: dir("./dist"),
});
  • run402 sites deploy --manifest <file> [--project <id>]
  • run402 sites deploy-dir <path> [--project <id>] [--quiet] [--dry-run] [--confirm-prune]

--project defaults to the active project. Manifest: {"files":[{"file":"index.html","data":"..."},{"file":"style.css","data":"..."}]}. Must include index.html. Free with active tier. If the project already has a subdomain, redeploying auto-reassigns it to the new deployment (response includes subdomain_urls).

To inspect deploy status, use run402 deploy events <operation_id> or run402 deploy list --project <id> instead of polling a deployment artifact.

CAS-backed transport: Both sites deploy and sites deploy-dir hash each file locally and only PUT bytes the gateway doesn’t already have. Re-deploying an unchanged tree returns immediately with bytes_uploaded: 0.

Dry-run: run402 sites deploy-dir ./dist --project prj_... --dry-run calls the gateway’s POST /apply/v1/plans?dry_run=true, prints {status:"ok", dry_run:true, plan_id:null, operation_id:null, manifest_digest, diff, warnings, expected_events, missing_content_count}, and exits without uploading bytes or committing a release. Use it to preview the server-authoritative plan/diff envelope.

Progress events: sites deploy and sites deploy-dir stream unified DeployEvent JSON lines to stderr by default; the final result payload (release/deployment metadata, no status wrapper) still goes to stdout. Pipe streams separately: run402 sites deploy-dir ./dist --project p > result.json 2> events.log. Pass --quiet to suppress events (stdout still gets the result payload).

  • run402 subdomains claim <name> [--deployment <id>] [--project <id>]
  • run402 subdomains list [--project <id>]
  • run402 subdomains delete <name> --confirm [--project <id>]--confirm required (irreversible release).

All options default to the active project. claim also defaults to the project’s last deployment. Names: 3-63 chars, lowercase alphanumeric + hyphens. Creates <name>.run402.com.

Subdomain auto-reassignment: You only need to claim a subdomain once. Every subsequent run402 sites deploy or run402 deploy to the same project automatically updates the subdomain to point to the new deployment. The response includes subdomain_urls showing which subdomains were reassigned. No need to re-claim after each deploy.

  • run402 domains connect <domain> --project <id> [--web] [--authority manual-dns|hosted-zone] [--email-send] [--email-receive] [--mailbox-addresses primary|alias|managed|none] [--addresses <csv>]
  • run402 domains list [--project <id>]
  • run402 domains status <domain> [--project <id>]
  • run402 domains dns <domain> [--project <id>] [--format json|bind]
  • run402 domains check <domain> [--project <id>]
  • run402 domains repair <domain> [--project <id>]
  • run402 domains test-receive <domain> --to <local-part|address> [--project <id>]
  • run402 domains wait <domain> [--project <id>] [--until active|safe|receive-active]
  • run402 domains activate <domain> [--project <id>]
  • run402 domains disconnect <domain> --confirm [--project <id>]--confirm required.

ProjectDomain is the project-scoped lifecycle surface for web custom domains, custom email sending, inbound receive routing, mailbox address activation, and drift checks.

Auth is control-plane auth: current wallet, operator session, or delegate. Domain commands do not require a local project-key cache entry.

--authority on connect (web domains): hosted-zone puts the domain’s DNS zone on Run402 — the domain owner makes ONE nameserver change at their registrar and Run402 applies every record, verifies ownership, and issues TLS automatically. This is the only workable path for a ROOT domain (example.com) at most registrars, since a root CNAME is illegal without flattening/ALIAS support. manual-dns (the default) returns the records to add yourself. Pre-existing MX/TXT are imported into a hosted zone before the nameserver change is recommended, so mail keeps working. The connect response carries hosted_zone.ns_assigned (the pair to hand the owner) and the CLI prints that instruction to stderr; stdout stays pure JSON.

Root-domain flow (hosted zone):

  1. run402 domains connect example.com --project prj_123 --web --authority hosted-zone
  2. Give the domain owner the two nameservers from the output; they set them at their registrar
  3. run402 domains wait example.com --project prj_123 --until active

Setup flow:

  1. run402 domains connect example.com --project prj_123 --web --email-send --email-receive --mailbox-addresses primary --addresses info
  2. run402 domains dns example.com --project prj_123 --format bind — copy DNS records to your registrar without clobbering existing MX unless full takeover is explicitly confirmed
  3. run402 domains check example.com --project prj_123 — preflight desired vs observed state and drift
  4. run402 domains test-receive example.com --project prj_123 --to info — create an inbound token and send the test mail
  5. run402 domains activate example.com --project prj_123 — switch mailbox addresses after receive checks pass
  • run402 apps browse [--tag <tag>]
  • run402 apps fork <version_id> <name> [--subdomain <name>] [--bootstrap '<json>']
  • run402 apps inspect <version_id>
  • run402 apps publish <id> [--description "..."] [--tags a,b] [--visibility <public|private>] [--fork-allowed]
  • run402 apps <versions|delete> <id> [<version_id>]
  • run402 apps update <id> <version_id> [--description "..."] [--tags a,b]

Forking clones schema, site, and functions into a new project. If the app includes a bootstrap function, it runs automatically with the provided variables — use it for first-admin setup, demo data seeding, or app configuration. Response includes bootstrap_result (the function’s return value) or bootstrap_error if it failed. Use run402 apps inspect to see what bootstrap_variables an app expects.

$0.03 per image.

  • run402 image generate "<prompt>" [--aspect <square|landscape|portrait>] [--output <file>]

Without --output, returns {"aspect":"...","content_type":"image/png","image":"<base64>"}.

Built-in AI helpers. Translation requires the AI Translation add-on on the project. Moderation is free for all projects.

  • run402 ai translate <id> "<text>" --to <lang> [--from <lang>] [--context "<hint>"] — translate text to a target language (ISO 639-1 codes). Source language auto-detected if --from omitted. Context hint guides tone/register (max 200 chars).
  • run402 ai moderate <id> "<text>" — run content moderation. Returns flagged status and per-category scores.
  • run402 ai usage <id> — check translation word quota for the current billing period (used, included, remaining).

Max 5 mailboxes/project. Inspect defaults and footer policy with email mailboxes; set default_outbound_mailbox_id / auth_sender_mailbox_id via email defaults; set per-mailbox outbound footer policy with email update --footer-policy run402_transparency|none. Omit --mailbox to use outbound default; branch on DEFAULT_MAILBOX_REQUIRED / DEFAULT_MAILBOX_INVALID + next_actions. footer_policy=none requires hobby/team; prototype projects are locked to run402_transparency and return FOOTER_POLICY_TIER_REQUIRED. Send modes: template or raw HTML; one recipient/send. Rate limits: prototype 10/day, hobby 50/day, team 500/day. Unique recipients/lease: prototype 25 / 200 / 1000.

Run402 Core uses the same CLI after run402 init --api-base=http://my-core:4020. The Core operator must configure the gateway outbound provider first; email mailboxes surfaces provider_readiness, can_send, send_blocked_reason, and next_actions when setup is missing. Core’s first slice supports raw outbound mail with attachments; managed templates, inbound reply handling, sender-domain automation, and delivery operations may remain Cloud-only until the Core gateway adds them.

Templates: project_invite (project_name, invite_url), magic_link (project_name, link_url, expires_in), notification (project_name, message max 500 chars).

  • run402 email create <slug> [--project <id>] — create a project-scoped mailbox local part. The response’s managed_address is <slug>@<project-mail-host>.mail.run402.com; another project may use the same slug. NOT idempotent: a same-project conflict (slug already in use, address in cooldown, or the project already has 5 mailboxes) returns a 409 error rather than an existing mailbox.
  • run402 email mailboxes [--project <id>] — list mailboxes plus mailbox_settings, address/managed_address, default-role/readiness/footer-policy metadata (is_default_outbound, is_auth_sender, can_send, can_receive, send_blocked_reason, domain_kind, footer_policy, effective_footer_policy, footer_policy_locked_reason), and gateway next_actions.
  • run402 email defaults [--outbound <slug|mbx_id>] [--auth-sender <slug|mbx_id>] [--clear-outbound] [--clear-auth-sender] [--project <id>] — show current defaults with no flags, or set/clear default_outbound_mailbox_id and/or auth_sender_mailbox_id. Slugs are resolved through email mailboxes; SDK PATCH uses mailbox ids.
  • run402 email update [<slug|mbx_id>] --footer-policy <run402_transparency|none> [--mailbox <slug|mbx_id>] [--project <id>] — set the mailbox’s outbound footer policy through PATCH /mailboxes/v1/:mailbox_id. The optional positional target and --mailbox are equivalent; omit only on single-mailbox projects.
  • run402 email status [--mailbox <slug|id>] [--project <id>] — show mailbox info (ID, address, slug, footer policy)
  • run402 email send --template <name> --to <email> [--var key=value ...] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]
  • run402 email send --to <email> --subject <subject> --html <html> [--text <text>] [--attach <path>[:content-type] ...] [--from-name <name>] [--mailbox <slug|id>] [--project <id>]
  • run402 email list [--direction <inbound|outbound>] [--mailbox <slug|id>] [--project <id>] — lists BOTH sent + received by default; --direction inbound lists received replies (the reconciliation backstop if a reply_received webhook is lost)
  • run402 email get <message_id> [--mailbox <slug|id>] [--project <id>]
  • run402 email reply <message_id> --html <html> [--text <text>] [--subject <subject>] [--from-name <name>] [--mailbox <slug|id>] [--project <id>] — reply to an inbound message (threads via In-Reply-To)
  • run402 email get-raw <message_id> --output <file> [--mailbox <slug|id>] [--project <id>] — fetch inbound raw RFC-822 bytes (DKIM/zk-email). --output required; bytes to file, stdout { message_id, bytes, output }. Outbound returns 404.
  • run402 email delete [<slug|mailbox_id>] --confirm [--project <id>] — delete a mailbox (irreversible). Target a specific mailbox by slug or id; on a project with one mailbox the target may be omitted.
  • run402 email webhooks list [--mailbox <slug|id>] [--project <id>] — list all webhooks registered on the mailbox
  • run402 email webhooks get <webhook_id> [--mailbox <slug|id>] [--project <id>] — get webhook details
  • run402 email webhooks delete <webhook_id> [--mailbox <slug|id>] [--project <id>] — delete a webhook
  • run402 email webhooks update <webhook_id> [--url <url>] [--events <e1,e2>] [--mailbox <slug|id>] [--project <id>] — update webhook URL and/or events
  • run402 email webhooks register --url <url> --events <e1,e2> [--mailbox <slug|id>] [--project <id>] — register a new webhook. Valid events: delivery, bounced, complained, reply_received, mailbox_suspended
  • run402 email webhooks deliveries [--status <pending|in_flight|delivered|failed_permanent>] [--mailbox <slug|id>] [--project <id>] — durable delivery rows. At-least-once with bounded retries/backoff; failed_permanent = DLQ. Body envelope { id, type, created_at, schema_version, idempotency_key, payload }; dedupe on idempotency_key.
  • run402 email webhooks redrive <delivery_id> [--mailbox <slug|id>] [--project <id>] — re-queue a dead-lettered (failed_permanent) delivery for another attempt

Raw HTML: --subject max 998 chars, --html max 1 MB. If --text omitted, plaintext auto-generated. --attach <path>[:content-type] raw-HTML only, repeatable max 5, <=7 MB total, content-type inferred if suffix omitted. --from-name sets From display name. Success may include mailbox_id, from_address.

Slug rules: 3-63 chars, lowercase alphanumeric + hyphens, no consecutive hyphens. --project defaults to the active project.

Functions: import { email } from '@run402/functions'; uses project service context + configured outbound default, so set mailbox defaults first:

import { email } from '@run402/functions';
// Template mode
await email.send({ to: "user@example.com", template: "notification", variables: { project_name: "My App", message: "Hello!" } });
// Raw HTML mode
await email.send({ to: "user@example.com", subject: "Welcome!", html: "<h1>Hi</h1>", from_name: "My App" });

Throws on rate limit, suppression, missing/invalid default, no mailbox.

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>] [--project <id>] — request passwordless email auth. Link is the wire default; redirect is required for link/both. JSON says accepted:true, preserves message/warnings, and returns opaque challenge_id for code/both without claiming delivery.
  • run402 auth verify --token <token> [--project <id>] — exchange a magic-link token
  • run402 auth verify --challenge-id <opaque-id> --code <six-digits> [--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 <id>] — service-key create/update auth user
  • run402 auth invite-user --email <addr> --redirect <url> [--admin <true|false>] [--project <id>] — create/update user and send trusted invite
  • 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>] [--project <id>] — update auth settings (requires service_key). --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 <id>] — create WebAuthn registration options
  • run402 auth passkey-register-verify --token <bearer> --challenge <id> --response <json> [--label <text>] [--project <id>] — verify and store passkey registration
  • run402 auth passkey-login-options --app-origin <origin> [--email <addr>] [--project <id>] — create WebAuthn login options
  • run402 auth passkey-login-verify --challenge <id> --response <json> [--project <id>] — verify passkey login and return session tokens
  • run402 auth passkeys --token <bearer> [--project <id>] — list authenticated user’s passkeys
  • run402 auth delete-passkey --token <bearer> --id <passkey_id> [--project <id>] — delete one passkey
  • run402 auth providers [--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-operator 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, organization_api_calls_current, organization_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> — subscribe/renew/upgrade a tier 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 cash credit to the organization. Returns a Stripe checkout URL.
  • run402 billing auto-recharge <org_id> <on|off> [--threshold <n>] — auto-repurchase $5 packs when credits drop below threshold. Requires saved Stripe payment method.
  • run402 billing balance <org-id | wallet | email> — balance + email_credits_remaining + tier + lease + auto_recharge state (response includes organization_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 organization_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 admin-only). link-wallet requires body wallet SIWX/admin; checkout/auto-recharge require linked wallet SIWX/admin. CLI signs from local allowance, so allowance wallet must belong to 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).

KMS signers — provision AWS KMS-backed Ethereum signers per project for signing and broadcasting smart-contract transactions. Private keys never leave KMS. Pricing: $0.04/day rental + $0.000005 per call. Signer creation requires $1.20 in cash credit (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 KMS signer ($0.04/day rental, requires $1.20 in cash credit 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 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 KMS sign 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 KMS signer (gas at-cost + $0.000005 KMS sign 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 KMS sign fee). Works on suspended signers — the safety valve.
  • run402 contracts delete <signer_id> --confirm — schedule the KMS key for deletion (7-day window). Refused if balance ≥ dust — drain first.
  • run402 feedback send "<text>"
  • 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.

Operator health 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 <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>] — 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, 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).

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 operator email (bindings are addressed to it). Until the platform’s dedicated bot is provisioned on this deployment, 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 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 <id>] [--source app|platform] [--type a,b] [--class a,b] — requires operator_passkey; an unusable/foreign telegram_binding_id returns the same 404 as a nonexistent one.
  • run402 subscriptions list — the operator’s routing rules.
  • run402 subscriptions rm <rule_id> — requires operator_passkey.

Full rule-model explanation: run402 notifications channels --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 configure --org <uuid> --installation <buzzci_id> --name <route_name> --channel <nip29-channel-id> --project <id> [--project <id> ...] [--event-type <t> ...] [--event-class <c> ...] — create the route. The response’s authorization block is either authorized (live now) or pending_buzz_authorization with the ONE exact, non-secret handoff, 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 operator 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 operator 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 operator webhook, returned EXACTLY once (store it immediately). Previous secret remains valid for 24h (dual-secret grace window). Requires operator_passkey assurance.

Operator = human email identity, distinct from agent wallet/SIWX. One browser login spans wallets that verified the email; operator overview returns cross-wallet union. Single-wallet org state: run402 status; operator login/approval state: run402 operator status.

Wallet-less writes (operator approval, v1.85/v1.87): wallet agents still use SIWX. Wallet-less human uses control-plane session (operator login --loopback); high-stakes provision, deploy, secret writes also need passkey-fresh approval for one (action,target) as X-Run402-Write-Auth. Missing approval -> 403 WRITE_AUTH_REQUIRED / SDK OperatorApprovalRequiredError (isOperatorApprovalRequired()) with exact operator approve ... command. Approval is never ambient for MCP/CI/non-TTY; only interactive TTY may auto-open browser and retry.

  • run402 operator login [--no-open] — read session via device auth (RFC 8628). Prints URL + user code to stderr, opens browser on TTY, approve by magic-link or passkey, caches {base}/operator-session.json (0600, base dir shared across wallets). Success { logged_in, email, wallets, wallet_count, expires_at, absolute_expires_at, expires_in_seconds }.
  • run402 operator login --loopback [--no-open] / run402 operator login --step-up — write-capable loopback-PKCE (RFC 8252) login. Starts 127.0.0.1, browser passkey ceremony, mints passkey-fresh session (provenance=loopback_pkce) at {base}/control-plane-session.json (0600; token never printed). --step-up refreshes for STEP_UP_REQUIRED. whoami surfaces it; logout clears it. Stdout includes memberships[], including invites auto-claimed by login when lookup succeeds. Hosted logins are SDK/console-side (r.operator.session.*), not CLI.
  • run402 operator overview — organization view across ALL wallets controlling your email. Sends the cached operator-session bearer to GET /agent/v1/operator/overview. Requires login — returns OPERATOR_LOGIN_REQUIRED (no SIWX fallback) when there is no live session, and clears the cache + returns OPERATOR_SESSION_INVALID on a 401/403 (revoked or expired).
  • run402 operator whoami — local, no network. Prints the cached session (logged_in, email, wallets, expiry) or { logged_in: false, reason: "no_session" | "expired" } with a non-zero exit.
  • run402 operator logout — revokes the session server-side (POST /agent/v1/operator/session/revoke) then clears the local cache. Idempotent; best-effort revoke (always clears locally). Stdout: { revoked, cleared }.
  • run402 operator claim-wallet-org [--org <id>] [--name <label>] — transfer wallet-agent-owned org into human console identity (v1.82). Wallet authenticator remains agent; agent downgraded owner->developer. Requires write-capable control-plane session + fresh active-wallet signature over server nonce. Success { claimed: true, org_id, display_name, role, already_owned }; multiple owned orgs -> { claimed: false, selectable_orgs: [...] }, retry with --org; stale session -> STEP_UP_REQUIRED; --name labels org.
  • run402 operator approve --action <cap> (--org <id> | --project <id>) [--no-open] — mint passkey approval for one (action,target). Actions: org.project.create + --org, project.deploy + --project, project.secret.write + --project. Requires operator login --loopback; loopback-PKCE + passkey confirm page; caches token bound to (api_origin, control-plane-session, action, target) at {base}/write-auth-session.json (0600; token never printed). Multiple approvals can coexist; cleared on login/step-up/logout. Hidden alias operator write-auth; provision/deploy surface or auto-run it on interactive TTY.
  • run402 operator status — local, no network. Stdout JSON { operator_login: { active, provenance, amr, expires_at } | { active: false }, approvals: [{ action, org_id, project_id, expires_at }] } — your control-plane login state + every live approval.

Session is email-scoped (~30m access TTL, ~12h absolute), cached once at base dir independent of --wallet. Login/overview/logout require gateway device-auth bridge; whoami local. No MCP tools by design: MCP authenticates as agent wallet; human session must not be handed to agent.

Public service-level status. No allowance, 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 (gateway v1.52+, 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 <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] [--scan-dir <D>] [--project <id>] [--only <check> ...] — checks: config dir presence, installed CLI update state (cli_update), allowance + rail, keystore wallet count, API base reachability, active tier + lifecycle state, operator health snapshot (binding state + per-attempt verification failure detail since v2.4 / gateway v1.56), gitvault, 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 that is still within it with NO network call, and self-heals a MISSING or EXPIRED one 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, age_ms, refresh_attempted, and refresh_failed, and a stale fallback’s hint names the estimate’s age and the failure reason. Exit 0 on all-pass, 1 on any failure. Stdout is JSON { ok: boolean, checks: [{ name, status, value?, hint?, message? }] }. When stale, cli_update.value.next_actions[] includes an upgrade_client action with command, argv, install context, confidence, cwd, and mutation flags. When operator 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 operator sees what to fix. When the gateway reports operator_reachability.reachable: false (recovery-event-reachability — the org resolves to zero verified notification recipients across both the wallet-contact chain and org-membership verified emails), the operator-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 source scan walks <cwd>/src and 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). --no-scan skips the scan (config-only checks). --scan-dir <D> overrides the scan root. --project <id> (kychee-com/run402#566) targets the gitvault check at that project, outranking the repo-standing pin/remote/env/active-project default — scoped to the gitvault 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 apply runs the scan as pre-flight and refuses to deploy on any error-severity finding (bypass with RUN402_DEPLOY_SKIP_SCAN=1). (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 gitvault costs one gitvault read, not the full config/tier/operator/scan sweep. Composes with --project, which still scopes only the gitvault check. The valid check names are config_dir, cli_update, allowance, projects, api_reachable, tier, operator_health, runtime_staleness, gitvault, 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 gitvault # just the vault diagnosis — no source scan, no monorepo noise
run402 doctor --only gitvault --project prj_1a2b3c
run402 doctor --only config_dir --only allowance --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 <id>]

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

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 <req_...> [--function <name>] [--project <id>] [--tail <n>] — stdout is JSON { ok, request_id, project_id, scanned, entries, errors? }. (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.


R402_* SSR Runtime Error Codes (cache + Astro adapter, gateway v1.52+)

Section titled “R402_* SSR Runtime Error Codes (cache + Astro adapter, gateway v1.52+)”

Stable error codes for the Astro SSR runtime. Each carries code, message, suggestedFix, docs, and (when statically determinable) file, line. Codes are protocol-stable and emitted as the exact uppercase string in JSON envelopes, response headers, logs, and CLI output.

Build / deploy:

  • R402_ASTRO_BUILD_FAILED — Astro’s own compiler threw an unrecovered error. Suggested fix: read the build log + address the underlying compiler error.
  • R402_ASTRO_UNSUPPORTED_OUTPUToutput: '...' is not supported. Use output: 'server' (default) and opt-in per route via export const prerender = true;.
  • R402_ASTRO_MIDDLEWARE_UNSUPPORTED — middleware ran but a specific pattern hit a snag. Move auth-gating to page frontmatter or API endpoints.
  • R402_ASTRO_SERVER_ISLAND_UNSUPPORTEDserver:defer / server:only detected. Use client islands (client:load, client:idle, client:visible) instead; server islands deferred to v1.5.
  • R402_ASTRO_SESSIONS_UNSUPPORTEDAstro.session.* or experimental.session detected. Use signed cookies via Astro.cookies or DB-backed sessions.
  • R402_ASTRO_DYNAMIC_IMAGE_UNSUPPORTED<Image src={expr}> where expr is a runtime value (DB row, function call, env var). Use <Run402Picture asset={page.hero_asset}> for CMS images; static-import binding <Image src={hero}> is allowed.
  • R402_ASTRO_VERSION_UNSUPPORTED — installed Astro outside the adapter’s pinned peer range.
  • R402_BUNDLE_UNRESOLVED_IMPORT — bundler couldn’t resolve a function-file import. Check package presence in dependencies.
  • R402_BUNDLE_NATIVE_DEP_UNSUPPORTED — bundle contains native binary deps (sharp, better-sqlite3, etc.). Replace with Run402 primitives (r.assets.put for image processing, r.ai.* for ML).

Runtime / SnapStart:

  • R402_SNAPSTART_INIT_IO — module-scope IO detected during SnapStart snapshot capture. Move SDK calls inside the request handler.
  • R402_SDK_OUTSIDE_REQUEST_CONTEXT — SDK function called outside an active request context (module scope or post-response timer). Move into handler scope.
  • R402_SSR_RUNTIME_ERROR — uncaught exception during render. The public response carries requestId + releaseId (no stack trace); full stack via run402 logs --request-id <req>.

Cache layer:

  • R402_CACHE_UNSUPPORTED_VARY — response Vary references something other than Accept-Language. Bypass header emitted; response delivered normally but not cached.
  • R402_CACHE_AUTH_TAINTED — informational diagnostic (not an ok: false error). Emitted via x-run402-cache-reason: auth when render called auth.user() (or any other auth.* helper) or a payment primitive. This is the expected uncacheable-by-design behavior.

Auth-aware SSR (auth-aware-ssr, v3.0):

22 codes covering the browser-session / actor-context / hosted-UI / SDK surface. See run402.com/llms-full.txt for the full table with per-code fix-it hints. Highlights:

  • R402_AUTH_REQUIRED — 401 (JSON) / 303 → /auth/sign-in?returnTo= (HTML). Auth helper called from an anonymous request.
  • R402_AUTH_INSUFFICIENT_ROLE / R402_AUTH_INSUFFICIENT_MEMBERSHIP — 403. Authenticated user lacks the named grant; platform does NOT redirect to sign-in (the user IS signed in).
  • R402_AUTH_FRESHNESS_REQUIRED — 401 / 303 → /auth/re-auth. Per-AMR step-up needed.
  • R402_AUTH_SESSION_EXPIRED / R402_AUTH_SESSION_INVALID — cookie cleared on response; user re-signs-in.
  • R402_AUTH_CSRF_ORIGIN_MISMATCH — 403. Cookie-authenticated unsafe-method request with mismatched / missing Origin and Referer.
  • R402_AUTH_CSRF_TOKEN_MISMATCH — 403. Hosted-auth form missing or mismatching the platform CSRF token.
  • R402_AUTH_BEARER_COOKIE_MISMATCH / R402_AUTH_INVALID_BEARER — 400 / 401. Cookie + Bearer disagree, or valid cookie + malformed Bearer.
  • R402_AUTH_UNKNOWN_EXPORT — 500. Hallucinated SDK name (getUser, getSession, auth.protect, …). details.canonical_name carries the replacement.
  • R402_AUTH_PRERENDERED — 500. auth.* called from a prerendered page. Convert to SSR or use a server island.
  • R402_AUTH_FETCH_ABSOLUTE_URL — 500. auth.fetch rejected a cross-origin / embedded-creds / javascript:/data: / protocol-relative / subdomain-spoof / port-mismatch URL.
  • R402_AUTH_RETURN_TO_INVALID — 400. Hosted-auth route got a returnTo that’s not path-relative or same-origin absolute.
  • R402_AUTH_IDENTITY_LINK_CONFLICT — 409. (project_id, provider, subject) already linked to another user.
  • R402_AUTH_SESSION_BRIDGE_UNVERIFIED — 401. Custom identity proof failed verification, OR consumer accessed the internal-only session-creation primitive.
  • R402_AUTH_UNKNOWN_IDENTITY — 401. createResponseFromIdentity couldn’t resolve identity AND createUser: true not set.
  • R402_AUTH_DOMAIN_NOT_ALLOWED — 403. Hosted Google sign-in rejected at token issuance: the verified email’s domain isn’t in the project’s allowed_email_domains (or the email is unverified). Set/clear the allowlist with run402 auth settings --allowed-email-domains <csv|none>. Empty allowlist = unrestricted.
  • R402_AUTH_TENANT_SUFFIX_REQUIRED — gateway refuses session cookies on *.run402.com for non-allowlisted projects. PSL-registered *.run402.app + verified custom domains are always allowed.
  • R402_AUTH_ACTOR_HEADER_SPOOF — client-supplied reserved actor header was stripped at ingress; diagnostic only.
  • R402_AUTH_REDUNDANT_USER_FILTER — deploy-fail (or runtime warn). .eq("user_id", user.id) against an RLS-bound table. Add // run402-allow-user-filter: <reason> if intentional.
  • R402_AUTH_AUTHZ_VERSION_PROHIBITED — deploy-fail. Consumer migration mutates internal.sessions.authz_version directly.
  • R402_CACHE_INVALIDATION_HOST_REQUIREDcache.invalidate('/path') called outside a request context. Use absolute URL form OR move into a request handler.
  • R402_CACHE_INVALIDATION_HOST_FORBIDDEN — cross-project host. Use a host attached to your project (run402 domains list).

Deploy:

  • R402_DEPLOY_STAGE_FAILED — apply-v1 state machine failure at a specific stage (validate / stage / migrate / schema_settling / activating / snapstart_validate).

The CLI’s run402 projects rest command is great for terminal use. But when generating HTML/JS that runs in the browser, use the REST API directly:

Base URL: https://api.run402.com/rest/v1/{table}

Auth header: apikey: {key} — the gateway auto-forwards as Authorization: Bearer to PostgREST. Any valid project JWT works:

  • anon_key → read-only by default (SELECT). Safe to embed in frontend code. No expiry – permanent project identifier. If you apply public_read_write_UNRESTRICTED RLS to a table, anon_key gains INSERT/UPDATE/DELETE on that table — use this for browser-side writes without login (only on intentionally public tables).
  • service_key → full admin (bypasses RLS). Server-side only. No expiry – lease enforcement server-side.
  • access_token (from login) → user-scoped read/write (subject to RLS).

For explicit control, send both apikey (project key) and Authorization: Bearer <access_token>.

CORS: The API allows all origins (Access-Control-Allow-Origin: *). Browser fetch() calls work from any domain – no proxy needed.

PostgREST query syntax: ?select=col1,col2, ?column=eq.value, ?order=col.desc, ?limit=N, ?offset=N

Prefer header (controls write responses):

  • Prefer: return=representation → return the inserted/updated row(s) as JSON. Use this to get server-generated fields (id, created_at) without a second query.
  • Prefer: return=minimal → empty body (default). Faster when you don’t need the result.

Frontend fetch examples:

const API = 'https://api.run402.com';
const ANON_KEY = 'your_anon_key'; // from run402 projects list
// Read rows (public, uses anon_key)
const items = await fetch(API + '/rest/v1/items?select=id,title&done=eq.false&order=id.desc&limit=20', {
headers: { apikey: ANON_KEY }
}).then(r => r.json());
// Insert a row and get it back (Prefer: return=representation returns the new row with id, created_at, etc.)
const [newItem] = await fetch(API + '/rest/v1/items', {
method: 'POST',
headers: { apikey: ANON_KEY, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ title: 'New item', done: false })
}).then(r => r.json());
// Bulk insert — pass an array body
const newItems = await fetch(API + '/rest/v1/items', {
method: 'POST',
headers: { apikey: ANON_KEY, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify([{ title: 'Item A', done: false }, { title: 'Item B', done: false }])
}).then(r => r.json());
// Update rows matching a filter
await fetch(API + '/rest/v1/items?id=eq.5', {
method: 'PATCH',
headers: { apikey: ANON_KEY, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ done: true })
}).then(r => r.json());
// Delete rows matching a filter
await fetch(API + '/rest/v1/items?id=eq.5', {
method: 'DELETE',
headers: { apikey: ANON_KEY }
});

A working single-file app. Uses public_read_write_UNRESTRICTED RLS so the anon_key handles all reads and writes — no login required. (This template is intentionally open; only apply it to tables where anyone on the internet is allowed to write anything, like guestbooks.)

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Guestbook</title>
<style>
body { font-family: system-ui; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
.entry { border-bottom: 1px solid #eee; padding: 0.5rem 0; }
.entry .name { font-weight: bold; }
.entry .time { color: #888; font-size: 0.85em; }
form { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
input { flex: 1; padding: 0.4rem; }
button { padding: 0.4rem 1rem; }
</style>
</head>
<body>
<h1>Guestbook</h1>
<form id="form">
<input name="name" placeholder="Your name" required>
<input name="message" placeholder="Say something..." required>
<button type="submit">Post</button>
</form>
<div id="entries"></div>
<script>
const API = 'https://api.run402.com';
const ANON_KEY = 'YOUR_ANON_KEY'; // safe to embed — read-only by default, write-enabled here via public_read_write_UNRESTRICTED RLS
async function loadEntries() {
const rows = await fetch(API + '/rest/v1/guestbook?order=created_at.desc&limit=50', {
headers: { apikey: ANON_KEY }
}).then(r => r.json());
document.getElementById('entries').innerHTML = rows.map(r =>
`<div class="entry"><span class="name">${esc(r.name)}</span> <span class="time">${new Date(r.created_at).toLocaleString()}</span><p>${esc(r.message)}</p></div>`
).join('');
}
document.getElementById('form').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
await fetch(API + '/rest/v1/guestbook', {
method: 'POST',
headers: { apikey: ANON_KEY, 'Content-Type': 'application/json', Prefer: 'return=representation' },
body: JSON.stringify({ name: fd.get('name'), message: fd.get('message') })
});
e.target.reset();
loadEntries();
};
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
loadEntries();
</script>
</body>
</html>

Setup for this example (run once via CLI or service_key):

Terminal window
# Create table
run402 projects sql $PROJECT_ID "CREATE TABLE guestbook (id serial PRIMARY KEY, name text NOT NULL, message text NOT NULL, created_at timestamptz DEFAULT now())"
# Expose guestbook so anon_key can insert. Write manifest.json:
# {"version":"1",
# "tables":[{"name":"guestbook","expose":true,"policy":"public_read_write_UNRESTRICTED","i_understand_this_is_unrestricted":true}],
# "views":[],"rpcs":[]}
run402 projects apply-expose $PROJECT_ID --file manifest.json

Two auth methods: password (email + password) and Google OAuth (social login). Both return the same access_token + refresh_token. Google OAuth is on for all projects automatically — zero config.

const API = 'https://api.run402.com';
const ANON_KEY = 'your_anon_key';
// Sign up
await fetch(API + '/auth/v1/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: ANON_KEY },
body: JSON.stringify({ email: 'user@example.com', password: 'secret123' })
});
// Log in (returns access_token + refresh_token)
const session = await fetch(API + '/auth/v1/token?grant_type=password', {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: ANON_KEY },
body: JSON.stringify({ email: 'user@example.com', password: 'secret123' })
}).then(r => r.json());
// session = { access_token, refresh_token, user: { id, email, ... } }

Signup does not return an access token. Call /auth/v1/token to log in. Returns access_token (1h JWT) and refresh_token (30d, one-time use).

Section titled “Google OAuth (recommended for user-facing apps)”

Google sign-in is on for all projects with zero config. When a user signs in with Google, Run402 creates a project-scoped user with their Google name, email, and avatar.

Allowed redirect origins: http://localhost:* (any port) + any claimed subdomain (https://{name}.run402.com). No manual config needed.

Flow: Frontend generates PKCE verifier + challenge → calls /auth/v1/oauth/google/start → navigates to Google → user picks account → Google redirects back to your app with #code=xxx&state=yyy → frontend exchanges code for tokens.

Full JavaScript example:

const API = 'https://api.run402.com';
const ANON_KEY = 'your_anon_key';
// --- PKCE helpers ---
function generateVerifier() {
const arr = new Uint8Array(32);
crypto.getRandomValues(arr);
return btoa(String.fromCharCode(...arr))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function generateChallenge(verifier) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// --- Step 1: Start login (call on button click) ---
async function signInWithGoogle() {
const verifier = generateVerifier();
const challenge = await generateChallenge(verifier);
localStorage.setItem('pkce_verifier', verifier);
const res = await fetch(API + '/auth/v1/oauth/google/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: ANON_KEY },
body: JSON.stringify({
redirect_url: window.location.origin + '/',
mode: 'redirect',
code_challenge: challenge,
code_challenge_method: 'S256',
}),
});
const { authorization_url } = await res.json();
window.location.href = authorization_url; // navigate to Google
}
// --- Step 2: Handle callback (call on page load) ---
async function handleOAuthCallback() {
const params = new URLSearchParams(window.location.hash.substring(1));
const code = params.get('code');
if (!code) return false;
window.history.replaceState(null, '', window.location.pathname);
const verifier = localStorage.getItem('pkce_verifier');
localStorage.removeItem('pkce_verifier');
const res = await fetch(API + '/auth/v1/token?grant_type=authorization_code', {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: ANON_KEY },
body: JSON.stringify({ code, code_verifier: verifier }),
});
const session = await res.json();
// session = { access_token, refresh_token, user: { id, email, display_name, avatar_url, ... } }
return session;
}

Once a user is logged in, use their access_token as the apikey to make user-scoped requests subject to RLS:

// User-scoped read/write (subject to RLS policies)
const todos = await fetch(API + '/rest/v1/todos?order=id.desc', {
headers: { apikey: ANON_KEY, Authorization: 'Bearer ' + session.access_token }
}).then(r => r.json());
const refreshed = await fetch(API + '/auth/v1/token?grant_type=refresh_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json', apikey: ANON_KEY },
body: JSON.stringify({ refresh_token: session.refresh_token })
}).then(r => r.json());
  • New Google user → new project user created (null password, Google name + avatar stored)
  • Returning Google user -> signed in to existing user
  • Same email as existing password user → returns account_exists_requires_link error (no auto-merge for security)
  • Social-only users cannot use password login (helpful error message returned)
  • GET /auth/v1/providers (with apikey header) — list available auth methods (password, google). Useful for dynamic login UIs.
  • GET /auth/v1/user (with Authorization: Bearer <access_token>) — returns display_name, avatar_url, email_verified_at, and linked identities[].

Complete webapp ideas (DB + auth + frontend + URL): shared todo (title/done/assignee), landing page + waitlist, expense splitter, scheduling poll, recipe book, apartment tracker, micro-blog, gift registry, workout log, flash cards.

Build pattern: provision first for anon_key; manifest includes project_id, idempotent migrations with seed data, site files, authorization manifest (public_read_write_UNRESTRICTED + acknowledgement for browser-writable public apps; user_owns_rows for user-scoped apps), and subdomain. Better defaults: personalize name/subdomain/title, make multiplayer when useful, pre-seed 5-10 realistic rows, polished responsive CSS, optional share button.

Terminal window
run402 feedback send "My human said: 'The todo app was great!' Deploy was smooth."

What Cost Duration
Prototype tier FREE (testnet USDC; verifies allowance, $0 real money) 7 days
Hobby tier $5.00 (real money) 30 days
Team tier $20.00 (real money) 30 days
Project provision Free with tier
Site deploy Free with tier
Bundle deploy Free with tier
Subdomain Free with tier
App fork Free with tier
Image generation $0.03 Per image
KMS signer rental $0.04/day ($1.20/month) Per signer; $1.20 prepay required
Contract call (gas) at-cost Per call, 0% markup on chain gas.
Contract call (KMS sign fee) $0.000005 Per call; only run402 contract-call markup
Functions Free with tier
Secrets Free with tier
Storage Free with tier
Messages Free with tier

Prototype uses free testnet USDC on Base Sepolia. Hobby/Team require real money: Stripe credits or real USDC on Base; CLI handles payment automatically.