| name | run402 |
| description | Provision Postgres + REST API + auth + content-addressed storage + serverless functions + email — paid with x402 USDC on Base. Prototype tier is free on testnet. |
| metadata | {"openclaw":{"emoji":"🐘","homepage":"https://run402.com","requires":{"bins":["npx"]},"install":[{"kind":"node","package":"run402","bins":["run402"]}],"primaryEnv":"RUN402_API_BASE"}} |
Run402 — Postgres, storage & deploys for AI agents
Run402 gives an agent a real Postgres database with REST API and user auth, content-addressed CDN storage, static site hosting, Node 22 serverless functions, email, image generation, and KMS-backed on-chain signing. One command provisions; payment happens automatically with x402 USDC on Base. Prototype tier is free on testnet — no real money, no human signup.
Every example below is a CLI command. The CLI prints JSON to stdout, JSON errors to stderr, and exits 0 on success / 1 on failure — designed for shells, scripts, and agent loops.
30-second start
run402 up --name my-app -y
run402 up verify
run402 up --verify
run402 subdomains claim my-app
That's a real Postgres database + a deployed static site, paid for autonomously with testnet USDC.
run402 up is the CLI path for local repos with a deploy manifest. It validates the manifest first, then recursively performs only missing prerequisites through the SDK action runner. Project resolution is --project, .run402/project.json, manifest project_id, approved creation from --name, then approved active-project fallback. --dry-run prints planned steps[] without mutating. If an app manifest defines verify.http[], up reports fresh edge sentinel misses as propagation_pending while the host binding converges; tune with --propagation-budget-s, use --no-propagation-wait to return immediately, and run run402 up verify to rerun checks without upload, deploy, project creation, or resource mutation. Add --verify to a real deploy when you need edge_coherence evidence in the final JSON; a valid non-coherent report exits 2.
Self-hosted Core target
Use the same commands against a self-hosted Run402 Core Gateway by configuring the API base once:
run402 init --api-base=http://my-core:4020
run402 projects provision --name my-app
run402 deploy apply --manifest app.json
For Core, init --api-base stores the target in the active profile and does not create a Cloud allowance, request faucet funds, or require a Cloud tier. The CLI, Node SDK, and MCP all read the same configured target by default. Unsupported Cloud-only manifest slices fail as Core capability errors; they are not silently deployed to Run402 Cloud.
How to think about it
| You want to… | Reach for… |
|---|
| Set up a wallet from scratch | run402 init |
| Bootstrap/link/deploy a manifest repo | run402 up --name <name> -y |
| Bootstrap/link/deploy and wait for edge coherence | run402 up --name <name> -y --verify |
| Rerun app HTTP verification | run402 up verify --project <project_id> |
| Point at self-hosted Core | run402 init --api-base=<url> |
| Make a database | run402 projects provision |
| Run SQL on it | run402 projects sql |
| Check an auth manifest before applying | run402 projects validate-expose |
| Make a table reachable from the browser | run402 projects apply-expose |
| Deploy a frontend from a directory | run402 sites deploy-dir <path> |
| Link GitHub Actions deploys | run402 ci link github |
| Stash a file with a paste-able CDN URL | run402 assets put <file> |
| Run code on the server | run402 functions deploy |
| Send email | run402 email send |
| Sign on-chain | run402 contracts call |
| One-call full-stack deploy | run402 deploy apply --manifest app.json |
The active project is sticky — run402 projects use <id> server-validates the project and makes it the default for every subsequent <id>-taking command. Most commands work without an explicit <id> once a project is active.
Multiple wallets (profiles)
Hold several wallets on one machine - per-client isolation, personal vs work, blast-radius containment. Keys never leave the machine (non-custodial). The default wallet stays at ~/.config/run402/; named wallets live under ~/.config/run402/profiles/<name>/.
run402 wallets new kychon
run402 wallets list
run402 wallets use kychon
run402 wallets bind kychon
run402 wallets current
run402 --wallet kychon projects list
Selection precedence for every command: --wallet <name> flag > RUN402_WALLET env > nearest ./.run402.json directory binding > wallets use default > default. A RUN402_WALLET that disagrees with a .run402.json binding is a hard error - pass --wallet, unset RUN402_WALLET, or run402 wallets unbind. Non-default selections echo the wallet name on stderr so you always know which wallet you are on.
Project credentials
After provision, two keys are cached in the selected profile's credentials/project-keys.v1.json:
anon_key — for the browser. Read-only by default; safe to embed in HTML. RLS still applies.
service_key — server-side admin. Never embed in browser code. CORS is intentionally open for x402 clients, so a leaked service_key is exploitable from any origin. Use only inside functions or when running CLI as the agent.
Neither expires. Lease enforcement happens server-side. This is a local credential cache, not project inventory; server-visible projects come from run402 projects list/get/use.
run402 credentials project-keys list
run402 credentials project-keys status --project <id>
run402 credentials project-keys export --project <id> --reveal
run402 projects get <id>
Error Envelopes and Safe Retry
Run402 JSON errors may include canonical fields. Branch on stable code, not English message or legacy error text.
Fields to use:
code: machine-readable reason, e.g. PROJECT_FROZEN, PAYMENT_REQUIRED, MIGRATION_FAILED, MIGRATE_GATE_ACTIVE
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 when reporting an issue
request_id: routed/function failure handle; use run402 functions logs <id> <name> --request-id <req_...> for diagnostics. Distinct from gateway trace_id.
details: structured route-specific context
next_actions: advisory actions e.g. authenticate, submit_payment, renew_tier, check_usage, retry, resume_deploy, edit_request, edit_migration, create_project, initialize_wallet, deploy; never treat them as blindly executable. CLI-resolvable entries carry a literal command. Cold start: from run402 deploy apply, follow the chain it hands back — no allowance -> run402 init, no tier -> run402 tier set prototype, no project -> run402 projects provision — then retry. tier set and projects provision accept --idempotency-key so retries never double-charge
Retry policy:
- Retry directly only when
retryable: true and safe_to_retry: true; reuse the same idempotency key for mutating operations.
safe_to_retry: true alone is not a retry signal; it means duplicate-safe, not likely-to-succeed. Lifecycle-gated writes, auth token exchanges, and passkey verifies need the indicated action before retrying.
run402 deploy apply already handles safe BASE_RELEASE_CONFLICT release races for omitted/current-base specs by re-planning through the SDK. A handled retry appears as a deploy.retry stderr event; exhausted retries include attempts, max_retries, and last_retry_code. Static activation/config failures reported from activation_pending throw promptly with gateway metadata. Do not hand-roll this specific deploy race loop.
- For mutating 5xx errors with
safe_to_retry: false, or mutation_state: "committed", "partial", or "unknown", inspect/poll/reconcile state before retrying. For deploys, inspect events or resume the existing operation instead of starting a duplicate deploy.
- Lifecycle/payment codes usually require an action:
PROJECT_FROZEN/PROJECT_DORMANT/PROJECT_PAST_DUE -> check usage or renew tier; PAYMENT_REQUIRED/INSUFFICIENT_FUNDS -> submit/fund payment.
Examples:
{ "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" }] }
{ "message": "Payment required.", "code": "PAYMENT_REQUIRED", "category": "payment", "retryable": true, "safe_to_retry": true, "next_actions": [{ "type": "submit_payment" }] }
{ "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" }] }
Deploying
deploy-dir — the modern path
deploy-dir walks a local directory, hashes each file client-side, and only PUTs bytes the gateway doesn't already have. Re-deploying an unchanged tree returns immediately with bytes_uploaded: 0.
run402 sites deploy-dir ./dist > result.json 2> events.log
Skips .git/, node_modules/, .DS_Store automatically. Symlinks throw (no cycles).
stderr streams progress events — one JSON object per line:
| phase | When fired | Extra fields |
|---|
plan | After the planning request | manifest_size (file count) |
upload | After each missing file finishes PUTing | file, sha256, done, total |
commit | Just before commit | — |
poll | Per server-side copy poll | status, elapsed_ms |
Pass --quiet to suppress events; the final result envelope still goes to stdout.
deploy — one-call full stack
For a database + migrations + manifest + secret dependencies + functions + site + subdomain, set secret values first, then deploy a value-free manifest:
run402 secrets set <project_id> OPENAI_API_KEY --file ./.secrets/openai-key
run402 deploy apply --manifest app.json --final-only
Use --final-only (alias of --quiet) when an agent or CI job only wants the final stdout JSON envelope. Use repeatable --allow-warning <code> for reviewed warning codes; reserve broad --allow-warnings for reviewed exceptional cases.
After deploys, inspect release state without starting another mutation:
run402 deploy release active --project prj_...
run402 deploy verify op_... --project prj_... --wait --timeout 120
run402 deploy release get rel_... --project prj_...
run402 deploy release diff --from empty --to active --project prj_...
After pointer-swap recovery, compose promote with verification:
run402 deploy promote RELEASE_ID --project PROJECT_ID
run402 deploy verify --operation OPERATION_ID --wait
Take OPERATION_ID from the promote result. A successful promote means the origin pointer is active, while mutable public URLs can still be converging. Inspect edge.state; edge.verify_url is the direct operation-scoped HTTP verification endpoint.
Inventories expose site paths, static_public_paths when returned, functions, secret keys only, subdomains, materialized routes, applied migrations, release_generation, static_manifest_sha256, nullable static_manifest_metadata, and warnings when returned. site.paths is the release static asset inventory; static_public_paths[] is the browser reachability inventory with public_path, asset_path, reachability_authority, direct, cache class, and content type. static_manifest_metadata: null means unavailable, not zero. Release diffs use migrations.applied_between_releases, route added / removed / changed buckets, and static_assets counters for unchanged/changed/added/removed files, CAS byte reuse, eliminated deployment-copy bytes, and immutable/CAS warning counts.
Same-origin web routes
Use run402 deploy apply for site.public_paths clean static browser URLs and public browser routes to functions or exact method-aware static aliases. Release static asset paths and public browser paths are distinct: events.html can be a private release asset while /events is the public static URL.
{
"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" } }
]
}
}
site.public_paths.mode: "explicit" means only the complete public_paths.replace table is directly reachable as static URLs. In the example, /events serves release asset events.html, while /events.html is not public unless separately declared. { "mode": "implicit" } restores filename-derived public reachability and can widen access; review gateway warnings before confirming that switch. Public-path-only site specs are meaningful deploy content.
Omit routes or pass routes: null to carry forward base routes. Use routes: { "replace": [] } to clear the route table. Do not use path-keyed maps. Function targets use { "type": "function", "name": "<materialized function name>" }. Prefer site.public_paths for ordinary clean static URLs e.g. /events -> events.html. Static route targets use exact patterns only, methods ["GET"] or ["GET","HEAD"], and { "pattern": "/events", "methods": ["GET", "HEAD"], "target": { "type": "static", "file": "events.html" } } for route-only aliases; file is a release static asset path, not a public path, URL, CAS hash, rewrite, or redirect. Direct /functions/v1/:name remains API-key protected; browser-routed paths are public same-origin ingress, so the function owns application auth, CSRF for cookie-authenticated unsafe methods, CORS/OPTIONS, cookies, redirects, and spoofed forwarding-header hygiene.
Function routes may declare fixed tenant x402 pricing: { "pattern": "/api/credits", "methods": ["POST"], "target": { "type": "function", "name": "credits" }, "pricing": { "mode": "always", "amount_usd_micros": 250000, "pay_to": "org_default_payout" } }. 250000 is $0.25 per matching action. Omit networks for production mainnet only; include "testnet" explicitly for testnet payments. Static aliases cannot be priced, direct function invocation is not monetized, and service/admin keys do not bypass a priced browser route. Before deploying priced routes, ensure the org has a payout wallet with run402 org payout-wallet <org_id> <wallet_address>. For conditional credits, use one fixed-price /api/credits route and keep the app envelope unpriced behind app-local auth. In the handler, import getRoutedPaymentContext from @run402/functions, call getRoutedPaymentContext(req), and key idempotency by payment.paymentId; audit with run402 projects tenant-payments <project_id>.
Matching is exact or final /* prefix only. /admin/* does not match /admin; use both /admin and /admin/* for a dynamic area root. Query strings are ignored for matching and preserved in the handler's full public req.url. Exact beats prefix, longest prefix wins, and method-compatible dynamic routes beat static assets. A POST /login route can coexist with static GET /login HTML. Unsafe method mismatch returns 405; matched dynamic route failures fail closed.
Routed functions use the Node 22 Fetch Request -> Response contract: export default async function handler(req) { ... }. req.method is the browser method, and req.url is the full public URL on managed subdomains, deployment hosts, and verified custom domains. Derive OAuth callbacks from it, for example new URL("/admin/oauth/google/callback", new URL(req.url).origin). Append multiple cookies with headers.append("Set-Cookie", value); redirects, cookies, and query strings are preserved. The raw run402.routed_http.v1 envelope is internal; do not write route handlers against it.
Use run402 deploy diagnose --project prj_123 https://example.com/events --method GET before mutating deploy state when the question is "what would this public URL serve?" For lower-level parity use run402 deploy resolve --project prj_123 --url https://example.com/events?utm=x#hero --method GET or run402 deploy resolve --project prj_123 --host example.com --path /events --method GET; never combine --url with --host/--path. Output is JSON with status, would_serve, diagnostic_status, match, normalized request, warnings, full resolution, edge_propagation, and structured next_steps. URL query strings/fragments are disclosed in request.ignored. When returned, asset_path, reachability_authority, and direct explain which release asset backs the public URL and whether reachability came from implicit file-path mode, explicit site.public_paths, or a route-only static alias. Stable-host diagnostics may also include authorization_result, cas_object (sha256, exists, expected_size, actual_size), hostname-specific response_variant, route/static fields e.g. allow, route_pattern, target_type, target_name, and target_file, plus edge_propagation (settled, propagating, or sync_pending; non-settled means retry or run run402 up verify). Known match literals are 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, and route_method_miss; preserve unknown future strings. Known authorization_result values include 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, and unauthorized_cas_object. Known fallback_state values include active_release_missing, unsupported_manifest_version, and negative_cache_hit; preserve unknown future strings. result is diagnostic body status, not CLI process status, so host misses can exit 0 with would_serve: false. Do not use diagnostics as a fetch, cache purge, or reason to hard-code cache_policy strings; branch on structured JSON e.g. allow, cas_object, and edge_propagation.
Known route warning recovery: PUBLIC_ROUTED_FUNCTION means review app auth, CSRF, CORS/OPTIONS, and cookies before retrying with --allow-warning PUBLIC_ROUTED_FUNCTION; broad --allow-warnings is last resort after every warning is reviewed. ROUTE_SHADOWS_STATIC_PATH and WILDCARD_ROUTE_SHADOWS_STATIC_PATHS mean inspect affected paths, active routes, static_public_paths, and resolve diagnostics before confirming. STATIC_ALIAS_SHADOWS_STATIC_PATH, STATIC_ALIAS_RELATIVE_ASSET_RISK, STATIC_ALIAS_DUPLICATE_CANONICAL_URL, STATIC_ALIAS_EXTENSIONLESS_NON_HTML, and STATIC_ALIAS_TABLE_NEAR_LIMIT are route-only static alias warnings; prefer site.public_paths for ordinary clean URLs, inspect the backing asset_path, fix relative assets/canonical URLs, and avoid table-exhausting page-by-page routes. ROUTE_TARGET_CARRIED_FORWARD means inspect carried-forward function targets. METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK means confirm static fallback is intended. WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS means a wildcard API prefix 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. ROUTE_TABLE_NEAR_LIMIT means consolidate routes. ROUTES_NOT_ENABLED means deploy without routes or request enablement. Runtime route failure codes to branch on: 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 (selected route failed release revalidation), ROUTE_METHOD_NOT_ALLOWED (method mismatch), PAYOUT_WALLET_REQUIRED / PAYOUT_WALLET_AMBIGUOUS / PAYOUT_WALLET_UNRESOLVED (priced-route payout setup), PAYMENT_PROOF_MISMATCH (stale or wrong x402 proof), and ROUTED_RESPONSE_TOO_LARGE (body over 6 MiB).
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 to the manifest, and run 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 e.g. /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".
Routed functions: locale awareness
Declare supported locales as a spec.i18n release slice and the gateway negotiates a locale per routed-function request, then surfaces it to user code through two request headers. Add i18n to the deploy manifest alongside functions and routes and run run402 deploy apply --manifest run402.deploy.json:
{
"project_id": "prj_...",
"functions": {
"replace": {
"api": {
"runtime": "node22",
"source": { "data": "export default async (req) => { const locale = req.headers.get('x-run402-locale'); const def = req.headers.get('x-run402-default-locale'); return Response.json({ locale, default: def }); }" }
}
}
},
"routes": {
"replace": [
{ "pattern": "/api/*", "target": { "type": "function", "name": "api" } }
]
},
"i18n": {
"defaultLocale": "en",
"locales": ["en", "es", "fr", "zh-Hant"],
"detect": ["cookie:wl_locale", "accept-language"]
}
}
Carry-forward semantics: omit i18n to carry forward from base release; pass "i18n": null to clear the slice on the new release; pass { defaultLocale, locales, detect? } to replace. Simpler than routes — no { replace } envelope.
Locale-tag rules (strict, no canonicalization):
defaultLocale MUST be byte-identical to one entry in locales[]. The gateway does NOT silently canonicalize; the CLI/SDK validate this client-side before planning.
- Each tag MUST match
/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/. Tags are opaque — no BCP-47 semantic validation.
locales[] is non-empty, max 50 entries.
- Negotiation returns canonical casing from
locales[], NOT the request's casing.
Detection (detect[], default ["accept-language"], max 10, [] allowed and means "always default"):
- Walked in order; first match wins.
"accept-language" parses per RFC 9110, drops q=0 and *, sorts by q descending; applies RFC 4647 §3.4 lookup-style truncation (zh-Hant-TW → zh-Hant → zh); longest matching prefix wins. A generic request tag does NOT match a more-specific locales[] entry — Accept-Language: es does NOT match locales: ["es-MX"].
"cookie:<name>" does a case-sensitive cookie-name lookup; the raw cookie value (no percent-decode) is matched case-insensitively against locales[]. Cookie names MUST match RFC 6265 grammar (/^[!#$%&'*+\-.^_|~0-9A-Za-z]+$/`).
Read the negotiated locale inside a routed function (single-arg (req) signature, not (req, ctx)):
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' });
};
x-run402-locale and x-run402-default-locale are OMITTED entirely when the active release has no i18n slice (additive-compat). The gateway injects them at request time, so already-deployed function bundles see new headers on the next deploy that adds i18n — no function redeploy required. The bundled @run402/functions runtime translates the routed envelope into a Web-standard Request before calling user code, so the routed-envelope context.locale is NOT visible to typical user functions — read the headers instead.
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.
Client-side gotcha: language switchers must write a cookie
Apps that persist locale to localStorage only (a common pattern from Astro/Next i18n tutorials) won't be seen by Run402's server-side negotiation. Mirror the locale to a cookie so the next request hits the right translations, then declare a 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`;
}
{ "i18n": { "defaultLocale": "en", "locales": ["en", "es"], "detect": ["cookie:wl_locale", "accept-language"] } }
The deploy manifest is a v2 ReleaseSpec; put the auth manifest under database.expose:
{
"project_id": "prj_…",
"database": {
"migrations": [{ "id": "001_init", "sql_path": "setup.sql" }],
"expose": {
"$schema": "https://run402.com/schemas/manifest.v1.json",
"version": "1",
"tables": [
{ "name": "items", "expose": true, "policy": "user_owns_rows",
"owner_column": "user_id", "force_owner_on_insert": true }
]
}
},
"secrets": { "require": ["OPENAI_API_KEY"] },
"functions": {
"replace": {
"my-fn": {
"runtime": "node22",
"source": { "data": "export default async (req) => new Response('ok')" },
"config": { "timeoutSeconds": 30, "memoryMb": 256 }
}
}
},
"site": {
"replace": {
"index.html": { "data": "<!doctype html>…" },
"logo.png": { "data": "iVBORw0…", "encoding": "base64" }
}
},
"subdomains": { "set": ["my-app"] }
}
The database.expose entry is auth-as-SDLC — your authorization travels with the release. The gateway validates it against the migration SQL and applies it atomically. If the manifest references a table the migration doesn't create, the deploy is rejected with HTTP 400 and a structured errors array listing every violation.
Provision first (run402 projects provision) so you have the anon_key to embed in your HTML before deploying.
GitHub Actions OIDC deploys
Link once locally, then let GitHub Actions run the same deploy command agents already use:
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/*
git add .github/workflows/run402-deploy.yml run402.deploy.json
git commit -m "Add run402 deploy workflow"
The generated workflow uses a pinned run402@<current> CLI via npx, includes permissions: id-token: write and contents: read, and runs:
run402 deploy apply --manifest run402.deploy.json --project prj_...
Useful follow-ups:
run402 ci list --project prj_...
run402 ci revoke cib_...
V1 intentionally keeps the shape narrow: push and workflow_dispatch only, no PR deploy flags, no raw subject or wildcard flags, and no soft repository-id binding. Without --route-scope, CI cannot deploy routes; with repeatable route scopes, it may deploy only matching exact paths e.g. /admin or final-wildcard prefixes e.g. /api/*. If CI returns CI_ROUTE_SCOPE_DENIED, re-link with covering scopes or run the route-changing deploy locally. Revocation stops future CI gateway requests but does not undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions.
Authorization — the expose manifest
Tables you create are dark by default. Until your manifest declares a table with expose: true, it's invisible to anon and authenticated callers. This eliminates the "agent forgot RLS, data leaked" footgun. The manifest is the single source of truth for what's reachable via /rest/v1/*.
JSON Schema: https://run402.com/schemas/manifest.v1.json. Set $schema on your manifest file and your editor gets autocomplete for free.
Preferred: ship manifest.json in your bundle
Authorization travels with your code. Put a file named manifest.json in the bundle's files[] and the gateway reads it, validates it against your migration SQL, applies it, and strips it from files[] before the site deploys — so it's never publicly reachable on your subdomain. The deploy response includes manifest_applied: true on success.
{
"$schema": "https://run402.com/schemas/manifest.v1.json",
"version": "1",
"tables": [
{ "name": "items", "expose": true, "policy": "user_owns_rows",
"owner_column": "user_id", "force_owner_on_insert": true },
{ "name": "audit", "expose": false }
],
"views": [
{ "name": "leaderboard", "base": "items", "select": ["user_id", "score"], "expose": true }
],
"rpcs": [
{ "name": "compute_streak", "signature": "(user_id uuid)", "grant_to": ["authenticated"] }
]
}
If the manifest references a table the migration doesn't create, the deploy is rejected with HTTP 400 and a structured errors array listing every violation (not the first).
Non-mutating validation: validate-expose
Before applying, run:
run402 projects validate-expose [id] --file manifest.json --migration-file migrations.sql
This validates the auth/expose manifest used by manifest.json, database.expose, and apply-expose; it is not deploy-manifest validation. Migration SQL is used only for reference checks and is not executed as a PostgreSQL dry run. The command prints { "hasErrors": boolean, "errors": [...], "warnings": [...] } and exits 0 even when hasErrors is true — the validator ran, so the command succeeded; read hasErrors to decide what to do.
Imperative escape hatch: apply-expose
For ad-hoc changes outside a deploy — same JSON shape, no bundle:
run402 projects apply-expose <id> --file manifest.json
run402 projects get-expose <id>
get-expose returns the live state. source: "applied" means it came from a prior apply-expose (or a bundled manifest.json); "introspected" means no manifest has ever been applied and the response was reconstructed from live DB state.
Convergent: applying the same manifest twice is a no-op. Items removed between applies have their policies, grants, triggers, and views dropped. Always include everything you want exposed.
Built-in policies
| Policy | Allows |
|---|
user_owns_rows | Rows where owner_column = auth.uid(). With force_owner_on_insert: true, a BEFORE INSERT trigger sets it automatically. Default for anything user-scoped. |
public_read_authenticated_write | Anyone reads. Any authenticated user writes any row. For shared boards / collaborative content. |
public_read_write_UNRESTRICTED | Fully open. Requires i_understand_this_is_unrestricted: true on the table entry. Only for guestbooks / waitlists / feedback forms. |
custom | Escape hatch. Provide custom_sql with CREATE POLICY statements. |
Views always run with security_invoker=true — they inherit the underlying table's RLS, so they can't accidentally leak hidden columns. RPCs are not exposed unless listed in rpcs[] (a database event trigger revokes PUBLIC EXECUTE on every newly-created function).
Storage — paste-and-go assets
run402 assets put returns an AssetRef. The URL is content-addressed (pr-<public_id>.run402.com/_blob/<key>-<8hex>.<ext>), served through CloudFront, and never needs cache invalidation:
run402 assets put ./logo.png --json
run402 assets put ./app.js --json
run402 assets put ./styles.css --json
run402 assets put ./asset --key assets/logo --content-type image/svg+xml --json
run402 assets put ./hero.jpg --meta uploaded_by=agent_abc --meta tags=hero,banner --exif-policy strip --json
Each response includes:
| Field | Use |
|---|
cdn_url | The content-addressed URL — paste straight into src= / href= |
sri | sha256-<base64> for <script integrity="…"> if you build tags by hand |
etag | Strong "sha256-<hex>" ETag |
cache_kind | immutable / mutable / private |
metadata (v1.50) | Echo of your --meta bag, or null |
image_format (v1.50) | Decoded format (jpeg/png/webp/…) or null for non-images |
image_info (v1.50) | has_alpha, color_space, animated, frame_count, bit_depth, orientation |
image_exif (v1.50) | EXIF block (null when stripped or no EXIF) |
image_exif_policy (v1.50) | keep or strip, echoing the applied policy |
Immutable upload is the default since v1.45 — the SDK computes the SHA-256 client-side and pairs the URL with SRI. The browser refuses execution on byte mismatch. No invalidation choreography.
blob put infers MIME type from the destination key. Use --content-type <mime> for extensionless assets, unusual file types, or deliberate overrides.
v1.50 --meta coercion: pure digits become a number, true/false become a boolean, values containing , become a string[], everything else is a string. ≤4 KB serialized; bad shapes are rejected client-side with INVALID_ASSET_METADATA before any HTTP call.
List, fetch, remove, sign
run402 assets ls --prefix images/
run402 assets ls --sort createdAt:desc --filter is_image=true --filter min_width=320 --filter format=webp
run402 assets ls --filter uploaded_by=agent_abc --filter tag=hero
run402 assets get images/logo.png --output /tmp/logo.png
run402 assets rm images/old.png
run402 assets sign secrets/report.pdf --ttl 3600
--sort (v1.50) takes key:asc (default), createdAt:asc, or createdAt:desc. Cursors are sort-pinned — cross-sort reuse returns INVALID_CURSOR_FOR_SORT. --filter k=v is repeatable; allowed keys: uploaded_by, tag, format, is_image, min_width/max_width/min_height/max_height. Unknown keys are rejected client-side with INVALID_FILTER_KEY.
Diagnose a stale CDN
run402 assets diagnose <url>
run402 cdn wait-fresh <url> --sha <hex> --timeout 120
diagnose is shell-loop friendly: until run402 assets diagnose <url>; do sleep 1; done blocks until the CDN catches up. Vantage is single-region (us-east-1) — other PoPs may differ. Don't call wait-fresh on immutable URLs — they're correct from the moment of upload.
Database
run402 projects sql <id> "CREATE TABLE items (id serial PRIMARY KEY, title text NOT NULL, user_id uuid)"
run402 projects sql <id> --file migrations.sql
run402 projects sql <id> "SELECT * FROM items WHERE id = \$1" --params '[42]'
run402 projects rest <id> items "select=id,title&order=id.desc&limit=10"
run402 projects validate-expose <id> --file manifest.json --migration-file migrations.sql
run402 projects schema <id>
run402 projects usage <id>
run402 projects costs <id> --window 30d
Idempotent migrations
CREATE TABLE IF NOT EXISTS only handles "already exists" errors — it won't add new columns to an existing table. For evolving schemas, wrap ALTER TABLE in a 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 $$;
This pattern is safe to re-run on every deploy.
SQL guardrails
The SQL endpoint blocks: CREATE EXTENSION, COPY ... PROGRAM, ALTER SYSTEM, SET search_path, CREATE/DROP SCHEMA, GRANT/REVOKE, CREATE/DROP ROLE. Table and sequence permissions are granted automatically — use the expose manifest for access control instead of GRANT.
Functions
Node 22 runtime. Handler: export default async (req: Request) => Response.
run402 functions deploy <id> my-fn --file fn.ts \
--timeout 30 --memory 256 \
--schedule "*/15 * * * *" \
--deps "stripe,zod@^3,date-fns@3.6.0"
run402 functions invoke <id> my-fn --body '{"hello":"world"}'
run402 functions invoke <id> paid-fn --body '{"text":"hi"}' --idempotency-key paid:call:123 --wait
run402 functions logs <id> my-fn --tail 100 --request-id req_abc123 --follow
run402 functions runs create <id> my-fn --event-type reminder.send --idempotency-key reminder:123 --delay 10m
run402 functions update <id> my-fn --schedule "0 */6 * * *"
run402 functions rebuild <id> my-fn
run402 functions rebuild <id> --all
run402 functions list <id>
run402 functions delete <id> my-fn
run402 functions list returns the recorded runtime_version, gateway runtime_current_version, guaranteed runtime_minimum_version, and runtime_stale for each function. The current 3.7.0 floor includes getRoutedPaymentContext() for priced routes. run402 functions rebuild is opt-in and never changes your source: it re-bundles the stored source against the platform's current runtime/entry-wrapper (deps pinned to the versions recorded at deploy), so the source code_hash is unchanged and no new release is created. 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. Functions deployed before dependency locking return CANNOT_REBUILD_UNLOCKED_DEPS; redeploy those from source instead.
run402 functions runs manages durable function requests. create requires --event-type and --idempotency-key, accepts --payload-json, --delay or --run-at, expiry, retry options, and optional --wait. list, get, logs, cancel, and redrive inspect and recover work by fnrun_.... Use durable runs for delayed work, webhook redrive, and retry-safe background tasks instead of ad hoc cron tables.
--deps accepts npm specs: bare names (lodash) resolve to the latest version at deploy time, pinned (lodash@4.17.21) and ranges (date-fns@^3.0.0) are honored verbatim. Max 30 entries, 200 chars each. Native binary modules (sharp, canvas, native bcrypt, etc.) are rejected — pure JS only. Don't list @run402/functions (auto-bundled).
The deploy response surfaces:
runtime_version — the bundled @run402/functions version
deps_resolved — { name: version } for every package the gateway pinned, including transitives
warnings — non-fatal notes (e.g. when a bare spec resolved to a version that's later than what's typical)
Inside the function — @run402/functions
The one place to write code, not commands. Built-in helpers are auto-bundled:
import { db, adminDb, auth, email, ai, assets } from "@run402/functions";
export default async (req: Request) => {
const user = await auth.user();
if (!user) return new Response("unauthorized", { status: 401 });
const mine = await db(req).from("items").select("*");
await adminDb().from("audit").insert({ event: "items_read", user_id: user.id });
if (mine.length === 0) {
await email.send({ to: user.email, subject: "Welcome", html: "<h1>Hi</h1>" });
}
return Response.json(mine);
};
db(req) — caller-context. Forwards Authorization header. RLS applies. Default choice.
adminDb() — bypass RLS. Use only for audit logs, cron cleanup, webhook handlers, platform-authored writes.
adminDb().sql(query, params?) — raw parameterized SQL. Always bypass.
ai.generateImage({ prompt, aspect? }) — live image generation from deployed functions, billed/rate-limited against the project organization through RUN402_SERVICE_KEY. Aspects: square, landscape, portrait; result: { image, content_type, aspect }. For public routed functions, authenticate/rate-limit app users before calling it.
assets.put(key, source, opts?) — upload runtime bytes through the same CAS-backed apply substrate as deploy-time assets. source is a string, Uint8Array, or { content | bytes }; returns an SDK-compatible AssetRef.
auth.* — canonical cookie/session auth namespace (auth.user, auth.requireUser, auth.requireRole, auth.requireMembership, auth.fetch, auth.sessions.*, auth.identities.link). Bare legacy helpers such as getUser, getUserId, and getRole were retired in @run402/functions v3.0 and fail run402 doctor.
- Function-level gate headers — when
FunctionSpec.requireAuth / requireRole passes, read req.headers.get("x-run402-user-id") and req.headers.get("x-run402-user-role") directly. Use these inside a gated function instead of re-decoding the JWT; the gate already verified the caller and resolved the application role.
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.
Fluent surface on both db(req).from(t) and adminDb().from(t):
- Reads:
.select(), .eq(), .neq(), .gt(), .lt(), .gte(), .lte(), .like(), .ilike(), .in(), .order(), .limit(), .offset()
- Writes:
.insert(obj | obj[]), .update(obj), .delete() — return arrays of affected rows
- Column narrowing on writes:
.insert({…}).select("id, title")
For TypeScript autocomplete in your editor: npm install @run402/functions in your project. Also works at build time for static-site generation if you set RUN402_SERVICE_KEY + RUN402_PROJECT_ID in .env.
Function-level auth gates (v1.51+)
Skip the hand-rolled "decode JWT → query members table → return 403" boilerplate. Declare the gate on your FunctionSpec and the gateway enforces it before invoking the function. Unauthorized callers get 401/403 without your code running, and the gateway injects the resolved identity into request headers your function can trust.
Two independent fields on FunctionSpec:
requireAuth: true — gateway rejects callers without a valid project user JWT with 401. No DB lookup.
requireRole: { table, idColumn, roleColumn, allowed[], cacheTtl? } — gateway resolves the caller's role from the project-schema table (RLS-bypass — the gateway is the trusted intermediary, not the caller) and rejects callers whose role is not in allowed with 403. Implies authentication.
Declare in your deploy manifest and run run402 deploy apply --manifest run402.deploy.json:
{
"project_id": "prj_...",
"functions": {
"patch": {
"set": {
"list-my-items": {
"source": { "path": "functions/list.ts" },
"requireAuth": true
},
"delete-content": {
"source": { "path": "functions/delete.ts" },
"requireRole": {
"table": "members",
"idColumn": "user_id",
"roleColumn": "role",
"allowed": ["admin"],
"cacheTtl": 60
}
},
"moderate-content": {
"source": { "path": "functions/moderate.ts" },
"requireRole": {
"table": "members",
"idColumn": "user_id",
"roleColumn": "role",
"allowed": ["admin", "moderator"]
}
}
}
}
}
}
Reading the gate result inside the function:
export default async (req: Request): Promise<Response> => {
const userId = req.headers.get("x-run402-user-id");
const role = req.headers.get("x-run402-user-role");
return Response.json({ actor: userId, role });
};
Rules and footnotes:
- One role table per release. All
requireRole blocks in a single release must share the same (table, idColumn, roleColumn) triple. Different allowed sets are fine; different tables are rejected at plan time with INVALID_SPEC.
- Unqualified identifiers. Schema-qualified names (e.g.
"public.members") are rejected. The project schema is resolved server-side.
- Deploy-time validation. Missing table or column at activation fails with
DEPLOY_INVALID_ROLE_GATE (422) before flipping the live release. run402 deploy apply surfaces the error envelope on stderr.
- Cache TTL. Default 60s, max 600s. A demoted user keeps the cached role until expiry — for instant revocation, set
cacheTtl: 0 (fresh lookup per request).
- Gate applies to both routed (
/your/route) and direct (POST /functions/v1/:name with API key) invocation. Direct invocation still requires the API key at the edge; the gate runs after API-key auth, against the user JWT.
Astro SSR runtime + ISR cache (v1.52+)
For Astro apps, scaffold with run402 init astro (sets up astro.config.mjs with the @run402/astro 1.0+ preset, [slug].astro SSR template wired to the DB, layouts, .env.example). Run run402 dev to start astro dev with project credentials in scope.
Functions opt into the SSR class declaratively:
{
"functions": {
"ssr": {
"class": "ssr",
"code": { "data": "...", "encoding": "base64" }
}
}
}
The gateway provisions SnapStart-enabled Lambda and reverse-validates the published version before activation. Failure ships the function anyway and surfaces DEPLOY_FUNCTION_SSR_SNAPSTART_VALIDATION_FAILED as a non-blocking warning.
Cache is bypass-by-default. SSR responses only get cached when Cache-Control explicitly allows it AND no Set-Cookie AND no auth-taint flag — auth.* helpers automatically taint per-request caching so personalized renders never get stored.
Invalidate from the CLI:
run402 cache invalidate https://eagles.kychon.com/the-guys — single URL
run402 cache invalidate https://eagles.kychon.com/blog --prefix — path prefix
run402 cache invalidate https://eagles.kychon.com --all — all rows for the host
run402 cache inspect https://eagles.kychon.com/the-guys — peek at cache state
Host ownership is server-validated; cross-project hosts throw R402_CACHE_INVALIDATION_HOST_FORBIDDEN. Writes are generation-guarded: in-flight MISS renders started before an invalidate cannot overwrite the freshly-cleared state.
Agent-DX shortcuts:
run402 doctor — 5 health checks (credentials, allowance, project, network, SDK build), --json for machine-readable output, exit 1 on fail.
run402 dev — runs npx astro dev with .env.local + Run402 credentials in scope.
run402 logs --request-id req_XYZ — fetch logs for a specific request id across every function in the project (parallel scan, timestamp-ascending merge).
Reference: astro/README.md (top section), cli/llms-cli.txt (R402_* SSR Runtime Error Codes section).
Rehearsals, snapshots, and branches
For database-bearing deploys, rehearse before commit:
run402 apply --manifest app.json --rehearse --json
run402 deploy rehearse <plan_id> --project prj_... --teardown on_pass --json
run402 apply --rehearse is the canonical one-shot path: plan, upload missing CAS bytes, create a contained branch, apply the candidate plan there, run checks, and exit nonzero on a failed rehearsal. Add --commit only after the rehearsal report is acceptable.
Manual restore points and branches are reference primitives:
run402 snapshots create prj_... --json
run402 snapshots list prj_... --json
run402 snapshots restore prj_... snap_... --json
run402 snapshots restore prj_... snap_... --confirm <token> --json
run402 branches create prj_... --ttl-days 7 --json
run402 branches list prj_... --json
run402 branches renew prj_... br_... --ttl-days 7 --json
run402 branches delete prj_... br_... --json
Snapshot restore is deliberately two-step so the user/agent sees the data-loss plan before mutation. Branches default to a 7-day TTL, use derived noindex hosts, sandbox email unless explicitly disabled, and keep scheduled functions off unless requested.
Portable project archives (Cloud -> Core)
Use portable archives when the user wants no vendor lock-in for the supported Run402 Core runtime slice. This is a portability trust claim: Cloud is the easiest place to start, not the only place the supported application can run. Keep it separate from allowance/spend-cap financial-risk claims.
Canonical CLI path:
run402 cloud archives create <project_id> --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
run402 core projects import ./project.r402ar --name imported-project --env-file ./required.env --json
run402 projects export <project_id> --output ./project.r402ar --json
run402 core projects apply ./project.r402ar --name imported-project --env-file ./required.env --json
projects export aliases the Cloud archive export flow; core projects apply aliases Core archive import. MCP tools mirror the same flow: export_project_archive, inspect_project_archive, verify_project_archive, and import_project_archive. SDK helpers live under r.archives; the Node entry adds local inspect, verify, and importToCore, plus standalone inspectArchive, verifyArchive, and importArchiveToCore.
Archive v1 exports active release/apply state, supported Postgres/RLS/REST data, storage/static bytes, functions, Astro SSR artifacts, disabled auth subject stubs, and value-free secret requirements. It does not export secret values, auth credentials, logs, billing/allowance/spend state, Cloud provider/fleet operations metadata, Cloud import, or existing-project merge import. verify is local/offline integrity and compatibility checking, not trust; Core import verifies again and creates a new local project only.
Calling a function from the browser
Agent-side calls should use run402 functions invoke; this direct fetch()
shape is only for app/browser code that needs to call the deployed function
from the user's session.
const res = await fetch("https://api.run402.com/functions/v1/my-fn", {
method: "POST",
headers: {
apikey: ANON_KEY,
Authorization: "Bearer " + session.access_token,
"Content-Type": "application/json",
},
body: JSON.stringify({ hello: "world" }),
});
Scheduled functions
Pass a 5-field cron expression. To remove a schedule: --schedule-remove. Tier limits:
| Tier | Max scheduled | Min interval |
|---|
| Prototype | 1 | 15 min |
| Hobby | 3 | 5 min |
| Team | 10 | 1 min |
Secrets
Injected as process.env.<KEY> inside every function. Values are write-only — list returns keys and timestamps only, never values or value-derived hashes. Deploy manifests use secrets.require[] to assert keys exist; they never carry secret values.
run402 secrets set <id> STRIPE_KEY --file ./.secrets/stripe-key
printf %s "$STRIPE_KEY" | run402 secrets set <id> STRIPE_KEY --stdin
run402 secrets set <id> JWT_PRIVATE --file ./private.pem
run402 secrets list <id>
run402 secrets delete <id> STALE_KEY
Jobs
Platform-managed jobs. This is not arbitrary Docker execution: submit the gateway-shaped request with job_type, input["input.json"], and max_cost_usd_micros, then inspect status/logs, cancel one run, or purge all project runs.
run402 jobs submit --file job.json --project <id>
run402 jobs get <job_id> --project <id>
run402 jobs logs <job_id> --project <id> --tail 100
run402 jobs cancel <job_id> --project <id>
run402 jobs purge --project <id>
run402 jobs artifacts get <job_id> result.json --output ./result.json --project <id>
When a job completes, jobs get returns an artifacts map keyed by filename, each value an object { url, content_type, sha256, size_bytes } (the old run402:// ref strings were retired; sha256/size_bytes are omitted for pre-change jobs). Download the bytes with jobs artifacts get; discover the recorded filenames from the job's artifacts map.
Email
Up to 5 mailbox local parts per project. The gateway returns managed_address as <slug>@<project-mail-host>.mail.run402.com; another project may use the same slug safely. Optionally bring your own domain. Configure explicit defaults before relying on omitted --mailbox sends.
run402 email create my-app
run402 email mailboxes
run402 email defaults --outbound my-app --auth-sender my-app
run402 email update my-app --footer-policy none
run402 email send --to user@example.com \
--template notification \
--var project_name="My App" --var message="Hello"
run402 email send --to user@example.com \
--subject "Welcome" --html '<h1>Hi</h1>' --from-name "My App"
run402 email list
run402 email get <message_id>
run402 email get-raw <message_id> --output msg.eml
Omitting --mailbox on email send uses default_outbound_mailbox_id; missing/invalid defaults return typed errors such as DEFAULT_MAILBOX_REQUIRED / DEFAULT_MAILBOX_INVALID with next_actions. Successful sends echo the actual mailbox_id and from_address when the gateway returns them.
Mailbox reads (email mailboxes / email info) include footer_policy, effective_footer_policy, and footer_policy_locked_reason when the gateway returns them. Use run402 email update <slug|mbx_id> --footer-policy run402_transparency|none to set the policy. none is allowed on hobby/team projects; prototype projects stay locked to run402_transparency and attempts to set none return FOOTER_POLICY_TIER_REQUIRED.
Templates: project_invite (project_name, invite_url), magic_link (project_name, link_url, expires_in), notification (project_name, message ≤ 500 chars).
Tier rate limits: prototype 10/day, hobby 50/day, team 500/day. Unique recipients per lease: 25 / 200 / 1000.
Webhooks
run402 email webhooks register --url https://… --events delivery,bounced,reply_received
run402 email webhooks list
run402 email webhooks update <id> --events delivery,bounced,complained,reply_received
run402 email webhooks delete <id>
run402 email webhooks deliveries --status failed_permanent
run402 email webhooks redrive <delivery_id>
run402 email list --direction inbound
Project domains for email
run402 domains connect example.com --email-send --email-receive --mailbox-addresses primary --addresses info
run402 domains dns example.com --format bind
run402 domains check example.com
run402 domains repair example.com
run402 domains test-receive example.com --to info
User auth
Auth supports passwords, magic links, Google OAuth, and WebAuthn passkeys. Google is on for all projects with zero config; passkeys require an exact allowed app_origin (claimed subdomain, project public-id host, active custom domain, or localhost when allowed).
run402 auth magic-link --email user@example.com --redirect https://my-app.run402.com/cb
run402 auth verify --token <token>
run402 auth invite-user --email admin@example.com --redirect https://my-app.run402.com/cb --admin true
run402 auth set-password --token <bearer> --new <pwd>
run402 auth passkey-login-options --app-origin https://my-app.run402.com
run402 auth passkey-login-verify --challenge <id> --response '<json>'
run402 auth providers
Magic-link tokens are single-use, expire in 15 min, and are rate-limited. The access_token works as apikey for user-scoped REST calls subject to RLS. Use run402 auth settings --preferred passkey --require-admin-passkey true to require eligible passkey auth for project_admin sessions.
For browser-side flows (PKCE, Google OAuth, refresh-token rotation), see https://docs.run402.com/llms-cli.txt.
Subdomains and project domains
run402 subdomains claim my-app
run402 subdomains list
run402 subdomains delete my-app --confirm
run402 domains connect example.com --web --web-target production
run402 domains status example.com
run402 domains dns example.com
run402 domains list
run402 domains disconnect example.com --confirm
Domain commands use project-scoped control-plane auth (wallet, operator session, or delegate), so a project shown by run402 projects list is not vetoed by a missing local project-key cache entry.
Subdomain auto-reassignment: claim once. Every subsequent run402 sites deploy-dir to the same project automatically points the subdomain at the new deployment. The deploy response includes subdomain_urls showing what got reassigned. No re-claim needed.
On-chain — KMS signers
For agents that need to sign Ethereum transactions. Private keys never leave AWS KMS — there is no export, ever. $0.04/day rental + $0.000005/call. Signer creation requires $1.20 in cash credit (30 days prepaid). Non-custodial — see https://run402.com/humans/terms.html#non-custodial-kms-wallets.
run402 contracts provision-signer --chain base-mainnet [--recovery-address 0x…]
run402 contracts list-signers
run402 contracts get-signer <signer_id>
run402 contracts call <project_id> <signer_id> --to 0x… \
--abi @abi.json --fn transfer --args '["0x…", "1000000"]' \
[--value-wei 0] [--idempotency-key <k>]
run402 contracts read --chain base-mainnet \
--to 0x… --abi @abi.json --fn balanceOf --args '["0x…"]'
run402 contracts status <call_id>
run402 contracts drain <signer_id> --to 0x… --confirm
run402 contracts delete <signer_id> --confirm
Tier and billing
Tier is per organization, not per project. run402 tier set is organization-wide — one subscribe / renew / upgrade applies to every project in the organization. api_calls and storage_bytes are pooled across every project in the organization, and across every wallet linked to it via run402 billing link-wallet. Quota-denial error envelopes include details.scope: "organization" | "project" — "organization" for the pooled path, "project" for the orphan fallback (project whose organization row was purged but cascade has not yet run).
run402 tier set prototype
run402 tier set hobby
run402 tier set team
run402 tier status
run402 billing create-email user@example.com
run402 billing link-wallet <org_id> <wallet_address>
run402 billing checkout <org_id> --product tier --tier hobby
run402 billing checkout <org_id> --product email-pack
run402 billing checkout <org_id> --product balance-topup --amount 5000000
run402 billing auto-recharge <org_id> on --threshold 2000
run402 billing balance <org_id | wallet | email>
run402 billing history <org_id | wallet | email>
run402 tier set refetches /tiers/v1/status after the call and includes the refreshed organization-pool snapshot as status_after in the JSON output, so the new pooled api_calls / storage_bytes totals come back in one step.
After subscribing you can create unlimited projects, deploy unlimited sites, fork apps — all free with your active tier, subject to the organization-pooled api_calls and storage_bytes caps. Only image generation ($0.03/image) is per-call.
The server auto-detects the action: no tier or expired → subscribe; same tier active → renew; higher tier → upgrade (prorated refund); lower tier → downgrade (prorated refund if usage fits).
Resource limits
| Prototype | Hobby | Team |
|---|
| Lease | 7 days | 30 days | 30 days |
| Storage | 250 MB | 1 GB | 10 GB |
| API calls | 500K | 5M | 50M |
| Functions | 5 | 25 | 100 |
| Function timeout | 10s | 30s | 60s |
| Function memory | 128 MB | 256 MB | 512 MB |
| Secrets | 10 | 50 | 200 |
| Scheduled fns | 1 / 15min | 3 / 5min | 10 / 1min |
run402 deploy apply preflights literal unified-deploy timeout, memory, cron interval, and scheduled-count values before plan/upload when caps are known; failures are structured BAD_FIELD errors. run402 tier status shows live function caps and current scheduled usage when returned.
Project-level rate limit: 100 req/sec. Exceeding returns 429 with retry_after. Each project has its own Postgres schema; cross-schema access is blocked.
Project lifecycle (~104-day soft delete)
Gateway v1.57 moved the lifecycle state machine from internal.projects to internal.organizations. The grace clock now ticks per organization, not per project — every project on the same organization inherits the same organization_lifecycle_state. The live data plane keeps serving the whole time; only the owner's control plane gets gated:
| State | When | What happens |
|---|
active | — | Full read/write |
past_due | day 0 | Site, REST, email keep serving. Owner gets first email. |
frozen | +14d | Control plane (deploys, secrets, subdomain claims, function upload) returns 403 with lifecycle_state / entered_state_at / next_transition_at. Site still serves. Subdomain reserved so the brand can't be claimed by another wallet. |
dormant | +44d | Scheduled (cron) functions pause. |
purged | +104d | Cascade: schemas dropped, Lambdas deleted, mailboxes tombstoned. Subdomains become claimable 14 days later. |
run402 tier set … at any point during grace reactivates the organization inline and clears every project's timers in one transaction. Each project entry also exposes:
effective_status — derived state for serving (active / past_due / frozen / dormant / archived / deleted). Use this for UX. When a single project is moderate-archived (archived_at set) or user-deleted (deleted_at set) it overrides the organization lifecycle in this field.
organization_lifecycle_state — the raw per-organization state. Identical across every project on the same organization.
lease_perpetual — operator escape hatch flag. When true, the organization never advances past active. Replaces the v1.56 per-project pinned. Toggle via run402 admin lease-perpetual <org_id> --enable | --disable (platform-admin only).
Operator moderation actions (independent of lifecycle): run402 admin archive <project_id> [--reason "..."] sets archived_at; run402 admin reactivate <project_id> flips it back. Both are scoped to a single project — siblings on the same organization keep serving.
Project transfer (unified noun, owned-org recipient v1.96+)
Hand off or move a project without redeploying — one noun, three recipient shapes. --to routes by value: a wallet is a two-party SIWX transfer (recipient completes with accept); an email is an email→org transfer (recipient completes with claim, claiming into an org they own). --to-org moves the project into another org you already own; same-actor owned-org moves complete immediately in the first gateway release. Owner-side mutations on pending wallet/email transfers freeze for the 72-hour window so the recipient reviews exactly what they will own.
run402 transfer init --to 0xRECIPIENT --project <project_id> [--message "..."]
run402 transfer init --to alice@example.com --project <project_id> [--retain-collaborator developer]
run402 transfer init --to-org <org_id> --project <project_id> [--message "..."]
run402 transfer preview <transfer_id>
run402 transfer cancel <transfer_id> [--reason "..."]
run402 transfer accept <transfer_id>
run402 transfer claim <transfer_id> [--into <org_id>] [--accept-retained-collaborator]
run402 transfer list
run402 transfer list --outgoing
Freeze invariant. While pending, owner-side mutations against the project (deploy, secrets, custom domains, function CRUD, scheduled-function changes, mailbox config, CI bindings, project rename) return 409 PROJECT_HAS_PENDING_TRANSFER with details.transfer_id and a next_actions[] cancel route. Data-plane traffic keeps serving. Payment-path routes (tier renew, billing) keep working. The transfer cancel route is intentionally unblocked.
What does NOT transfer: tier lease (stays with the original owner's organization; no Phase 1A proration), KMS signers (run402 contracts ... — wallet-scoped, not project-scoped), GitHub repo ownership (handle out of band), or on-chain balance on any wallet.
Billing policy. Wallet transfers support only --billing-policy migrate (default): the project moves into the recipient's organization. If the recipient has no active organization yet, the accept returns 409 RECIPIENT_ORGANIZATION_NOT_ACTIVE. Email and owned-org transfers always migrate ownership; don't send --billing-policy there.
Secrets rotation prompt. Secret VALUES are inherited at accept. The accept response returns secret_names_inherited[] (names only). The project carries a persistent secrets_rotation_advised advisory; run402 tier status surfaces it on projects[].secrets_rotation_advised. Use run402 secrets set <project> <name> <value> to rotate every inherited name; the advisory clears once every one has been re-written.
run402 tier status also surfaces incoming_transfers[] at the top level (each entry carries preview_path) so a single status call shows pending offers without a separate fetch.
Post-deploy catch-up — the events feed
run402 events reads the platform's durable, cursored per-project feed: deploy activations, mailbox suspensions, transfers, lifecycle cliffs, verification outcomes — each with platform-suggested next_actions. The feed also carries app-emitted business facts (a deployed function's own events.emit(...) calls) alongside those platform events, source-discriminated.
run402 events
run402 events --cursor evc_1a2b
run402 events --org <org_id>
run402 events --source app
run402 events --source app --type signature_completed,booking_created
Store the response's cursor (a file in the repo works) and pass it back as --cursor next session — one call returns everything you missed. Cursors are opaque; never parse them. An expired cursor returns reset: true + earliest_cursor to restart from, never a bare error. Every successful apply/promote response also hands you a poll entry positioned at your own deploy_activated event. The feed is read-only and works even on frozen projects. --source app|platform and --type <name[,name]> filter the feed; consumers should key on the (source, event_type) pair since app-chosen type names are free-form per app.
After a promote — gate on new error identities
run402 errors reads the platform's durable, grouped error memory. Every 5xx at the function invoke choke points is fingerprinted into one hot row per distinct failure identity (normalized message + stable stack frames), baselined against the previously ACTIVE release. Each read leads with a verdict.
run402 errors
run402 errors --function checkout --kind uncaught
run402 errors fp_9b21fa
run402 errors --new-in active
run402 errors --new-in <release_id> --watch 10m --fail-on-new
Verdict-first: the verdict pairs new-vs-recurring identity counts with invocations_in_window. Zero errors over zero traffic is absence of signal, not proven health — invocations_in_window disambiguates. The baseline is rollback-safe (activation history, not lineage): after A → B → rollback to A → C, C's baseline is A.
With --fail-on-new (requires --new-in) the run is a gate. Exit codes: 0 clean (no identity first seen under that release), 1 new identities appeared (each printed with a sample id + a runnable run402 logs command — revert, then drill in; under --watch it fails fast the instant one lands), 2 a verdict could NOT be produced (network / auth / API failure) — distinct from 1 so a script never reads an outage as clean. Every successful promote/apply response hands you the exact run402 errors --new-in <rel> --watch 10m --fail-on-new command in its next_actions (watch_errors) — copy it verbatim. Rows tagged coarse come from a function that predates the error side-channel; redeploy it to upgrade future fidelity. Read-only; a project A key requesting project B's errors gets 403, never a 404.
My bug or yours? If an error envelope carries correlated_platform_incident ({ id, subsystem, status }), the platform was degraded when your call failed — poll the events feed (run402 events, the stamp's own poll next_action) and check platform_status before debugging your own code. It's a correlation, not an exoneration, but a strong signal to look at the platform first; when the incident resolves, the matching platform_incident feed event carries your project's real count of platform-caused failed invocations.
Image generation
run402 image generate "a serif logo for a coffee shop" --aspect square --output logo.png
$0.03 per image. Aspects: square, landscape, portrait. Without --output, returns base64 in JSON.
AI helpers
run402 ai translate <id> "Hello world" --to es --context "marketing tagline"
run402 ai moderate <id> "<text>"
run402 ai usage <id>
Translation requires the AI Translation add-on on the project. Moderation is free.
Apps marketplace
run402 apps browse [--tag <tag>]
run402 apps inspect <version_id>
run402 apps fork <version_id> my-clone --bootstrap '{"admin_email":"me@example.com"}'
run402 apps publish <id> --description "…" --tags todo,demo --visibility public --fork-allowed
run402 apps versions <id>
run402 apps update <id> <version_id> --description "…" --tags a,b
run402 apps delete <id> <version_id>
Forking clones schema + site + functions into a new project. If the source app has a bootstrap function, it runs automatically with the variables you pass via --bootstrap. The fork response includes bootstrap_result (the function's return value) or bootstrap_error. Use apps inspect to see what bootstrap_variables an app expects.
Service status (no auth, no setup)
These work on a fresh install before init — useful for evaluating Run402 or distinguishing platform problems from your own:
run402 service status
run402 service health
Don't confuse with run402 status (your organization's allowance, balance, tier, projects).
Send feedback
run402 message send "deploy-dir was magical, but the cdn wait-fresh timeout default felt too aggressive"
Free with active tier. The team reads every message.
Standard Workflow — zero to deployed
run402 init
run402 tier set prototype
run402 projects provision --name my-app
PROJECT=$(run402 status | jq -r '.active_project')
cat > setup.sql <<'EOF'
CREATE TABLE IF NOT EXISTS items (
id serial PRIMARY KEY,
title text NOT NULL,
user_id uuid,
created_at timestamptz DEFAULT now()
);
EOF
run402 projects sql $PROJECT --file setup.sql
cat > manifest.json <<'EOF'
{ "version": "1",
"tables": [{ "name": "items", "expose": true, "policy": "user_owns_rows",
"owner_column": "user_id", "force_owner_on_insert": true }],
"views": [], "rpcs": [] }
EOF
run402 projects validate-expose $PROJECT --file manifest.json --migration-file setup.sql
run402 projects apply-expose $PROJECT --file manifest.json
run402 sites deploy-dir ./dist
run402 subdomains claim my-app
run402 functions deploy $PROJECT my-fn --file fn.ts
Payment Handling
Two payment rails work with the same wallet key:
- x402 (default): USDC on Base. Prototype = Base Sepolia testnet (free from faucet). Hobby/Team = Base mainnet.
- MPP: pathUSD on Tempo Moderato. Prototype = testnet (instant faucet, no rate limit). Hobby/Team = Tempo mainnet.
Switch rails via run402 init mpp instead of run402 init.
The CLI handles all signing automatically — never ask the human for a private key or set up payment libraries by hand. When a paid call returns 402, the CLI parses the requirements and signs from the local allowance. If funds are short, you get a structured error and a renew_url.
For real-money tiers, two paths to fund:
- Path A — fund the agent allowance: human sends USDC on Base mainnet to the
address field from run402 allowance export. Agent pays Run402 autonomously via x402 from then on.
- Path B — Stripe credits: create or pick the organization, then
run402 billing checkout <org_id> --product tier --tier hobby returns a checkout URL the human pays once.
Suggest $10 to your human for two Hobby projects, or $20 for one Team plus renewal buffer.
Tips & Guardrails
- Provision before authoring HTML. The
anon_key is permanent and must be embedded in your frontend; write the HTML after provision returns it.
- Use the manifest for access control, never raw
GRANT/REVOKE (the SQL endpoint blocks those).
user_owns_rows is the default for user-scoped data. Reach for public_read_write_UNRESTRICTED only on intentionally-public tables (and pass i_understand_this_is_unrestricted: true).
- Make migrations idempotent with
CREATE TABLE IF NOT EXISTS and DO-block ALTER TABLE (see Database section).
- Use immutable URLs from
blob put — they're correct from the moment of upload, no wait-fresh needed.
- Don't bake unconditional faucet calls into deploy scripts — they hit the rate limit and break already-funded flows.