SDK reference
Package:
@run402/sdk(npm) Wayfinder: https://run402.com/llms.txt Sibling references: CLI at https://docs.run402.com/llms-cli.txt · MCP at https://docs.run402.com/llms-mcp.txt · HTTP at https://run402.com/llms-full.txt Source: sdk/llms-sdk.txt in https://github.com/kychee-com/run402
The canonical agent-facing reference for the typed TypeScript SDK. Every Run402 capability is a method on a resource namespace; the CLI and MCP server are thin shims over this kernel.
The attention model, in one sentence: everything operationally significant is a fact; you read facts with a cursor (store and echo, never parse — a stale cursor resets, it never errors); you can request attention at a declared guarantee (feed-visible → opt-in rules → mandatory page that climbs); and closure is visible on the fact itself (acks are first-writer-wins, a replay reports the ORIGINAL, and a timed-out wait RETURNS the unsettled state — silence is an answer to look at, never consent). Learn it once on any surface (rooms, events, escalations) and you have learned them all.
The SDK is the recommended surface when you’re authoring code. Fewer process boundaries than the CLI, typed error envelopes, identical behavior. If you’re already in TypeScript, prefer this.
Run402 treats people and agents as first-class principals. An agent uses its own authenticator rather than borrowing a human login; identity records who acted, while organization roles, grants, delegates, freshness, and spend policy determine authority. Founder-agent ownership and human co-ownership are both legitimate states.
Install
Section titled “Install”npm install @run402/sdkTwo entry points:
| Import | Use when | Bundles |
|---|---|---|
@run402/sdk/node |
Running in Node 22 with local profile state, project-key cache, and allowance | Auto-loads the configured API base, active project state, local project-key cache, and signs x402 payments from the selected allowance or opaque signer. Includes r.actions.run(...), r.up(...), r.sites.deployDir(dir), fileSetFromDir(dir), loadDeployManifest(path), normalizeDeployManifest(input), and resolveRun402TargetProfile(). |
@run402/sdk/config |
Authoring typed deploy configs that normalize to ReleaseSpec |
Browser-safe helper descriptors and types: defineConfig, dir, file, sqlFile, nodeFunction, Run402ExecutionMode. No filesystem, env, credential, or network side effects. |
@run402/sdk/node/config |
Loading explicit executable deploy configs in Node | Re-exports config helpers plus loadDeployManifest, loadExecutableDeployConfig, and normalizeDeployManifest. |
@run402/sdk |
Isomorphic — Node, Deno, Bun, V8 isolates. No filesystem. | Bring your own CredentialsProvider. |
Node SDK requests include bounded client metadata through Run402-Client, such as surface="sdk", version="3.7.14", sdk="3.7.14". CLI-created SDK instances use surface="cli". The header is semantic version/surface metadata only: no cwd, executable path, package manager, wallet/org/project ids, secrets, or install confidence. The isomorphic entry omits the header by default to avoid browser/CORS surprises; only callers that explicitly pass clientMetadata opt in.
Quick start (Node)
Section titled “Quick start (Node)”import { run402 } from "@run402/sdk/node";
const r = run402();const project = await r.projects.provision({ tier: "prototype" });await (await r.project(project.project_id)).assets.put("hello.txt", { content: "hi" });That’s it — credentials are read, x402 payments are signed, results are typed.
Public Buzz/Nostr identity links (r.identityLinks)
Section titled “Public Buzz/Nostr identity links (r.identityLinks)”identityLinks represents public Nostr attribution for human and agent principals through one common shape discriminated by proof_protocol. One principal may have multiple active subjects, while an active subject is linked to only one principal. It never accepts a Nostr secret and never affects authentication, authorization, organization ownership, grants, delegates, payment, or transfers.
import { readFile } from "node:fs/promises";
const challenge = await r.identityLinks.nostr.begin({ nostrPubkey: "npub1...", // canonical npub or 64-char lowercase hex visibility: "public", // deliberately explicit idempotencyKey: crypto.randomUUID(),});
// Give challenge.proof_content to Buzz as one standalone kind-1 message.// Buzz owns Nostr signing; the SDK never sees its private key.const rawEvent = await readFile("buzz-event.json", "utf8");const proof = await r.identityLinks.nostr.complete({ rawEvent });
const links = await r.identityLinks.list();const publicProof = await r.identityLinks.getProof(proof.identity_link_id);await r.identityLinks.revoke(proof.identity_link_id);begin/complete is the agent protocol. It requires a credential provider with signPersonalMessage(message), checks that the returned EOA matches the gateway payload, verifies the exact kind-1 Nostr event locally, and never sees a Nostr secret. Human creation is browser-canonical at https://console.run402.com/identity-links/connect: direct human session, fresh passkey, explicit public-correlation disclosure, and released Buzz approval, with no terminal/raw-event/resource-id/passkey choreography. list() preserves every active and revoked record and its proof protocol. Public proof reads expose common subject/principal/lifecycle fields, the immutable Nostr event, and either agent EOA evidence or the Run402-attested human-session verification statement. Human link and organization membership revocation are independent.
Current whoami, project, deploy-operation/release, and transfer response types include linked-identity and immutable action-time actor snapshots. Render unknown future principal/authenticator/authority kinds as data. A snapshot is historical attribution, not a live authorization decision.
Buzz community control plane (r.buzz)
Section titled “Buzz community control plane (r.buzz)”await r.buzz.status() capability-detects run402.buzz-control-plane.v1 plus capabilities.human_adoption_offers and returns independent skill/offer/adoption/community/enrollment state without manufacturing state on an older gateway. The canonical ownership alias is r.buzz.offerAdoption; typed humanAdoptionOffers.create/get/cancel/createAttempt separates durable inert offers from short human/session-bound attempts. A completed poll exposes the terminal consent receipt, public human identity link, and ordinary owner membership as distinct typed effects. The membership is the only organization-authority source; identity-link and membership revocation are independent and neither rewrites the completed receipt. r.buzz.adopt and humanAdoptions remain direct advanced compatibility. Other goal aliases are r.buzz.install and r.buzz.enroll.
The SDK generates mutation idempotency keys when omitted and rejects nested secret-shaped fields before network access. It never signs a Buzz event. Human adoption requires a directly authenticated human completion; community activation accepts an ordinary Buzz kind-1 owner/admin approval and lets Run402 verify released NIP-11/NIP-43 relay evidence. Run402 owns descriptor discovery, policy/default revisions, and revocation, so Buzz itself remains unchanged. Agent enrollment can create only finite grants on named existing projects. It never creates agent org membership, future-project, owner, delegate, or payment authority. Installation revocation leaves existing grants unchanged; enrollment revocation affects only its linked grants; drift is advisory. Buzz failures preserve the gateway’s stable code, exact repair field, complete nextActions, and safeToRetry; there is no client-synthesized generic edit fallback, and an unchanged call is retried only when the gateway marks it safe.
Before creating an x402 payment payload, the Node entry confirms USDC with
bounded retry/backoff and independent RPC failover on Base and Base Sepolia.
RPC exhaustion is never treated as a zero balance. Branch on the exported
X402BalanceError.code: X402_RPC_TIMEOUT, X402_RPC_RATE_LIMITED, and
X402_RPC_UNAVAILABLE are pre-payment failures with safeToRetry === true
and mutationState === "not_started"; X402_INSUFFICIENT_FUNDS means the
relevant balance reads succeeded and the confirmed funds do not cover any
accepted requirement. After a retryable preflight failure, the next request
refreshes only mutable RPC balance state while retaining the originally
selected signer and payer provenance. Error details contain provider indexes
and failure classes, never RPC credentials, wallet keys, or signed proofs.
Payment signer selection (Node)
Section titled “Payment signer selection (Node)”Authentication and payment are separate authorities. A custom credentials
provider controls API authentication; the x402 payer is resolved exactly once
in this order:
paymentSigner— an explicit async EVM signer provider (KMS/HSM friendly).allowancePath— an explicit local allowance file.credentials.readAllowance()— when a supplied provider implements it.- The Node default provider’s active-profile allowance — only when the caller did not supply a custom credentials provider.
Once a source is selected, the SDK never falls back to the ambient/global
wallet. paymentSigner and allowancePath together throw
PAYMENT_SOURCE_CONFLICT. Passing both credentials and allowancePath is
valid: auth uses credentials, while payment intentionally uses that file.
fetch still takes precedence over built-in paid fetch, and
disablePaidFetch: true disables automatic payment entirely.
An opaque signer returns only its public payer address and signing operation; raw keys and replayable payment authorizations do not cross the provider boundary:
import { run402, type CredentialsProvider, type EvmPaymentSigner, type EvmPaymentSignerProvider, type PaymentPublicClient, type X402PaymentNetwork,} from "@run402/sdk/node";
declare const sessionCredentials: CredentialsProvider;declare function kmsSignerFor( network: X402PaymentNetwork, publicClient: PaymentPublicClient,): Promise<EvmPaymentSigner>;
const paymentSigner: EvmPaymentSignerProvider = { async getSigner({ network, publicClient }) { return kmsSignerFor(network, publicClient); // address + signTypedData },};
const r = run402({ credentials: sessionCredentials, paymentSigner });const payer = await r.paymentPayer();// { source: "payment_signer", rail: "x402", payers: [{ address, network }, ...] }The provider may return null for an unsupported Base network. Paid-fetch
initialization is lazy and retries after missing/recoverable local state, so a
long-lived client can start paying after its selected allowance/provider
becomes available without being reconstructed. r.paymentPayer() initializes
the selected source if necessary and returns only its source, rail, public
address(es), and network(s); it never returns a key, signed authorization, or
replayable proof. It returns null when automatic paid fetch is disabled, a
custom fetch owns payment, or the selected source is not currently available.
Buy arbitrary x402 URLs (Node)
Section titled “Buy arbitrary x402 URLs (Node)”r.pay.fetch(url, init?, options?) is the canonical buyer surface for an
arbitrary x402-priced HTTP endpoint. It passes unpriced endpoints through,
defaults maxUsdMicros to 100_000 ($0.10), forwards an optional
Idempotency-Key, and returns the response together with a faithful receipt:
import { run402 } from "@run402/sdk/node";
const r = run402();const result = await r.pay.fetch( "https://seller.example/translate", { method: "POST", body: JSON.stringify({ text: "hello" }) }, { maxUsdMicros: 50_000, idempotencyKey: "translation:1", requireReceipt: true, },);
console.log(result.outcome, result.payment, await result.response.json());payment is null when no payment was required or when an already-used proof
confirms that a prior ambiguous request settled but the target cannot return a
transaction reference. Otherwise it includes settlement, movement/replay,
delivery, offer, merchant-receipt, signer-relationship, policy, and
raw-evidence fields. Set requireReceipt: true to require a verified
wallet-rooted offer before payment and a matching receipt afterward. The buyer
verifies the exact URL, scheme, network, asset, atomic amount, recipient,
validity, settlement, payer, transaction, and signer relationship. Branch on
PaymentBuyerError.code: PAYMENT_EXCEEDS_MAX, PAYMENT_WALLET_UNFUNDED,
PAYMENT_NETWORK_UNSUPPORTED, exact Run402 pending/drain/destination/fence/
lifetime/key-reuse codes, or PAYMENT_SETTLEMENT_FAILED. The error preserves
fundsMoved, paymentId, intent/delivery facts, and canonical nextActions.
Successful results preserve paymentId, deduplicated, fundsMoved,
delivery, settledAt, and intentState when supplied.
Required policy fails before signing with MERCHANT_RECEIPT_REQUIRED when no
eligible offer remains. A post-settlement evidence failure throws
PaymentPolicyError with MERCHANT_RECEIPT_UNAVAILABLE, the upstream
Response, complete commerce result, true funds-moved/mutation state, and one
canonical retry or reconcile_payment action. Never authorize a second
payment to recover a receipt. payFetchResultToJson renders the complete
snake_case x402-commerce-result.v1 envelope.
For an ambiguous transport failure, retry the identical request on the same SDK
instance with the same idempotency key. This buyer keeps the signed proof only
in memory and re-presents that exact proof; it never mints a second authorization.
An upstream used-proof response becomes outcome: "already_settled" and
replay: true. Across a fresh process, a Run402 managed/deployment host can
recover a caller-keyed intent by repeating the same request with the same payer
and key. Trusted pending requires status 409, the exact code and reserved
header, the same payment-bearing origin, redirects disabled, HTTPS, and an
exact Run402 DNS-label match. Custom, arbitrary, lookalike, and redirected
hosts remain ambiguous.
PAYMENT_CALLER_IDENTITY_NOT_ACTIVE is a rollout fail-closed response. Keep
the same key and retry after caller identity is activated; removing the key to
force a proof-only attempt changes the contract and is never a recovery step.
Raw HTTP interoperability follows the same protocol:
- Send the intended request with a stable
Idempotency-Key. - On 402, base64url-decode
PAYMENT-REQUIRED, verify its exact scheme, network, asset, atomic amount, recipient, and your local spend ceiling. - Sign one x402 payload and retry the same request with that base64url JSON in
PAYMENT-SIGNATURE(X-PAYMENTfor v1). Do not follow redirects with a payment proof. - On success, base64url-decode
PAYMENT-RESPONSEand requiresuccess: true, a non-emptytransaction, and the expected network before reporting funds moved. - If the signed request loses its response, retain and re-present the same
proof for the same intent. Never create a fresh proof until the first
settlement is reconciled. A used-proof 402 can establish
already_settled, but without a settlement header it is not a transaction receipt.
Automatic x402 attempt recovery (Node)
Section titled “Automatic x402 attempt recovery (Node)”The Node entry tracks each automatic x402 payment across the provider-dispatch boundary. If setup, challenge handling, or signing fails before a payment-bearing request is sent, it throws PaymentAttemptError with mutationState: "not_started" and safeToRetry: true. Check retryable separately: persistent local-journal corruption is safe from duplicate payment but requires repair instead of an automatic retry. If the signed request may have reached the target but no reliable result returns, it reports mutationState: "ambiguous", safeToRetry: false, and reconcile_payment / poll next actions. Generic automatic requests must not be blindly retried; r.pay.fetch is the deliberate exception because the same live SDK instance retains and re-presents the original proof.
Every challenged payment gets a stable paymentAttemptId. A redacted intent is written atomically under the active profile’s mode-0700 payment-attempts/ directory before provider dispatch; individual records are mode 0600. Inspect them with readPaymentAttempt(id) or listPaymentAttempts({ limit }). Trusted pending records use state: "intent_pending" and may include payment_id, retry timing, and a SHA-256 caller-key digest. Records never contain a raw caller key, URL path/query, request body, header, wallet key, signature, signed authorization, provider proof, or raw cause.
The SDK sends X-Run402-Payment-Attempt-Id only on the payment-bearing request so a compatible target can correlate its logs. Redirects are disabled for that signed request, preventing both the correlation id and signed payment authorization from reaching a redirect target. A caller may supply a canonical pat_ id only when it is new; the SDK reserves it atomically across processes, and an id already present in the journal fails closed with X402_ATTEMPT_ID_ALREADY_EXISTS before any network request. Generic automatic payment retries require reconciliation and a fresh authorized attempt; only r.pay.fetch may re-present its in-memory proof for an identical request. Malformed reserved-header values fail locally with INVALID_PAYMENT_ATTEMPT_ID; they are never replaced with an id that could authorize a new payment.
Repo-level deploy through the same SDK action runner used by run402 up:
import { Run402Action, run402 } from "@run402/sdk/node";
const r = run402();
await r.up({ name: "my-app" }, { approval: "yes" });
const provision = await r.actions.run({ type: Run402Action.ProjectsProvision, name: "my-app",});Typed deploy config loop:
await r.up({ manifest: "run402.deploy.ts" }, { mode: "check" });const reviewed = await r.up({ manifest: "run402.deploy.ts" }, { mode: "plan" });await r.up( { manifest: "run402.deploy.ts" }, { mode: { kind: "applyReviewed", planId: reviewed.result?.plan?.plan_id ?? "", planFingerprint: reviewed.result?.plan?.plan_fingerprint ?? undefined, }, },);For a self-hosted Run402 Core Gateway, run run402 init --api-base=http://my-core:4020 once. The Node SDK then targets that API base by default; explicit run402({ apiBase }) still wins.
App build scripts should use resolveRun402TargetProfile() instead of parsing target.json or local project-key cache files:
import { resolveRun402TargetProfile } from "@run402/sdk/node";
const target = resolveRun402TargetProfile({ requiredTarget: "core", requireProject: true, requireAnonKey: true,});
console.log(target.apiBase, target.projectId, target.anonKey);For app-specific legacy env names, pass aliases:
import { resolveRun402TargetProfile } from "@run402/sdk/node";
resolveRun402TargetProfile({ envAliases: { projectId: ["MY_APP_PROJECT_ID"], anonKey: ["MY_APP_ANON_KEY"], },});Quick start (isomorphic / sandbox)
Section titled “Quick start (isomorphic / sandbox)”import { Run402, type CredentialsProvider } from "@run402/sdk";
const credentials: CredentialsProvider = { async getAuth() { return { Authorization: `Bearer ${session.token}` }; }, async getProject(id) { return session.projects[id] ?? null; },};
const r = new Run402({ apiBase: "https://api.run402.com", credentials,});The CredentialsProvider interface has two required methods (getAuth, getProject) plus optional ones for hosts that want full sticky-default behavior (saveProject, updateProject, removeProject, setActiveProject, getActiveProject, readAllowance, saveAllowance, createAllowance, getAllowancePath).
Mental model
Section titled “Mental model”The SDK is the canonical kernel. A single typed Run402 class with one namespace per resource group (r.projects, r.assets, …). The hero apply primitive is r.project(id).apply(spec); there is no public r.deploy surface. Every method:
- Takes typed parameters (TS interfaces in
*.types.ts) - Returns a typed
Promise<T> - Throws a typed subclass of
Run402Erroron failure - Never calls
process.exit
The MCP server’s tools and the CLI’s subcommands are argv-/schema-parsing wrappers around these methods. They share the configured API target, active project state, allowance, and local project-key cache so target selection and credentials carry across surfaces without treating cached keys as project inventory.
Action runner (@run402/sdk/node)
Section titled “Action runner (@run402/sdk/node)”The Node entry owns recursive agent actions. The CLI command run402 up is only a flag parser around this surface.
export const Run402Action = { ProjectsProvision: "projects.provision", TierSet: "tier.set", Up: "up",} as const;
export type Run402ActionType = typeof Run402Action[keyof typeof Run402Action];
type Run402ActionInput = | { type: typeof Run402Action.ProjectsProvision; name?: string; tier?: "prototype" | "hobby" | "team"; orgId?: string; idempotencyKey?: string } | { type: typeof Run402Action.TierSet; tier: "prototype" | "hobby" | "team"; idempotencyKey?: string } | { type: typeof Run402Action.Up; source?: string; dir?: string; manifest?: string; projectId?: string; name?: string; tier?: "prototype" | "hobby" | "team"; orgId?: string; idempotencyKey?: string; verifyOnly?: boolean; propagationBudgetSeconds?: number; propagationWait?: boolean; };
type Run402ExecutionMode = | "apply" | "check" | "printSpec" | "plan" | { kind: "applyReviewed"; planId: string; planFingerprint?: string };r.actions.run(input, opts) returns { action, mode, dry_run, target, steps, result }. r.up(input, opts) is equivalent to actions.run({ type: Run402Action.Up, ...input }, opts).
Run402Action.Up behavior:
- Discover
run402.deploy.json, thenapp.jsonunderdir/ cwd; explicitmanifestwins. - Validate the deploy manifest and referenced local files before allowance, tier, project, link, upload, or deploy mutations.
- Resolve project as explicit
projectId, then.run402/project.json, then manifestproject_id, then approved project creation fromname, then approved active-project fallback. - For app manifests with
verify.http[], fetch verification URLs after apply and write per-check details toresult.app_result.verification.http[]. Fresh edge sentinel misses (x-run402-edgeor JSON codes such asSUBDOMAIN_NOT_CONFIGURED) and non-settled deploy-resolve diagnostics becomepropagation_pendinginstead of permanent failures while the binding is fresh. - Set
propagationBudgetSecondsto control the wait for edge convergence (default 120). SetpropagationWait: falseto returnstatus: "propagation_pending"immediately withverify.status,propagation_wait_ms, warnings,next_action, and diagnosticedge_propagation/resolvepayloads. - Set
verifyOnly: trueto rerun app HTTP verification without upload, deploy, resource mutation, or project creation. This is the SDK equivalent ofrun402 up verify. nameis only project creation/link metadata. It is not a manifest field and never renames an existing project.- Write
.run402/project.jsonatomically whenupneeds to remember an explicit/created/active project. Schema:{ schema_version: "run402.workspace-project.v1", project_id, name?, target?, created_at, updated_at? }. - On Run402 Cloud, recursively ensure allowance and tier (default bootstrap tier
prototype) only when missing; existing active tiers are not downgraded or renewed just becauseupran. - On Run402 Core, skip Cloud allowance/tier prerequisites and fail closed if no Core project is selected.
- Delegate the final deployment to
r.project(id).apply(spec, opts).
Action options:
mode: "check"validates local manifest/config and file references only. No gateway calls, uploads, prerequisite mutations, or local writes.mode: "printSpec"returns the normalizedReleaseSpecinresult.spec; CLI prints only that JSON.mode: "plan"creates a gateway-reviewed non-deploying plan. It returnsresult.plan.plan_id,plan_fingerprint,plan_expires_at, warnings, diff, and same-surfacenext_actions[].mode: { kind: "applyReviewed", planId, planFingerprint? }applies only when the reviewed plan still matches. The SDK verifies before upload and commit.approval: "never" | "yes" | { mode: "interactive"; approve(request) }gates recursive prerequisites and local link writes. SDK default is"never"; CLI maps-y/--yesto"yes"and TTY prompts to interactive approval. If allowance/tier/project/link are already configured,r.up()can run the requested deploy without approval.autoPrerequisitesdefaults totrueforupandfalsefor direct actions.idempotencyKeysupplies a root key; recursive gateway mutations derive child keys from it.
Legacy dryRun: true remains an action-graph compatibility mode.
For typed deploy config, use mode: "check" for local validation and mode: "plan" for gateway review.
Typed deploy config (@run402/sdk/config)
Section titled “Typed deploy config (@run402/sdk/config)”Typed configs compile to the same SDK-native ReleaseSpec as JSON manifests. Raw ReleaseSpec slices remain valid for fields without helpers.
import { defineConfig, dir, file, 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", { deps: ["zod@^3"], requireAuth: true, }), }, }, assets: { put: [{ key: "logo.svg", source: file("assets/logo.svg", { contentType: "image/svg+xml" }) }], }, secrets: { require: ["OPENAI_API_KEY"] },}));Helper semantics:
defineConfig(config)preserves type inference. The export may be an object or(context) => object; context hasmanifestPath,rootDir, andenv. Useenv.get("NAME"),env.required("NAME"), orenv.RUN402_*property reads; executable manifest loads reportconfig.env_accessedmetadata for those reads.dir(path, { prefix?, ignore?, includeSensitive? })resolves from the config directory, walks deterministically by normalized/path, skips sensitive defaults unless opted in, rejects symlinks, infers content type, and produces local directory descriptors consumed by the Node normalizer.file(path, { contentType? })produces a local file source; the Node normalizer reads bytes later and keeps secrets out of config examples.sqlFile(path, { id?, name?, checksum?, transaction? })derivesidfrom the filename when omitted and keeps checksum/transaction metadata stable. Pass{ name: "seed" }for generated/idempotent SQL; the SDK compiles<name>_<sha256(sql)[0:16]>from the post-build SQL bytes, changed content applies once under a new id, and unchanged re-deploys noop. SQL declared withnameMUST be idempotent because it re-runs whenever content changes against a database where prior versions may already exist.nodeFunction(path, opts)creates a Node 22FunctionSpecfrom built JavaScript. TypeScript function sources (.ts,.tsx,.mts,.cts) currently fail locally withTYPESCRIPT_FUNCTION_REQUIRES_BUNDLE; build them first and point at.js.
Executable trust policy:
loadDeployManifest("run402.deploy.ts")can load.ts/.mts/.cts/.js/.mjs/.cjsconfigs only when the path is explicit.- Auto-discovery for
upchecks only data manifests:run402.deploy.json, thenapp.json. - If a repo only contains
run402.deploy.ts,upfails withEXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFESTand a next action to rerun with--manifest run402.deploy.ts --check. --check/mode: "check"and--print-spec/mode: "printSpec"are local-only; use--plan/mode: "plan"for gateway policy, quota, cost, secret existence, missing-content, and base-release facts.
The runner never executes arbitrary gateway-authored next_actions[].command; it uses its own fixed action graph (allowance, tier, projects.provision, workspace link, deploy apply).
Casing in returned shapes
Section titled “Casing in returned shapes”Two casings coexist by design — classify a field by the shape it belongs to:
- Raw API result shapes preserve the gateway’s snake_case fields. Examples:
ProvisionResult.project_id,ProvisionResult.anon_key,ProvisionResult.service_key,ProvisionResult.schema_slot,ProjectInfo.project_id,ProjectSummary.lease_expires_at,UsageReport.api_calls,SchemaReport.schema. These mirror the HTTP response bodies one-to-one. - SDK-specific helper shapes use camelCase. Examples:
AssetRef.cdnUrl/AssetRef.cacheKind/AssetRef.contentSha256,Run402DeployError.safeToRetry/operationId/mutationState, everyDeployEventvariant’s discriminator (type, plus per-variant fields likereleaseId,urls).
The split is stable across the 3.x line. CI fails any TypeScript-fenced example that
accesses a field that does not exist on the actual type. Reference tables
below use plain code fences (no ts) — they document the type surface for
visual scanning, are not runnable, and are exempt from type-checking.
Timestamp Convention
Section titled “Timestamp Convention”Public API and SDK response timestamps are ISO-8601 strings, never JavaScript
Date objects or numeric epochs. Absolute instants use fields such as
created_at, updated_at, expires_at, lease_expires_at, timestamp, and
ingestion_time; nullable means the gateway state is genuinely absent. Numeric
time values are reserved for relative durations or local measurements and carry
units in the name (expires_in, duration_ms, elapsedMs, ttl_seconds).
Project credentials
Section titled “Project credentials”After r.projects.provision(...), the result has project_id, anon_key, service_key, schema_slot. The Node entry’s credentials provider auto-saves keys to the active profile’s local project-key cache (credentials/project-keys.v1.json). Legacy projects.json files are one-way migration input only.
anon_key— read-only by default; safe in browser HTML. RLS policies apply.service_key— server-side admin. Never embed in browser code.
Neither key expires. Lease enforcement happens server-side. Server project reads such as r.projects.list(), r.projects.get(id), and r.projects.use(id) authorize with the current principal and do not require local cache membership.
Rotatable project credentials — the replacement for the derived pair
Section titled “Rotatable project credentials — the replacement for the derived pair”The anon_key/service_key above are DERIVED from the platform signing key: they never expire, cannot be revoked individually, and the signing key behind them is being retired. A project credential (r402_…) is a ROW instead — named, listable, expiring, individually revocable — and several may be live per kind at once, which is exactly how you rotate with no downtime.
r.credentials carries both surfaces, and they do not overlap: r.credentials.<verb> is the gateway’s rows, r.credentials.projectKeys.<verb> is the local cache on this machine.
// Am I still on the retiring key? Only project.read is needed, so an agent can// check its own posture. retirement.deadline is ALWAYS null on purpose —// retirement is condition-gated, never a date. Read retirement.gated_on.const posture = await r.credentials.status(projectId); // { state: "legacy" | "rotatable", ... }
// Mint one. The secret is returned EXACTLY ONCE; there is no read that// returns it. Persist it before doing anything else.const cred = await r.credentials.issue(projectId, { kind: "service", name: "ci-deploy" });cred.secret; // r402_… — once, and never again
await r.credentials.list(projectId, { includeRevoked: true }); // metadata onlyawait r.credentials.rotate(projectId, cred.credential_id); // replace in one tx; new secret onceawait r.credentials.revoke(projectId, cred.credential_id, { reason: "leaked in a log" });Zero-downtime rotation is issue a second live credential → deploy it → revoke the first. rotate() collapses that into one transaction (same name, records replacement_of) and is the right call when the old secret is already compromised.
issue/rotate/revoke require owner membership on the project’s owning org PLUS a fresh step-up, and a delegate can NEVER satisfy them — a scoped agent credential must not be able to escalate itself into a permanent root.
// The one exception, and the cold-restart recovery path: an agent that lost// local state but still holds a delegate mints a SHORT-LIVED token with no// human present. No step-up, because there is nobody to prompt; it expires,// so it cannot become a durable root.const token = await r.credentials.mintToken(projectId); // { secret, expires_in, … }Never write an issue / rotate / mintToken response to a result cache, tmp file, or expansion handle — they are secret-bearing, like provision and project keys.
await r.projects.use(projectId); // make this the active projectconst keys = await r.projects.keys(projectId);const info = await r.projects.info(projectId);Rehearsals, snapshots, and branches
Section titled “Rehearsals, snapshots, and branches”For database-bearing deploys, rehearse before commit. Create a reviewed plan with r.project(id).apply.plan(spec, { mode: "reviewedPlan" }), upload missing bytes, then call r.project(id).apply.rehearse(plan.plan.plan_id, { teardown: "on_pass" }). The rehearsal creates a contained branch, applies migrations and checks there, and returns report.status, migration/check results, branch URL, snapshot id, and next_actions. A passing report does not mutate the source project until you commit the original plan.
Manual restore points are exposed as r.snapshots and the scoped r.project(id).snapshots. restorePlan() is the no-mutation loss-statement step; restore() requires the confirm token from the plan and performs the atomic offline-materialize-then-flip restore. Auth users/passkeys are restored only with { includeAuth: true }; sessions and tokens are never restored.
Contained branch projects are exposed as r.branches and r.project(id).branches. Branch creation can capture a fresh snapshot or start from an existing snapshot, saves returned branch keys to the Node credential cache when available, defaults email to sandboxed, keeps cron off unless requested, and expires by TTL.
Portable project archives
Section titled “Portable project archives”Portable archives are the SDK path for proving Cloud is the easiest place to start, not the only place the supported application can run. They are a vendor-lock-in trust artifact and are 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.
Node happy path:
import { writeFile } from "node:fs/promises";import { importArchiveToCore, inspectArchive, run402, verifyArchive,} from "@run402/sdk/node";
const r = run402({ surface: "cli" });
const exported = await r.archives.export("prj_...", { scope: "portable-runtime-v1", auth: "stubs", consistency: "pause-writes", onProgress: (event) => console.log(JSON.stringify(event)),});
await writeFile("./project.r402ar", exported.bytes);
const inspected = await inspectArchive("./project.r402ar");const verified = await verifyArchive("./project.r402ar");
if (!verified.ok) { console.error(JSON.stringify(verified.diagnostics));}
const imported = await importArchiveToCore({ archivePath: "./project.r402ar", name: "imported-project", envFile: "./required.env", requireRunnable: true,});
console.log({ inspected, imported });The isomorphic SDK exposes r.archives.create(projectId, opts), get(projectId, archiveId), wait(projectId, archiveId, opts), download(projectId, archiveId), and export(projectId, opts). The Node entry upgrades r.archives to add local inspect(archivePath), verify(archivePath), and importToCore(opts), and also exports standalone inspectArchive, verifyArchive, importArchiveToCore, and readEnvFile.
Archive progress events and diagnostics use stable agent fields: event, stage, resource_type, resource_id, project_id, status, completed_units, total_units, code, message, next_action, retryable, and safe context. verify is offline and checks integrity and compatibility only; archives remain untrusted input. Core import verifies before mutation, creates a new Core project only, and accepts required secret values via envFile or secretValues. Secret values, auth credentials, logs, billing/allowance state, and managed Cloud operations are never exported in v1.
Errors
Section titled “Errors”All failures throw subclasses of Run402Error. Every subclass carries a stable
kind string discriminator and an isRun402Error brand. Branch with the
exported type guards (or by comparing e.kind) — NOT with instanceof X:
identity-based checks fail silently when the consumer’s runtime holds a
different copy of the SDK (duplicate npm installs, bundler chunk splits,
ESM/CJS interop, V8-isolate realms). instanceof continues to work for
single-copy single-realm callers as a back-compat path.
| Class | kind |
When | Notable fields |
|---|---|---|---|
PaymentRequired |
"payment_required" |
HTTP 402 | x402 payment requirements in body |
ProjectNotFound |
"project_not_found" |
Project ID not in the credential provider | projectId |
Unauthorized |
"unauthorized" |
HTTP 401 / 403 — authentication missing or invalid | — |
NotAuthorizedError |
"not_authorized" |
HTTP 403 with code: "NOT_AUTHORIZED" — org-owned control-plane denial (gateway v1.77+): authenticated, but the principal lacks the required org membership/role or per-project grant |
requiredRole, requiredCapability, reason, action |
ApiError |
"api_error" |
Other non-2xx responses | status, body |
NetworkError |
"network_error" |
Fetch rejected with no HTTP response | cause |
PaymentAttemptError |
"payment_attempt_error" |
Automatic x402 setup/signing/submission failed | code, phase, paymentAttemptId, providerStarted, safeToRetry, mutationState, nextActions |
PaymentBuyerError |
"payment_buyer_error" |
Bounded arbitrary-URL x402 buying failed | code, fundsMoved, details, safeToRetry, nextActions |
LocalError |
"local_error" |
Local-host issues (filesystem, signing) | cause |
X402BalanceError (Node entry) |
"local_error" |
x402 USDC balance preflight could not be confirmed, or confirmed funds are insufficient | code, safeToRetry, mutationState="not_started", details, nextActions |
Run402DeployError |
"deploy_error" |
Structured envelope from the deploy state machine | code, phase, operationId, safeToRetry, mutationState, nextActions |
TransferFreezeError |
"transfer_freeze" |
HTTP 409 with code: "PROJECT_HAS_PENDING_TRANSFER" from the v1.59 transfer-freeze middleware blocking owner-side mutations during a pending transfer |
transferId, projectId, cancelPath, previewPath |
The exported Run402ErrorKind union type ("payment_required" | "payment_buyer_error" | "project_not_found" | "unauthorized" | "not_authorized" | "api_error" | "network_error" | "payment_attempt_error" | "local_error" | "deploy_error" | "transfer_freeze" | "step_up_required" | "operator_approval_required") supports exhaustive switch statements with TypeScript exhaustiveness checking.
import { run402, withRetry, isPaymentRequired, isDeployError, type ReleaseSpec,} from "@run402/sdk/node";
declare const spec: ReleaseSpec;const r = run402();
try { const release = await withRetry( async () => (await r.project(spec.project)).apply(spec, { idempotencyKey: "deploy-2026-05-01" }), { attempts: 3, onRetry: (_e, attempt, delayMs) => process.stderr.write(`retry ${attempt} in ${delayMs}ms\n`), }, ); console.log(release.urls);} catch (e) { if (isPaymentRequired(e)) { // narrowed to PaymentRequired — read e.body for the x402 quote } else if (isDeployError(e)) { // narrowed to Run402DeployError — log the structured envelope for triage process.stderr.write(JSON.stringify(e) + "\n"); } else throw e;}Run402DeployError.code is one of MIGRATION_FAILED, MIGRATION_CHECKSUM_MISMATCH, BASE_RELEASE_CONFLICT, PAYMENT_REQUIRED, SCHEMA_SETTLE_TIMEOUT, ACTIVATION_FAILED, STORAGE_UNAVAILABLE, SITE_STAGE_FAILED, FUNCTION_BUILD_FAILED, CONTENT_UPLOAD_FAILED, INVALID_SPEC, MANIFEST_EMPTY, OPERATION_NOT_FOUND, MIGRATE_GATE_ACTIVE, INTERNAL_ERROR, NETWORK_ERROR, PROJECT_NOT_FOUND (or any other string the gateway emits — consumers SHALL treat unknown codes as opaque). Pair it with the structured nextActions advisory array carried in the error body.
Type guards and the canonical retry policy
Section titled “Type guards and the canonical retry policy”The SDK exports identity-free guards plus a single canonical “should I retry this?” function:
isRun402Error(e)— true for anyRun402Errorsubclass instance, regardless of which SDK copy created it.isPaymentRequired(e),isPaymentAttemptError(e),isProjectNotFound(e),isUnauthorized(e),isNotAuthorized(e),isApiError(e),isNetworkError(e),isLocalError(e),isDeployError(e),isTransferFreezeError(e)— narrowunknownto the named subclass.isPaymentAttemptErroris the automatic x402 failure guard; branch onsafeToRetrybefore doing anything.isUnauthorized(authentication missing/invalid) andisNotAuthorized(org control-plane denial — authenticated but under-privileged) are distinct: the first calls for re-auth, the second for obtaining an org membership/role or grant.isRetryableRun402Error(e)— encapsulates the retry policy:e.retryable || kind === "network_error" || status in {408, 425, 429} || status >= 500, unless the gateway explicitly setsretryable: false.safeToRetryalone is not a retry signal; it means a repeated mutation should not duplicate/corrupt state, not that lifecycle/payment/auth gates will become allowed without an action. Returnsfalsefor non-Run402 inputs so it’s safe to call from anycatchblock.getQuotaScope(e)— returns"organization"for pooled organization quota denials,"project"for the orphan-project fallback when a organization row has been purged but cascade has not yet run, andundefinedfor non-quota errors or pre-v1.46 gateways. Safe to call with anyunknown; readsRun402Error.quotaScope, which is lifted fromdetails.scopeon the gateway envelope.isCiBindingRevoked(e)— true for the CI token-exchangebinding_revokeddenial (HTTP 403): a subject-matching binding existed but was revoked (most often the project was transferred/handed off). Distinct fromaccess_denied(no binding ever matched), which shares the same canonicalcode: "FORBIDDEN"— the guard reads the OAuth-styleerrorfield for you. The error stays anUnauthorized(no regression). Fix: re-runrun402 ci link github; do NOTset-asset-scopes(409 on a revoked binding). Safe to call with anyunknown.
Run402Error.toJSON() returns a canonical envelope (name, kind, message, status, code, category, retryable, safeToRetry, mutationState, traceId, context, details, nextActions, quotaScope, body). Run402DeployError.toJSON() extends it with phase, resource, operationId, planId, fix, logs, rolledBack, and, when automatic deploy retries are exhausted, attempts, maxRetries, lastRetryCode. JSON.stringify(error) produces a populated structured object — never the empty "{}" plain Error produces.
correlated_platform_incident — the error might not be yours. While a platform incident is OPEN and its subsystem correlates with the error’s code, the gateway envelope (available raw on error.body) carries correlated_platform_incident: { id: "inc_…", subsystem, status: "ongoing" | "resolved" }, and a poll action is appended to nextActions. This is a CORRELATION, not an exoneration — the platform states that it was degraded when your call failed and lets you judge (an app can still cause its own throttling). Treat it as a strong signal to poll the events feed (r.events.list) before debugging your own code; when the incident resolves, the matching platform_incident feed event carries your project’s real failed-invocation count. The field is absent on any error with no correlated open incident — never a false confession.
withRetry(fn, opts?)
Section titled “withRetry(fn, opts?)”withRetry runs an async function with exponential backoff. Defaults: 3 attempts (1 + 2 retries), 250 ms base delay, 5 s cap. Uses isRetryableRun402Error as the default retry decision. Pair with the SDK method’s own idempotencyKey so retried mutations dedup server-side — the closure carries the same key on every attempt.
Do not wrap lifecycle-gated writes, auth token exchanges, or passkey verification in blind retry loops because an error says safeToRetry: true. Use a custom retryIf when a caller-specific recovery action makes a retry meaningful.
For r.project(id).apply(), do not hand-roll the BASE_RELEASE_CONFLICT loop: the deploy namespace already re-plans and retries omitted/current-base specs when the gateway returns safe_to_retry: true. Default deploy budget is 2 retries after the initial attempt, maxRetries: 0 opts out, each retry emits deploy.retry, and exhausted retries surface attempts / maxRetries / lastRetryCode on Run402DeployError.
RetryOptions: attempts?: number, baseDelayMs?: number, maxDelayMs?: number, retryIf?: (error, attempt) => boolean, onRetry?: (error, attempt, delayMs) => void.
After exhausting attempts, withRetry throws the LAST observed error — your catch handler sees the original structured envelope, not a wrapper. A buggy onRetry that throws is swallowed; the retry chain is unaffected.
The patterns
Section titled “The patterns”Paste-and-go assets — content-addressed URLs with SRI
Section titled “Paste-and-go assets — content-addressed URLs with SRI”r.assets.put returns an AssetRef:
const logo = await (await r.project(projectId)).assets.put("logo.png", { bytes });
logo.cdnUrl // → "https://pr-<public_id>.run402.com/_blob/logo-3a7fc02e.png"logo.sri // → "sha256-…" for <script integrity="…">logo.etag // → strong "sha256-<hex>" ETaglogo.cacheKind // → "immutable" | "mutable" | "private"The URL is content-addressed and served through CloudFront. No cache-invalidation choreography needed. The browser refuses execution on byte mismatch via SRI.
immutable: true is the default. The SDK always computes and sends the object SHA-256 because upload sessions require it; pass false only when you specifically need mutable URL/cache semantics (the returned cdnUrl and sri are then null).
Binary files are bytes, never strings. In Node, call readFile(path)
without an encoding; in a browser, read File.arrayBuffer(). Do not use
readFile(path, "utf8"), Blob.text(), or another text decoder for PNG,
WASM, fonts, audio, video, archives, or other binary formats and then hash or
re-encode that string. CAS verifies the submitted bytes against their hash; it
cannot reconstruct bytes discarded by an earlier UTF-8 decode. String sources
for known binary keys/MIME types fail locally before network traffic with
BINARY_CONTENT_REQUIRES_BYTES.
import { readFile } from "node:fs/promises";
const logoBytes = await readFile("./logo.png"); // Buffer is a Uint8Arrayawait (await r.project(projectId)).assets.put("logo.png", { bytes: logoBytes });Raw /content/v1 clients have the same obligation: compute sha256 and
size from the original byte buffer, then PUT that exact buffer. The declared
content_type is metadata, not proof that the pre-hash bytes were decoded
correctly. Prefer assets.put, fileSetFromDir, dir, or assets.uploadDir
so the byte-safe path is automatic.
If you suspect cache staleness on a mutable URL, the SDK has helpers:
const blobUrl = "https://pr-….run402.com/_blob/avatar.png";const diag = await (await r.project(projectId)).assets.diagnoseUrl( blobUrl);// diag.expectedSha256 / observedSha256 / cache.* / invalidation.* / hint
const fresh = await (await r.project(projectId)).assets.waitFresh( { url: blobUrl, sha256: expectedSha, timeoutMs: 60_000,});// `fresh.fresh === false` on timeout — handle by switching to immutableDon’t call waitFresh on immutable URLs — they’re correct from upload time.
Unified apply — r.project(id).apply
Section titled “Unified apply — r.project(id).apply”The canonical primitive for any deploy (database + migrations + manifest + value-free secret declarations + functions + site + subdomain). Three layers:
// One-shot — most agents use this. Awaits to a terminal state.const result = await (await r.project(spec.project)).apply(spec);
// Long-running with progress events. Events are a discriminated union on `type`.const op = await (await r.project(spec.project)).apply.start(spec);for await (const ev of op.events()) { console.log(ev.type, ev);}const final = await op.result();
// Resume by id (e.g. after an interrupted process or a 5xx).const resumed = await (await r.project(projectId)).apply.resume(operationId);
// Lower-level steps for CLI debugging:const pScoped = await r.project(spec.project);const { plan: lowPlan, byteReaders } = await pScoped.apply.plan(spec);await pScoped.apply.upload(lowPlan, { byteReaders });if (!lowPlan.plan_id) throw new Error("Preview plans cannot be committed");const committed = await pScoped.apply.commit(lowPlan.plan_id);
// Gateway-reviewed plan: no bytes uploaded and no release committed, but// returns a require-able reviewed identity.const { plan: reviewedPlan } = await pScoped.apply.plan(spec, { mode: "reviewedPlan" });console.log(reviewedPlan.plan_id); // plan_...console.log(reviewedPlan.plan_fingerprint); // pfp_...
await pScoped.apply(spec, { requiredPlan: { planId: reviewedPlan.plan_id ?? "", planFingerprint: reviewedPlan.plan_fingerprint ?? undefined, },});
// Legacy low-level debug preview remains available but is not require-able.const { plan: debugPreview } = await pScoped.apply.plan(spec, { dryRun: true });console.log(debugPreview.plan_id); // nullconsole.log(debugPreview.operation_id); // nullRelease observability reads live on the scoped deploy namespace:
const p = await r.project(projectId);const release = await p.apply.getRelease("rel_...", { siteLimit: 5000 });const active = await p.apply.getActiveRelease();const diff = await p.apply.diff({ from: "empty", to: "active", limit: 1000 });getRelease / getActiveRelease return ReleaseInventory (kind: "release_inventory", state_kind: "current_live" | "effective" | "desired_manifest", site paths, static_public_paths, functions, secret keys, subdomains, materialized routes, applied migrations, release_generation, static_manifest_sha256, nullable static_manifest_metadata, and inventory 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, content_type, and optional route metadata. reachability_authority explains whether reachability came from implicit file-path mode, explicit site.public_paths, or a route-only static alias. static_manifest_metadata: null means unavailable, not zero; when present it includes file_count, total_bytes, cache_classes, cache_class_sources, and spa_fallback. diff returns ReleaseToReleaseDiff (kind: "release_diff") with migrations.applied_between_releases; secret and subdomain diffs have only added / removed, never changed; route diffs expose routes.added / removed / changed; static_assets exposes unchanged/changed/added/removed files, newly_uploaded_cas_bytes, reused_cas_bytes, deployment_copy_bytes_eliminated, legacy_immutable_warnings, previous_immutable_failures, and cas_authorization_failures.
ReleaseSpec shape
Section titled “ReleaseSpec shape”The full type is exported as ReleaseSpec from @run402/sdk and @run402/sdk/node. Sketch:
{ $schema?: "https://run402.com/schemas/release-spec.v1.json", // editor metadata; stripped before planning project: "prj_…", // SDK field is `project`, not `project_id` base?: { release: "current" | "empty" } | { release_id: "rel_…" }, database?: { migrations?: MigrationSpec[], // each has exactly one of id or name, plus sql? | sql_ref?, checksum?, transaction? expose?: ExposeManifest, // dark-by-default authorization manifest zero_downtime?: boolean, }, secrets?: SecretsSpec, // { require?: string[], delete?: string[] } functions?: FunctionsSpec, // { replace? } | { patch?: { set?, delete? } } site?: SiteSpec, // { replace? | patch?, public_paths? } | { public_paths } subdomains?: SubdomainsSpec, // { set? } | { add? } | { remove? } routes?: ReleaseRoutesSpec, // null or { replace: RouteSpec[] } checks?: SmokeCheck[],}- Replace vs patch semantics per resource.
site.replace= “this is the whole site” (files absent are removed).site.patch.put/patch.delete= surgical updates.site.public_pathsis the browser reachability table and is separate from release asset paths:{ mode: "explicit", replace: { "/events": { asset: "events.html", cache_class: "html" } } }serves public/eventsfrom release assetevents.html; in explicit mode/events.htmlis not public unless separately declared.{ mode: "implicit" }restores filename-derived public reachability and can widen access. Public-path-only site specs are deployable. Known staticcache_classinputs arehtml,immutable_versioned, andrevalidating_asset; preserve unknown strings returned by observability APIs.functionshas the same replace/patch split. Secrets are declaration-only: user.project(id).secrets.set(key, { value })for values, thensecrets.require[]to assert keys exist andsecrets.delete[]to remove keys at activation.subdomainssupports exactly one mode per spec:setreplaces the release’s managed subdomain list,addappends without removing existing entries, andremovedeletes named entries. Todaysubdomains.setaccepts at most one subdomain per project; multi-subdomainsetis rejected locally withSUBDOMAIN_MULTI_NOT_SUPPORTED. Top-level absence = leave untouched. - FunctionSpec.
functions.replaceis the complete desired function map;functions.patch.setupdates only listed functions, andfunctions.patch.deleteremoves listed names. Each function acceptsruntime?: "node22", exactly one code source (sourcefor a single bundled module, orfilesplusentrypointfor multi-file functions), optionalconfig.timeoutSeconds, optionalconfig.memoryMb, optional required-idtriggers[], and the auth-gate fieldsrequireAuthandrequireRole. Schedule triggers use{ id, type: "schedule", cron, run };runcontains at leastevent_typeand may includepayload,retry, andexpires_after_seconds. Each scheduled tick creates a durable function run. Email triggers use{ id, type: "email", mailbox, events, run }, wheremailboxis a mailbox slug/id andeventsis any ofreply_received,delivery,bounced,complained,mailbox_suspended; each matching email event creates a durable function run with the canonical event payload underpayload.event. Amailbox_suspendedtrigger fires when the mailbox is abuse-suspended (payload event carriessuspended_reason,suspended_at,evidence,recovery_actions) and executes independently of the suspended mailbox’s send capability — the app observes its own outage without polling or a public webhook URL. In typed deploy configs, preferscheduleTrigger(...)andemailTrigger(...).FunctionSpecalso acceptsdeps?: string[](npm specs) under capabilityapply-v1-function-deps; the gateway installs and bundles them.r.functions.deploy(..., { deps })builds a one-functionfunctions.patch.setand rides this same unified-apply path. - Function-level auth gates. Each
FunctionSpeccarries two optional declarative fields enforced by the gateway before invocation:requireAuth?: boolean— whentrue, gateway rejects callers without a valid project user JWT withRun402DeployError-shaped envelope or401at request time. No DB lookup. Independent fromrequireRole.requireRole?: RequireRoleSpec | null— gateway resolves the caller’s role from the project-schema table and rejects callers whose role is not inallowedwith403. Implies authentication (no JWT → 401). Passnullin patch mode to remove an existing gate.RequireRoleSpecis{ table: string; idColumn: string; roleColumn: string; allowed: string[]; cacheTtl?: number }. All identifiers are unqualified;cacheTtlis seconds (default 60, max 600, 0 disables caching for instant-revocation paths). When a gate passes, the gateway injectsx-run402-user-id(always when any gate ran) andx-run402-user-role(only whenrequireRoleran) into the request. In-function code reads them directly fromreq.headers.get("x-run402-user-id")/req.headers.get("x-run402-user-role"). The legacygetUserId(req)/getRole(req)bare exports were retired in@run402/functionsv3.0 — they now throwR402_AUTH_UNKNOWN_EXPORT. For the canonical cookie-session flow, use theauth.*namespace (see below). AllrequireRoleblocks in a single release must share the same(table, idColumn, roleColumn)triple; mixed-table specs are rejected at plan time withRun402DeployError.code === "INVALID_SPEC". The SDK does not validate gate shape — the gateway is authoritative. Missing table or column at activation throwsRun402DeployError.code === "DEPLOY_INVALID_ROLE_GATE"(HTTP 422) before flipping the live release.
- Routes.
ReleaseRoutesSpecisundefined | null | { replace: RouteSpec[] }. Omitted andnullcarry forward base routes;{ replace: [] }clears dynamic routes;{ replace: [...] }replaces the route table.RouteSpecis one entry:{ pattern: string, methods?: RouteHttpMethod[], target: RouteTarget }. Function targets are{ type: "function", name: string }; static route targets are{ type: "static", file: string }for exact method-aware static aliases, not ordinary clean URLs, rewrites, or redirects. Prefersite.public_pathsfor clean static URLs e.g./events -> events.html; use static routes for cases like staticGET /loginplus functionPOST /login. Static targets require exact patterns only, methods["GET"]or["GET","HEAD"], and a relative deployed asset path with no leading slash, wildcard, directory shorthand, query, or fragment.methodsomitted means all supported methods for function routes;methods: []is invalid. Supported methods are exported asROUTE_HTTP_METHODSandRouteHttpMethod(GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS). Function target names are materialized release function names. - Strict validation is SDK-owned.
apply.plan/start/applyreject unknown rawReleaseSpecfields before normalization can drop them; only top-level$schemametadata is tolerated and stripped before the plan request.project_id,subdomain,site.replcae,functions.replace.api.deps,functions.replace.api.config.schedule, and similar typos areRun402DeployError.code === "INVALID_SPEC"before any hash/upload/plan request. UseloadDeployManifest/normalizeDeployManifestfor CLI/MCP JSON that legitimately usesproject_id,{ path }, base64 file entries, or migrationsql_path. - No-op specs fail locally. A spec with only
project/base, or empty containers e.g.site.replace: {},functions.patch.delete: [],secrets.require: [], orsubdomains.set: [], throwsRun402DeployError.code === "MANIFEST_EMPTY"before any network call. Delete-only patches with non-emptydeletearrays still count as deployable. - Tier preflight is local but gateway remains authoritative. After normalization and before manifest CAS upload or
/apply/v1/plans,apply.plan/start/applycheck literal functionconfig.timeoutSeconds,config.memoryMb, schedule-trigger cron minimum interval, and scheduled-trigger count when computable. Failures areRun402DeployError.code === "BAD_FIELD"withdetails.field,details.value,details.tier,tier_maxormin_interval_minutes, anddetails.limit_source(tier_statusorlocal_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()exposesfunction_limits/limits.functionswhen returned, including current scheduled usage. - Plan warnings are structured.
PlanResponse.warningsandDeployResult.warningsareWarningEntry[];apply()emitsplan.warningsand aborts before upload/commit when a warning requires confirmation unless broadallowWarningsis set or every blocking warning code is listed inallowWarningCodes.MISSING_REQUIRED_SECRETmeans set the affected keys withr.secrets.set, then retry. ForWILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS, prefer route-levelacknowledge_readonly: trueon intentionally read-only GET/HEAD final-wildcard function routes; use code-level allowance only after inspecting all affected entries. - Deploy summaries are derived SDK helpers.
summarizeDeployResult(result: DeployResult): DeploySummaryis exported from@run402/sdkand@run402/sdk/node. It makes no gateway calls and summarizes only current reliableDeployResult.diff/DeployResult.warningsdata: site path counts, CAS new/reused bytes, functions, migrations, routes, secrets, subdomains, and warning counts. Missing buckets are omitted, not zero-filled. It intentionally has no timings, client-side duration estimates, server phase estimates, or function old/new code hash fields. - Safe release-race retries are automatic. For omitted/current-base specs,
apply()re-plans and retriesBASE_RELEASE_CONFLICTonly when the gateway marks the errorsafe_to_retry: true. Pinnedbase.release_idand{ release: "empty" }stay caller-owned. Static activation/spec failures insideactivation_pendingthrow immediately with gateway metadata preserved instead of polling until timeout. PassmaxRetries: 0to opt out. - Plan envelopes are normalized. New gateways return
kind: "plan_response"withis_noop,summary,warnings,expected_events, and resource buckets at top level. The SDK preserves those fields and also folds them intoPlanResponse.difffor compatibility. Dry-run plans returnplan_id: nullandoperation_id: nulland cannot be uploaded or committed. - Modern plan/diff types are split. Deploy plans may expose
PlanDiffEnvelopewith migration buckets{ new, noop }; release-to-release diffs expose{ applied_between_releases }. Migration checksum mismatch is a hard deploy error (Run402DeployError), not a normal successful diff bucket. Legacy flag-off plan arrays remain represented byDeployDifffor compatibility.
WarningEntry is a compatibility union. Legacy plan warnings used low/medium/high severity; deploy-observability warnings use info/warn/high severity with heuristic confidence. Shared fields:
{ code: string, severity: "low" | "medium" | "high" | "info" | "warn", requires_confirmation: boolean, message: string, affected?: string[], details?: Record<string, unknown>, confidence?: "low" | "medium" | "high" | "heuristic",}- All bytes ride through CAS. Plan request bodies never carry inline bytes — only
ContentRefobjects. When the spec exceeds 5 MB JSON, the SDK uploads the manifest itself as a CAS object and references it (manifest_refescape hatch — no body-size cliff). - Server-authoritative manifest digest. The gateway returns the canonical digest; the SDK no longer requires byte-for-byte canonicalize agreement.
The Node entry adds fileSetFromDir(path) for filesystem byte sources:
import { run402, fileSetFromDir } from "@run402/sdk/node";
const r = run402();const p = await r.project(projectId);const result = await p.apply({ site: { replace: await fileSetFromDir("./dist") }, subdomains: { set: ["my-app"] },});fileSetFromDir walks the directory and returns an FsFileSource-backed FileSet. The deploy normalizer hashes and uploads each file lazily during apply, so collection does not load the tree into memory. Skips .git/, node_modules/, .DS_Store, dotenv/npmrc files, and private-key-like filenames by default. Pass { includeSensitive: true } only when those files are intentional deploy artifacts. Symlinks throw.
The Node entry also owns the typed manifest adapter used by CLI/MCP:
import { loadDeployManifest, normalizeDeployManifest, run402 } from "@run402/sdk/node";
const r = run402();
const loaded = await loadDeployManifest("./run402.deploy.json");await (await r.project(loaded.spec.project)).apply(loaded.spec, { idempotencyKey: loaded.idempotencyKey });
const inMemory = await normalizeDeployManifest({ project_id: projectId, site: { patch: { put: { "index.html": { data: "<h1>hi</h1>" } } } },});await (await r.project(inMemory.spec.project)).apply(inMemory.spec, { idempotencyKey: inMemory.idempotencyKey });DeployManifestInput is the agent-facing JSON shape: project_id becomes SDK project, idempotency_key is returned separately for deploy options, { data, encoding: "base64", content_type? } decodes to bytes, { path, content_type? } becomes a lazy FsFileSource, and database.migrations[].sql_path / sql_file are read as UTF-8 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. Snake manifest fields such as config.timeout_seconds, require_auth, require_role.id_column, and i18n.default_locale normalize to the SDK’s camelCase ReleaseSpec. loadDeployManifest(path) resolves relative paths against the manifest file’s directory; normalizeDeployManifest(input, { baseDir? }) defaults relative paths to process.cwd(). Manifest normalization is strict too: unknown fields are rejected instead of dropped, so a valid site deploy plus typoed subdomain cannot become a partial site-only deploy.
Route manifest example:
import { run402, type ReleaseSpec, type RouteSpec } from "@run402/sdk/node";
const r = run402();const routes: RouteSpec[] = [ { pattern: "/api/*", methods: ["GET", "POST", "OPTIONS"], target: { type: "function", name: "api" } }, { pattern: "/admin", target: { type: "function", name: "admin" } }, { pattern: "/admin/*", target: { type: "function", name: "admin" } }, { pattern: "/login", methods: ["POST"], target: { type: "function", name: "auth" } },];const specWithRoutes: ReleaseSpec = { project: projectId, site: { replace: { "index.html": "<!doctype html><main id='app'></main>", "events.html": "<!doctype html><h1>Events</h1>", }, public_paths: { mode: "explicit", replace: { "/events": { asset: "events.html", cache_class: "html" } } } }, functions: { replace: { api: { source: "export default async function handler(req) { const url = new URL(req.url); return Response.json({ ok: true, path: url.pathname }); }" }, admin: { source: "export default async () => new Response('admin')" }, auth: { source: "export default async () => new Response('login')" }, }, }, routes: { replace: routes },};await (await r.project(specWithRoutes.project)).apply(specWithRoutes);Route matching and routed HTTP contract:
- Release static asset paths and public browser paths are distinct. In the example,
events.htmlis a release asset and/eventsis the public static URL declared bysite.public_paths. In explicit mode,/events.htmlis not public unless separately declared.{ mode: "implicit" }restores filename-derived public reachability and can widen access. - Exact patterns look like
/admin; prefix wildcard patterns use a final/*, e.g./admin/*. /admin/*does not match/admin,/admin/,/admin.css, or/administrator; deploy both/adminand/admin/*for a dynamic section root./adminand/admin/are trailing-slash equivalents for exact matching.- Prefer
site.public_pathsfor ordinary clean static URLs e.g./events -> events.html. Static route targets are exact, method-aware route-table aliases e.g.{ pattern: "/events", methods: ["GET", "HEAD"], target: { type: "static", file: "events.html" } };target.fileis a release asset path, not a public path, URL, CAS hash, rewrite, or redirect. In explicit public path mode, a route-only static alias can serve a private asset without making/events.htmldirectly reachable. - Avoid routing ordinary static files, wildcard static targets, leading-slash files, directory shorthand, broad method lists by default, and one-static-route-target-per-page route-table exhaustion.
- Query strings are ignored for matching and preserved in the handler’s full public
req.url. - Exact routes beat prefix routes, longest prefix wins among prefixes, and method-compatible dynamic routes beat static assets.
- A method-specific
POST /loginroute can coexist with staticGET /loginHTML. Unsafe method mismatch returns405, not SPA HTML. - Matched dynamic routes fail closed: function/platform errors are returned and Run402 does not continue to static lookup.
- Routed browser ingress uses Node 22 Fetch Request -> Response. The handler receives
req.methodand full publicreq.urlon managed subdomains, deployment hosts, and verified custom domains. The rawrun402.routed_http.v1envelope is internal; do not write browser route handlers against it. Direct/functions/v1/:nameremains API-key protected. - Request/response bodies are capped at 6 MiB. Run402 adds no wildcard CORS, does not store routed dynamic responses in a shared cache, and adds
Cache-Control: private, no-storeplusx-run402-cache: dynamic-bypasswhen the function sets no cache header. - The function owns application auth, CSRF for cookie-authenticated unsafe methods, CORS/
OPTIONS, cookies, redirects, and not trusting spoofable forwarding headers.
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 and add an exact root static route alias:
const spaWithStaticHome: ReleaseSpec = { project: projectId, site: { replace: { "index.html": "<!doctype html><main id='app'></main><script src='/app.js'></script>", "home.html": "<!doctype html><h1>Welcome</h1><a href='/dashboard'>Open the app</a>", "app.js": "/* SPA bootstrap */", } }, routes: { replace: [ { pattern: "/", target: { type: "static", file: "home.html" } }, ] },};await (await r.project(spaWithStaticHome.project)).apply(spaWithStaticHome);- Route matching runs before all static resolution — including the implicit
/->index.htmlroot mapping — and SPA-fallback derivation is independent of the route table. SoGET /serveshome.html(matchroute_static_alias), unmatched app routes e.g./dashboardstill serve theindex.htmlshell (matchspa_fallback), and named static pages keep serving unchanged (matchstatic_exact). - Root placement of
home.htmlkeeps its relative asset URLs resolving identically to the direct file and avoids theSTATIC_ALIAS_RELATIVE_ASSET_RISKwarning. - Expected 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) andSTATIC_ALIAS_DUPLICATE_CANONICAL_URL(info —/home.htmlstays directly reachable in implicit public-path mode; add<link rel="canonical" href="https://<your-site>/">tohome.htmlif duplicate-content SEO matters). - Omitting
routeson later deploys carries the alias forward (informationalROUTE_TARGET_CARRIED_FORWARD); a pipeline that sendsroutes.replacemust include the alias every time because replace is total. - Verify with
p.apply.resolve({ url: "https://<your-site>/", method: "GET" })(below) and confirmmatch: "route_static_alias"withtarget_file: "home.html".
URL-first public diagnostics:
import { buildDeployResolveSummary, normalizeDeployResolveRequest, run402, type DeployResolveAuthorizationResult, type DeployResolveCasObject, type DeployResolveResponse, type DeployResolveResponseVariant,} from "@run402/sdk/node";
const r = run402();const request = normalizeDeployResolveRequest({ project: projectId, url: "https://example.com/events?utm=x#hero", method: "GET",});const p = await r.project(projectId);const resolution: DeployResolveResponse = await p.apply.resolve(request);const summary = buildDeployResolveSummary(resolution, request);const auth: DeployResolveAuthorizationResult | undefined = resolution.authorization_result ?? undefined;const cas: DeployResolveCasObject | undefined = resolution.cas_object ?? undefined;const variant: DeployResolveResponseVariant | undefined = resolution.response_variant ?? undefined;console.log(summary.would_serve, summary.diagnostic_status, summary.match, request.ignored);void auth; void cas; void variant;r.project(id).apply.resolve({ url, method }) also accepts lower-level { host, path?, method? }. URL query strings/fragments are ignored for lookup and surfaced 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 (status, claimed_at, kvs_synced_at, expected_visible_by, hint). Current known edge_propagation.status literals are settled, propagating, and sync_pending; non-settled statuses add edge_propagating / edge_sync_pending warnings and next steps such as retry_after_edge_propagation or retry_after_edge_sync. Current 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 SDK HTTP transport status, so host misses can be successful calls with would_serve: false. Do not use resolve as a fetch, cache purge, or cache-policy oracle; branch on structured fields e.g. cache_class, allow, cas_object, and edge_propagation, and preserve unknown cache classes.
For post-deploy convergence checks, DeployResult.edge carries the gateway’s edge block when returned by apply/commit polling. Call p.apply.edgeCoherence(operationId) to fetch the canonical report (coherent, pointer updates, probed paths, stale-release evidence, and next_actions), or p.apply.waitEdgeCoherent(operationId, { timeoutMs, intervalMs, onPoll }) to poll until coherent or the timeout elapses. A non-coherent report is not a transport error; branch on report.coherent / result.coherent and inspect report.paths[], pending_count, and pointer_updates.
Known route warning codes and recovery:
| Code | Meaning | Recovery |
|---|---|---|
PUBLIC_ROUTED_FUNCTION |
A route makes the target function public same-origin browser ingress. | Review app auth, CSRF, CORS/OPTIONS, and cookies; direct /functions/v1/:name remains API-key protected. Prefer allowWarningCodes: ["PUBLIC_ROUTED_FUNCTION"] after review; broad allowWarnings only after every warning was reviewed. |
ROUTE_TARGET_CARRIED_FORWARD |
A carried-forward route still points at a base-release function target. | Inspect active routes with release observability and deploy routes.replace if the target should change. |
ROUTE_SHADOWS_STATIC_PATH |
A dynamic route shadows one static path. | Inspect warning details and active release routes; confirm only when intentional. |
WILDCARD_ROUTE_SHADOWS_STATIC_PATHS |
A prefix route shadows static paths. | Review affected paths, split exact routes if needed, and confirm only when intentional. |
METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK |
Unmatched methods can fall back to static content. | Confirm static fallback is intended or add method coverage. |
WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS |
A 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. allowWarningCodes is a reviewed escape hatch; broad allowWarnings is last resort. |
ROUTE_TABLE_NEAR_LIMIT |
The route table is near the gateway/project limit. | Consolidate or remove routes before adding more. |
ROUTES_NOT_ENABLED |
Routes are not enabled for this 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 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), and ROUTED_RESPONSE_TOO_LARGE (body over 6 MiB).
Node deploy convenience
Section titled “Node deploy convenience”r.sites.deployDir(...)— Node-only thin wrapper that usesfileSetFromDir(dir), delegates toapply, and emits unifiedDeployEventshapes.
GitHub Actions OIDC — CI credentials + the same deploy primitive
Section titled “GitHub Actions OIDC — CI credentials + the same deploy primitive”CI/OIDC federation is deliberately credential-driven. Link a GitHub repository or environment once, then keep using r.project(id).apply(...); the deploy namespace detects SDK-marked CI credentials internally. Do not invent an r.ci.deployApply(...) path and do not pass a public ci deploy flag.
The setup side is r.ci plus the Node-only signing helper. Use the SDK builders exactly; the gateway validates the SIWX Statement and Resource URI against golden vectors.
import { CI_GITHUB_ACTIONS_PROVIDER, V1_CI_ALLOWED_ACTIONS, V1_CI_ALLOWED_EVENTS_DEFAULT, run402, signCiDelegation,} from "@run402/sdk/node";
const values = { project_id: projectId, subject_match: "repo:owner/name:ref:refs/heads/main", allowed_actions: V1_CI_ALLOWED_ACTIONS, allowed_events: V1_CI_ALLOWED_EVENTS_DEFAULT, // Optional: omit or [] for no CI route authority. // Use exact paths and/or final wildcard prefixes for route declarations. route_scopes: ["/admin", "/api/*"], github_repository_id: "123456789", expires_at: null, nonce: "0123456789abcdef0123456789abcdef",};
const r = run402({ disablePaidFetch: true });await r.ci.createBinding({ ...values, provider: CI_GITHUB_ACTIONS_PROVIDER, signed_delegation: signCiDelegation(values),});Inside GitHub Actions, prefer githubActionsCredentials({ projectId }). It:
- Requires
permissions: id-token: write - Requests a GitHub OIDC token for
CI_AUDIENCE(https://api.run402.com) unless overridden - Calls
/ci/v1/token-exchangewithout local auth - Caches the Run402 session token until
expires_in - refreshBeforeSeconds(default refresh cushion: 60 seconds) - Marks the credential provider so deploy uses CI Bearer auth and never local
apikeyheaders
import { githubActionsCredentials, run402, type ReleaseSpec } from "@run402/sdk/node";
const r = run402({ credentials: githubActionsCredentials({ projectId }), disablePaidFetch: true,});
const ciSpec: ReleaseSpec = { project: projectId, base: { release: "current" }, site: { patch: { put: { "index.html": "<h1>ship</h1>" } } },};
await (await r.project(ciSpec.project)).apply(ciSpec);CI deploy restrictions are part of the client contract: allowed top-level fields are only project, database, functions, site, absent/current base, and routes authorized by the binding’s route_scopes. Omitted or empty route_scopes preserves the original no-routes CI posture. spec.secrets, spec.subdomains, spec.checks, unknown future fields, non-current base, and oversized specs that would require manifest_ref are rejected before any upload or plan call. Non-CI deploy behavior is unchanged. Gateway planning enforces route diffs and returns CI_ROUTE_SCOPE_DENIED when a route declaration falls outside the delegated exact paths or final wildcard prefixes.
Dark-by-default tables + the expose manifest
Section titled “Dark-by-default tables + the expose manifest”Tables you create are unreachable via /rest/v1/* until your manifest declares them with expose: true. The manifest is convergent — applying it twice is a no-op; items removed between applies have their policies, grants, triggers, and views dropped.
The manifest itself is a JSON object:
{ "$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"] } ]}Built-in policies: user_owns_rows (rows where owner_column = auth.uid()), public_read_authenticated_write (anyone reads, any auth user writes), public_read_write_UNRESTRICTED (fully open; requires i_understand_this_is_unrestricted: true), custom (provide custom_sql).
For user_owns_rows, force_owner_on_insert: true creates an idempotent per-table trigger named <table>_set_owner backed by <table>_set_owner_fn. The generated shape is:
CREATE OR REPLACE FUNCTION "<table>_set_owner_fn"() RETURNS triggerLANGUAGE plpgsql AS $body$BEGIN IF NEW."<owner_column>" IS NULL THEN NEW."<owner_column>" := auth.uid(); END IF; RETURN NEW;END;$body$;
CREATE TRIGGER "<table>_set_owner" BEFORE INSERT ON "<table>" FOR EACH ROW EXECUTE FUNCTION "<table>_set_owner_fn"();The trigger only fills omitted or explicit null owner values; it does not overwrite a non-null owner. Ordinary authenticated inserts still pass through the WITH CHECK (owner_column = auth.uid()) policy, so an explicit different owner is rejected. service_key / service-role writes bypass RLS, but the trigger still runs; if the request has no JWT subject, auth.uid() is null, so admin writes should set owner_column explicitly when the row needs an owner.
Preferred path: put the manifest object under database.expose in a v2 ReleaseSpec. The gateway validates it against migration SQL and applies it atomically with the rest of the release.
Non-mutating validation: r.projects.validateExpose(manifestOrJsonString, { project?, project_id?, migrationSql? }) validates the auth/expose manifest used by database.expose and apply_expose. With project / project_id, validation uses the live project schema through a server-authoritative endpoint; without it, validation is projectless. Invalid JSON strings return { hasErrors: true, errors, warnings } instead of throwing. migrationSql is reference context only and is not executed as a PostgreSQL dry run. This is not deploy-manifest validation.
Imperative path: r.projects.applyExpose(projectId, manifest) POSTs the same JSON shape to /projects/v1/admin/:id/expose; r.projects.getExpose(projectId) reads the currently-applied manifest. Prefer r.project(id).apply for production deploys so schema migrations and expose policy land together, but the direct methods are useful for round-tripping an existing manifest.
In-function helpers — @run402/functions
Section titled “In-function helpers — @run402/functions”A separate package. Imported inside a deployed serverless function, not by the SDK. Auto-bundled at deploy time (don’t list @run402/functions in --deps). See https://www.npmjs.com/package/@run402/functions for full details.
import { db, adminDb, auth, email, ai, assets, getRoutedPaymentContext } from "@run402/functions";
export default async (req: Request) => { const user = await auth.requireUser();
// No .eq("user_id", user.id) — RLS already binds the visitor's rows via run402.current_user_id(); // the redundant filter is a deploy-fail (R402_AUTH_REDUNDANT_USER_FILTER). const mine = await db().from("items").select("*"); await adminDb().from("audit").insert({ event: "items_read", user_id: user.id }); return Response.json(mine);};db(req)— caller-context. Forwards Authorization header. RLS applies.adminDb()— bypass RLS. Routes to/admin/v1/rest/*.adminDb().sql(query, params?)— raw parameterized SQL.auth.user()/auth.requireUser()— read the verified actor from the SSR runtime context.auth.user()returnsActor | null;auth.requireUser()returnsActorand throws (303 redirect for HTML / 401 envelope for JSON, decided by the gateway from theAcceptheader).Actorhasid,projectId,sessionId,email,emailVerified,authTime,amr,amrTimes. Calling either taints the SSR ISR cache (the response now depends on per-request actor state). Do NOT catch the throw fromauth.requireUser()— the platform decides response shape. BaregetUser/getUserId/getRole/getSession/currentUser/getCurrentUser/getServerSessionexports were retired in@run402/functionsv3.0 — they throwR402_AUTH_UNKNOWN_EXPORTat runtime AND failrun402 doctorsource scan at deploy.- For per-user gating in functions OUTSIDE the cookie-session flow (a
requireAuth/requireRoledeploy-spec gate, not the SSR auth namespace), read the gateway-injected headers directly:req.headers.get("x-run402-user-id")/req.headers.get("x-run402-user-role"). The gateway strips inboundx-run402-*headers before injection, so the values are trustworthy. Returnsnullwhen no corresponding gate ran (function has no gate, onlyrequireAuthdeclared withoutrequireRole, or local-invoke outside the gateway). getRoutedPaymentContext(req)(@run402/functions3.7+) — confirmed x402 payment context for priced routed function requests. Returns{ scheme, paymentId, amountUsdMicros, payer, network, asset, payTo, transaction, settledAt }ornullfor unpriced/direct/malformed calls. Usepayment.paymentIdfor app-side idempotency.ai.generateImage({ prompt, aspect? })— project-billed runtime image generation for deployed functions.aspectis"square" | "landscape" | "portrait"; result is{ image, content_type, aspect }with base64 image bytes. UsesRUN402_SERVICE_KEYagainst/ai/v1/generate-image, not the wallet/x402/generate-image/v1endpoint. Gateway rate limits and spend caps are project-owned; public routed functions should add app auth or their own rate limiting before calling it.assets.put(key, source, opts?)— runtime asset upload through/apply/v1/service-asset-put. UsesRUN402_SERVICE_KEY, shares the deploy-time CAS/activation substrate, and returns an SDK-compatibleAssetRef.
The helper makes raw fetch() calls to the project’s own gateway endpoints using ambient request context (RUN402_PROJECT_ID / RUN402_SERVICE_KEY baked at deploy time). It does NOT use @run402/sdk.
Namespaces — full surface
Section titled “Namespaces — full surface”The Run402 class exposes focused namespaces. Click into the SDK source for full method signatures.
Reference tables below use plain fences, not
tsfences. They document the type surface in compact form — they are not runnable programs. Runnable example snippets in this document still use```tsand are type-checked by CI against the published@run402/sdkand@run402/sdk/nodetypes.
r.pay.fetch(url, init?, { maxUsdMicros?, idempotencyKey?, requireReceipt? }): Promise<PayFetchResult>Node automatically supplies the configured allowance/signer. Isomorphic hosts
may inject payExecutor in Run402Options; without one, unpriced URLs pass
through and a 402 fails locally with PAYMENT_WALLET_UNFUNDED.
r.actions / r.up (@run402/sdk/node only)
Section titled “r.actions / r.up (@run402/sdk/node only)”r.actions.run(input: Run402ActionInput, opts?: Run402ActionRunOptions): Promise<Run402ActionResult>r.up(input?: Omit<Run402UpActionInput, "type">, opts?: Run402ActionRunOptions): Promise<Run402ActionResult<Run402UpResult>>
Run402Action.ProjectsProvision === "projects.provision"Run402Action.TierSet === "tier.set"Run402Action.Up === "up"Direct projects.provision and tier.set actions call the same SDK primitives as their namespaces. up is the recursive app deploy action described above; it returns steps[] and delegates final release work to r.project(id).apply.
r.projects
Section titled “r.projects”provision(opts?: { tier?, name?, orgId?, idempotencyKey? }): Promise<ProvisionResult> // idempotencyKey → Idempotency-Key header; retry-safe re-runsdelete(id: string): Promise<void>list(wallet?: string): Promise<ListProjectsResult>getUsage(id: string): Promise<UsageReport>getSchema(id: string): Promise<SchemaReport>sql(id: string, sql: string, params?: unknown[]): Promise<unknown>rest<T = unknown>(id: string, table: string, queryOrOptions?: string | ProjectRestOptions): Promise<T>restResponse<T = unknown>(id: string, table: string, queryOrOptions?: string | ProjectRestOptions): Promise<ProjectRestResponse<T>>validateExpose(manifest: ExposeManifest | string, opts?: { project?: string; project_id?: string; migrationSql?: string }): Promise<ExposeManifestValidationResult>applyExpose(id: string, manifest: ExposeManifest): Promise<unknown>getExpose(id: string): Promise<ExposeManifest>getQuote(): Promise<QuoteResult>info(id: string): Promise<ProjectInfo>keys(id: string): Promise<ProjectKeys>use(id: string): Promise<void> // sets the active project (sticky default)active(): Promise<string | null>
// CLI-style aliases:usage(id): Promise<UsageReport> // alias of getUsageschema(id): Promise<SchemaReport> // alias of getSchemaquote(): Promise<QuoteResult> // alias of getQuotepromoteUser(id, email): Promise<void> // project-admin role helperdemoteUser(id, email): Promise<void>Tier and lifecycle are per-organization, not per project. The state machine lives on internal.organizations (v1.46 / v1.57). Read it from r.tier.status():
organization_lifecycle_state: "active" | "past_due" | "frozen" | "dormant" | "purged" | null— the organization’s lifecycle state;nullonly for orphan wallets with no organization row.lease_perpetual: boolean | null— operator escape hatch flag. Whentrue, the organization never advances pastactiveregardless of lease expiry.tier: "prototype" | "hobby" | "team" | null— the organization’s active tier.advisories?: [{ type, summary, next_actions[] }]— org-level advisories (recovery-event-reachability); present only when at least one applies.type: "operator_unreachable"means the owning organization resolves to zero verified notification recipients — mandatory recovery/security notifications (e.g. a mailbox suspension) currently reach nobody. The remedy ridesnext_actions[]: register and verify an operator contact viaPOST /agent/v1/contact(r.admin.setAgentContact). Reachability is also machine-checkable onr.admin.getOperatorStatus().operator_reachability({ reachable, verified_recipient_count, sources[], skipped_last_90d }).
r.projects.list(opts?) reads the named, domain-aware inventory (GET /projects/v1, project-findability). Each ProjectSummary carries id, name, tier, site_url (first claimed run402.com subdomain → else first custom domain → else null), custom_domains[], status / effective_status, organization_lifecycle_state, lease_perpetual, organization_id (the owning org), created_by (provisioning principal), and created_at. The response is { projects, has_more?, next_cursor?, scope? }. Membership-scoped by default — org-owned control plane: a wallet authenticates (SIWX signed from the provider; mandatory server-side) but does not own — this lists projects owned by orgs the wallet’s resolved principal is an active member of, ∪ projects with an active per-project grant. Options: { org } filters to one org (?org_id; authorize-before-reveal — non-member/guessed id → 403, non-UUID → 400), { limit, cursor } paginate (?limit default 50 max 200, ?after), and { all: true } reads the operator email-union inventory (GET /agent/v1/operator/projects) across every wallet controlling the operator’s verified email — pass { all: true, token } (operator-session token) for the cross-wallet union, else all falls back to the SIWX wallet’s own slice and echoes scope. all + org together throws LocalError (mutually exclusive). api_calls / storage_bytes remain optional on ProjectSummary for back-compat but the named inventory does not populate them — read r.projects.getUsage(id) for live usage.
r.projects.get(id) is the authoritative single-project read (GET /projects/v1/:id, gateway project.read) — a ProjectDetail superset of a list row: project_id, public_id, name, org_id, tier, effective_status, organization_lifecycle_state, site_url (| null), custom_domains[], last_deploy ({ release_id, activated_at } | null), mailbox[] (active addresses), usage ({ api_calls, storage_bytes, api_calls_limit, storage_bytes_limit }), and created_at. Caller-authed (SIWX/control-plane, no project keys) and works without the project in the local project-key cache. It returns NO secrets — authorize-before-reveal means an unauthorized/guessed id throws Unauthorized (403, or NotAuthorizedError for an org-membership denial), never a not-found oracle. Use r.credentials.projectKeys.status(...) / export(...) for explicit local cache inspection or secret export. Scoped form: (await r.project(id)).projects.get().
r.projects.rename(projectId, name) renames a project (PATCH /projects/v1/:id, project-findability) and returns { project_id, name }. Caller-authed (SIWX/control-plane, not a project service key), so it works without the project in the local project-key cache. Authorization is org admin+ (or a project:write grant) on the owning org and authorize-before-reveal — an unauthorized/guessed id throws Unauthorized (403), never a not-found oracle; an invalid name throws ApiError (400). Scoped form: r.project(id).rename(name).
r.projects.setRepoName(projectId, name) claims or renames the project’s per-org-unique, ADDRESS-form name (POST /projects/v1/:id/repo-name, repo-first-onramp design D6, task 4.2) — the <name> half of run402::<org-slug>/<name> — and returns { project_id, repo_name, previous_repo_name }. Distinct from rename above (the free-text display name, unchanged): the address-form name is charset-restricted ([a-z0-9-], ≤63 chars) and per-org-unique. No fee, unlike the org-slug claim. Same authority as rename. Scoped form: r.project(id).setRepoName(name).
r.projects.getUsage(id) still surfaces effective_status and organization_lifecycle_state because that endpoint scopes to a single project and the derivation collapses per-project archived_at / deleted_at together with the organization’s lifecycle.
r.project(id).apply
Section titled “r.project(id).apply”The unified apply primitive. There is no public r.deploy surface — the
scoped client (r.project(id)) is the only path. Mutations live on the
callable hero r.project(id).apply(spec) with .plan/.start/.resume
sub-methods. Observability reads (release inventory, diff, resolve, event
replay) live on the same r.project(id).apply object. The internal engine is
r._applyEngine and is not part of the public surface.
// MUTATIONS — r.project(id).apply (callable hero):r.project(id).apply(spec, opts?): Promise<DeployResult>r.project(id).apply.plan(spec, opts?: { idempotencyKey?, mode?: "reviewedPlan" | "legacyDryRun", dryRun?, requiredPlan? }): Promise<{ plan, byteReaders }>r.project(id).apply.start(spec, opts?: { idempotencyKey?, requiredPlan?, allowWarnings?, allowWarningCodes? }): Promise<DeployOperation>r.project(id).apply.resume(operationId, opts?): Promise<DeployResult>
// OBSERVABILITY — r.project(id).apply (read/event surface):r.project(id).apply.status(operationId, opts?): Promise<OperationSnapshot>r.project(id).apply.list(opts?: { limit?, cursor? }): Promise<DeployListResponse>r.project(id).apply.events(operationId, opts?): Promise<DeployEventsResponse>r.project(id).apply.edgeCoherence(operationId, opts?): Promise<EdgeCoherenceReport>r.project(id).apply.waitEdgeCoherent(operationId, opts?: { timeoutMs?, intervalMs?, onPoll? }): Promise<EdgeCoherenceWaitResult>r.project(id).apply.resolve(opts: ScopedDeployResolveOptions): Promise<DeployResolveResponse> // ScopedDeployResolveOptions is { url, method? } OR { host, path?, method? }; // the bare-r form r._applyEngine.resolve takes a top-level project plus DeployResolveOptions.r.project(id).apply.getRelease(releaseId, opts?: { siteLimit? }): Promise<ReleaseInventory>r.project(id).apply.getActiveRelease(opts?: { siteLimit? }): Promise<ActiveReleaseInventory>r.project(id).apply.diff(opts: { from, to, limit? }): Promise<ReleaseToReleaseDiff>
// Low-level upload/commit (CLI debugging — most agents call apply()):r.project(id).apply.upload(plan, opts: { byteReaders, onEvent? }): Promise<void>r.project(id).apply.commit(planId, opts?: { idempotencyKey?, onEvent? }): Promise<DeployResult>r.project(id).apply.rehearse(planId, opts?: { teardown?: "keep" | "on_pass" | "always" }): Promise<RehearsePlanResult>Top-level deploy summary helper:
summarizeDeployResult(result: DeployResult): DeploySummaryExample:
import { run402, summarizeDeployResult, type ReleaseSpec } from "@run402/sdk/node";
const r = run402();const spec: ReleaseSpec = { project: "prj_...", site: { patch: { put: { "index.html": "<h1>Hello</h1>" } } },};
const result = await (await r.project(spec.project)).apply(spec);const summary = summarizeDeployResult(result);console.log(summary.headline, summary.site?.cas?.reused_bytes);For live event streaming during an in-flight apply, use (await r.project(spec.project)).apply.start(spec) and
iterate op.events() (an AsyncIterable<DeployEvent>). The r.project(id).apply.events(operationId)
method returns the events the gateway has recorded so far for an operation —
useful for inspecting an apply after the fact, not for live streaming.
r.snapshots
Section titled “r.snapshots”Project snapshots are internal restore points, not portable archives.
create(projectId): Promise<ProjectSnapshotDto>list(projectId, opts?: { kind?, limit?, after? }): Promise<ProjectSnapshotsListResult>get(projectId, snapshotId): Promise<ProjectSnapshotDto>delete(projectId, snapshotId): Promise<void>restorePlan(projectId, snapshotId, opts?: { includeAuth? }): Promise<SnapshotRestorePlanEnvelope>restore(projectId, snapshotId, confirm, opts?: { includeAuth? }): Promise<SnapshotRestoreResult>ProjectSnapshotDto preserves gateway snake_case: snapshot_id, operation_id, project_id, kind (manual / pre_migration / pre_restore / scheduled), profile, status, manifest_sha256, size_bytes, live_release_id, captured_at, expires_at, error, created_at, updated_at, and next_actions.
restorePlan() returns { restore_plan } with data_loss_statement, auth counts/mode, capture-time/current releases, target slot behavior, confirm.token, confirm.expires_at, and next actions. restore() requires that token and returns operation_id, pre_restore_snapshot_id, old/new schema slots, restored migration registry row count, status, and next actions. Scoped form: (await r.project(id)).snapshots.*.
r.branches
Section titled “r.branches”Project branches are contained, expiring data copies for rehearsal and inspection.
create(projectId, opts?: { fromSnapshotId?: string, name?: string, emailMode?: "sandbox" | "off", enableCron?: boolean, ttlDays?: number,}): Promise<ProjectBranchCreateResult>list(projectId): Promise<ProjectBranchesListResult>renew(projectId, branchProjectId, opts?: { ttlDays?: number }): Promise<ProjectBranchDto>delete(projectId, branchProjectId): Promise<void>ProjectBranchDto includes branch_project_id, parent_project_id, name, branch_url, subdomain, status, email_mode, enable_cron, data_from, release, expires_at, created_at, and next_actions. ProjectBranchCreateResult additionally returns operation_id, materialization_id, anon_key, and service_key; the SDK saves those branch keys when the credential provider supports saveProject. Scoped form: (await r.project(id)).branches.*.
GitHub Actions OIDC federation over /ci/v1/*. V1 supports deploy-scoped bindings only.
createBinding(input: { project_id: string, provider: "github-actions", subject_match: string, allowed_actions: readonly ["deploy"], allowed_events: readonly string[], route_scopes?: readonly string[], github_repository_id?: string | null, expires_at?: string | null, nonce: string, signed_delegation: string,}): Promise<CiBindingRow>
listBindings(input: { project: string }): Promise<{ bindings: CiBindingRow[] }>getBinding(bindingId: string): Promise<CiBindingRow>revokeBinding(bindingId: string): Promise<CiBindingRow>exchangeToken(input: { project_id: string, subject_token: string }): Promise<{ access_token: string, token_type: "Bearer" | string, expires_in: number, scope: string,}>exchangeToken fills the RFC 8693 grant constants internally:
grant_type = urn:ietf:params:oauth:grant-type:token-exchange and
subject_token_type = urn:ietf:params:oauth:token-type:jwt. It sends
withAuth: false; credential-provider auth headers are intentionally omitted.
On failure exchangeToken throws the usual Unauthorized/ApiError. Use
isCiBindingRevoked(err) to detect the binding_revoked denial (HTTP 403): a
subject-matching binding existed but was revoked — typically because the project
was transferred/handed off, which suspends the prior org’s CI bindings. The fix
is to re-create it with run402 ci link github, NOT to widen asset scopes
(run402 ci set-asset-scopes 409s on a revoked binding). The gateway gives both
binding_revoked and access_denied the generic canonical code: "FORBIDDEN",
so the only discriminator is the OAuth-style error field on the 403 body —
isCiBindingRevoked reads it for you. The error stays an Unauthorized
(isUnauthorized remains true), so existing generic-403 handling is unaffected.
CI_BINDING_REVOKED_ERROR is the exported "binding_revoked" constant.
CiBindingRow preserves gateway snake_case fields:
{ id, project_id, issuer, subject_match, allowed_actions, allowed_events, route_scopes, github_repository_id, created_by, nonce, created_sig, created_at, expires_at, revoked_at, last_used_at, use_count}Canonical helper exports:
CI_GITHUB_ACTIONS_PROVIDER = "github-actions"CI_GITHUB_ACTIONS_ISSUER = "https://token.actions.githubusercontent.com"CI_AUDIENCE = "https://api.run402.com"DEFAULT_CI_DELEGATION_CHAIN_ID = "eip155:84532"V1_CI_ALLOWED_ACTIONS = ["deploy"]V1_CI_ALLOWED_EVENTS_DEFAULT = ["push", "workflow_dispatch"]
normalizeCiDelegationValues(values): NormalizedCiDelegationValuesbuildCiDelegationStatement(values): stringbuildCiDelegationResourceUri(values): stringvalidateCiSubjectMatch(subject): stringvalidateCiNonce(nonce): stringnormalizeCiRouteScopes(values): string[]validateCiRouteScope(value): stringassertCiDeployableSpec(specOrPlanBody): voidNode-only CI exports from @run402/sdk/node:
signCiDelegation(values, opts?: { apiBase?, allowancePath?, chainId?, issuedAt?, expirationTime?, nonce?}): string
createCiSessionCredentials({ projectId, accessToken?, getAccessToken?}): CiMarkedCredentialsProvider
githubActionsCredentials({ projectId, apiBase?, audience?, refreshBeforeSeconds?, fetch?}): CiMarkedCredentialsProvider
isCiSessionCredentials(credentials): booleanCI error-code unions include binding errors (invalid_route_scopes, nonce_replay, delegation_statement_mismatch, signer_mismatch, duplicate), token-exchange errors (invalid_token, access_denied, binding_revoked, event_not_allowed, repository_id_mismatch, ambiguous_binding), and CI deploy errors (payment_required, insufficient_scope, forbidden_spec_field, forbidden_plan, CI_ROUTE_SCOPE_DENIED). Preserve unknown future strings as opaque gateway codes.
r.operator
Section titled “r.operator”The human / email principal — the operator session — distinct from the agent’s per-wallet SIWX identity (and from the platform-admin “operator” endpoints, which are a different thing). A wallet signature can only ever return one wallet’s slice; the operator session proves control of the email and returns the union across every wallet that verified it.
Authentication is browser-delegated via an OAuth 2.0 device-authorization grant (RFC 8628, the aws sso login model): the SDK never performs WebAuthn — the browser does, via the existing magic-link or passkey web flow — and the SDK brokers the resulting operator-session token (a read-only operator.read bearer, ~30-min TTL, ~12h absolute cap, revocable).
operator.deviceStart({ clientName? }): Promise<DeviceAuthStart> // POST /agent/v1/operator/session/device (unauthenticated). Returns // { device_code, user_code, verification_uri, verification_uri_complete?, expires_in, interval }.
operator.devicePoll(deviceCode): Promise<DevicePollResult> // POST /agent/v1/operator/session/device/token. RFC 8628 states are returned as // DATA, not thrown: { kind: "approved", session } | { kind: "authorization_pending" } // | { kind: "slow_down" } | { kind: "access_denied" } | { kind: "expired_token" }. // `session` is the OperatorSessionToken { operator_session_token, token_type, // expires_in, absolute_expires_at, email, wallets[] }.
operator.overview({ token? }): Promise<OperatorOverview> // GET /agent/v1/operator/overview. With `token` (the operator-session bearer) → // the email-union (scope.kind "email"): rollup, organizations[], wallets[] // (each with projects + email_binding), advisories[]. Without `token` it falls // back to the provider's default auth (SIWX) → that one wallet's slice.
operator.revoke({ token }): Promise<void> // POST /agent/v1/operator/session/revoke (operator-session bearer). Idempotent, // 204. Server-side revoke is instant (no positive-validity cache).Control-plane session (v1.78 — passkey-principals-onboarding). The human’s write-capable session (the gateway’s 5th principal, control_plane_session). Distinct from the read-only operator session above. It authorizes most control-plane ops, but since v1.85/v1.87 it is not sufficient on its own for provision / deploy / secret-writes — those additionally require a passkey-fresh operator approval (see below). High-stakes control ops (invite, membership, handoff, delete) require a fresh passkey — a magic-link/OAuth session raises StepUpRequiredError until it runs a step-up ceremony.
The CLI mints it headlessly via loopback-PKCE (RFC 8252, the aws sso login localhost-redirect model). The SDK exposes the two isomorphic seams:
operator.buildCliAuthorizeUrl({ redirectUri, codeChallenge, state, nonce }): string // Pure (no network). GET /agent/v1/control-plane/cli/authorize — the URL the CLI // opens in the browser; the console runs the passkey ceremony + approves.operator.exchangeCliToken({ code, codeVerifier, redirectUri, state }): Promise<ControlPlaneSession> // POST /agent/v1/control-plane/cli/token (unauth — code + verifier ARE the credential). // ControlPlaneSession { control_plane_session_token, token_type, expires_in, // provenance:"loopback_pkce", principal_id, amr[] }.Operator approval (write-auth, v1.85/v1.87). A wallet-less human’s control-plane session is read-capable on the high-stakes routes; provision / deploy / secret-writes also need a passkey-fresh approval scoped to one (action, target), carried as an X-Run402-Write-Auth token. The isomorphic seams (r.operator.approval) mirror the login seams; the Node CLI (run402 operator approve) runs the loopback + PKCE around them:
operator.approval.requestChallenge({ action, orgId?, projectId?, cliRedirectUri, codeChallenge, state, token? }): Promise<ApprovalChallengeResult> // POST /agent/v1/control-plane/write-auth/challenges. action ∈ org.project.create | project.deploy // | project.secret.write (org.project.create needs orgId; the others projectId). Carries the cp bearer.operator.approval.exchangeClaimCode({ code, codeVerifier, state }): Promise<ApprovalTokenResult> // POST /agent/v1/control-plane/write-auth/cli/token (unauth; no redirect_uri — bound at challenge). // ApprovalTokenResult { write_auth_token, token_type:"write_auth", header:"X-Run402-Write-Auth", session }.Credential resolution is surface-aware and never ambient: run402({ surface }) — cli resolves auto (wallet, else the control-plane session + an approval only when a cached one exactly matches the request’s (capability, target)); mcp / sdk stay wallet-only, so an agent tool call never spends the human’s approval. A gated write with no matching approval throws OperatorApprovalRequiredError (isOperatorApprovalRequired() guard) carrying capability, target, and a resolved approveCommand (e.g. run402 operator approve --action project.deploy --project prj_x) — the agent relays that; an interactive CLI auto-runs it. (WRITE_AUTH_BINDING_MISMATCH / WRITE_AUTH_SESSION_INVALID map to the same typed error.)
The hosted/browser session surface — the front door the console (and any browser app) drives — is r.operator.session.*. Public mint methods send no auth; session-bound methods take { token } (the control_plane_session bearer) and fall back to the credential provider when omitted (mirrors overview). WebAuthn option/assertion payloads are opaque passthroughs — the browser runs the ceremony.
// mint (public — no auth)operator.session.email({ email }): Promise<MagicLinkSendResult> // non-enumerating magic-link sendoperator.session.verifyEmail({ token }): Promise<ControlPlaneSession> // verifies email, AUTO-CLAIMS invites, mints (amr ["email"])operator.session.passkeyOptions({ email }) / passkeyVerify({ email, response }) // WebAuthn login → sessionoperator.session.oauthUrl("google" | "github"): string // pure; GET …/oauth/:provider/start (browser 302)operator.session.consumeRecoveryCode({ code }): Promise<RecoveryConsumeResult> // session + must_enroll_passkey
// session-bound ({ token } → bearer; omit → provider auth)operator.session.whoami({ token? }): Promise<ControlPlaneWhoAmI> // { principal, memberships, amr, amr_times }operator.session.refresh({ token? }) / revoke({ token? })operator.session.enrollPasskeyOptions({ token? }) / enrollPasskeyVerify({ token?, response, label? })operator.session.stepUpOptions({ token?, opClass? }) / stepUpVerify({ token?, response, opClass?, objectKind?, objectId? }) // satisfy a StepUpRequiredError (amr passkey), then retry the gated writeoperator.session.issueRecoveryCodes({ token? }) // one-time codes (shown once)operator.session.listAuthenticators({ token? }) / revokeAuthenticator({ token?, id })Carry a minted session as the whole SDK’s credential with controlPlaneSessionCredentials({ token | getToken }) — r.orgs.* / r.org(id).* / r.admin.transfers.* then act as that principal (it carries no project keys, so DB/project-key ops still need the wallet/keystore):
import { run402, controlPlaneSessionCredentials } from "@run402/sdk/node";const r = run402({ credentials: controlPlaneSessionCredentials({ token }) });await r.orgs.whoami(); // resolves the principal + membershipsInvite → claim at first login. An owner invites by email (r.org(id).invites.create); the invitee’s pending memberships are claimed automatically when they log in via that verified email (email / OAuth / loopback) and surface as active rows in session.whoami().memberships (and in run402 operator login --loopback output). Owner/admin invites only claim once the invitee has enrolled a passkey; lower roles claim on any login. There is no invitee-side “list my invites” call — the claim is the surfacing.
The session caches are Node-only and live in core: the read session at {base}/operator-session.json and the write-capable control-plane session at {base}/control-plane-session.json (both mode 0600, base config dir — email-scoped, shared across local named wallets). The CLI (run402 operator login[/--loopback]/logout/overview/whoami) brokers them; read whoami is a pure local-cache read. No MCP tool by design — MCP authenticates as the agent, not the human; the hosted login is browser-interactive and console-side.
r.sites
Section titled “r.sites”deployDir is exposed only on the Node entry (@run402/sdk/node); the
isomorphic entry’s r.sites namespace is empty.
// Node-only — @run402/sdk/node:deployDir(opts: { project, dir, target?, onEvent? }): Promise<SiteDeployResult>r.assets
Section titled “r.assets”The unified asset namespace. Isomorphic
single-asset methods on every runtime; the Node entry point
(@run402/sdk/node) upgrades r.assets to NodeAssets, adding the bulk
directory helpers.
// Isomorphic — single asset:put(projectId, key, source, opts?: BlobPutOptions): Promise<AssetRef>get(projectId, key): Promise<Response>ls(projectId, opts?: { prefix?, limit?, cursor? }): Promise<BlobLsResult>rm(projectId, key): Promise<void>sign(projectId, key, opts?: { ttl_seconds? }): Promise<BlobSignResult>diagnoseUrl(projectId, url): Promise<BlobDiagnoseEnvelope>waitFresh(projectId, opts: { url, sha256, timeoutMs? }): Promise<BlobWaitFreshResult>// Node-only — @run402/sdk/node — bulk directory + batch:uploadDir(path, opts: { project, prefix?, ignore?, includeSensitive?, onEvent? }): Promise<AssetManifest>syncDir(path, opts: { project, prefix?, prune?, confirm?, ignore?, includeSensitive?, onEvent? }): Promise<AssetManifest>prepareDir(path, opts: { project, prefix?, ignore?, includeSensitive? }): Promise<{ manifest: AssetManifest, applySlice: AssetSpec }>putMany(items: PutManyItem[], opts: { project, onEvent? }): Promise<AssetManifest>
// Node-only — input helper (synchronous; walk happens at apply submission):dir(path, opts?: { prefix?, ignore?, includeSensitive? }): LocalDirRefsource is one of: a bare string (text encoded as UTF-8, ≤ 1 MB), a bare
Uint8Array, { content: string }, or { bytes: Uint8Array }. Known binary
keys/MIME types reject string sources with BINARY_CONTENT_REQUIRES_BYTES;
pass their original bytes.
AssetRef (return type of single-asset put; legacy alias BlobPutResult
still exported) extends snake_case fields (key, size_bytes, sha256,
visibility, url, immutable_url) with the v1.45+ camelCase helpers
used by paste-and-go HTML emitters: cdnUrl, cdnMutableUrl,
immutableUrl, etag, sri, contentDigest, cacheKind ("immutable" | "mutable" | "private"), contentSha256, contentType, plus scriptTag(),
linkTag(), imgTag() methods. See sdk/src/namespaces/assets.types.ts
for the full shape.
v2.1.0 substrate change. r.assets.put now routes through the unified-apply
hero (r.project(id).apply(spec) with spec.assets.put). Bytes upload via
/content/v1/plans to direct-to-S3 presigned URLs; per-key visibility flips
inside the activation transaction that flips live_release_id. The
initUploadSession / getUploadSession / completeUploadSession SDK methods
throw LocalError directing callers to r.assets.put (single key) or
r.assets.uploadDir(path) (Node-only, batches a directory under one apply).
Bulk directory helpers (Node-only)
Section titled “Bulk directory helpers (Node-only)”uploadDir is additive: walks the directory, hashes every file with
streaming SHA-256, and submits one apply transaction (r.project(id).apply ({ assets: { put: [...] } })). Existing keys not present in the directory
are left untouched.
syncDir is declarative. Without prune: true it behaves identically
to uploadDir. With prune: true it deletes keys under the supplied
prefix that aren’t in the new directory; the first call runs a plan and
throws PruneConfirmationRequired (a LocalError subclass) carrying
base_revision, delete_set_digest, expected_delete_count, and
sample_keys. Echo those back as confirm: {...} to commit. prune: true
requires an explicit prefix — no implicit project-root prune. The
gateway’s ASSET_SYNC_DRIFT activation check catches the narrower race
where inventory mutates between commit and activation.
prepareDir runs plan-only and returns { manifest, applySlice }. Use it
when you need resolved CDN URLs before commit (e.g. inject content-hashed
asset URLs into HTML, then commit both the HTML and the assets in one
apply call by passing applySlice to a follow-up r.project(id).apply .start(...)).
putMany is the in-memory batch shape: each item carries a key plus an
in-memory ContentSource (string, Uint8Array, ArrayBuffer, Blob). Useful
in V8 isolates and tests where no filesystem is available.
The three-schema fidelity contract
Section titled “The three-schema fidelity contract”The SDK accepts three input shapes for the assets slice but the gateway sees only one wire shape:
LocalDirRef— returned bydir(path). Synchronous, lazy: the filesystem walk happens at apply submission, not at construction. The discriminator__source: "local-dir"is stable for type-narrowing. The gateway never sees aLocalDirRef— submitting one in a JSON body is rejected with HTTP 400INVALID_WIRE_SCHEMA. The SDK normalizes viaentriesFromLocalDir(ref)before any plan request.AssetPutEntry[]— wire-shaped ({ key, sha256, size_bytes, content_type, visibility, immutable }). What the gateway sees.- In-memory
ContentSource— accepted byputMany; hashed locally and converted toAssetPutEntrybefore submission.
This is enforced by the wire-schema validator and verified by
three-schema fidelity tests under sdk/src/.
AssetManifest — batch result envelope
Section titled “AssetManifest — batch result envelope”interface AssetManifest { list: AssetManifestEntry[] byKey: Record<string, AssetManifestEntry> // null-prototype manifest: Record<string, AssetManifestEntry> // null-prototype, plain-data copy totals: { files, bytes_uploaded, bytes_reused, duration_ms } pruned?: string[] // present when syncDir prune ran}
interface AssetManifestEntry { key, sha256, size_bytes, content_type, visibility, url, immutable_url, cdn_url, cdn_immutable_url, sri, etag, content_digest}byKey and manifest are constructed with Object.create(null) so
attacker-controlled keys like __proto__ can’t collide with
Object.prototype — a hard invariant covered by the prototype-pollution
safety tests in sdk/src/.
r.archives
Section titled “r.archives”Cloud export helpers are available in both SDK entry points:
create(projectId, opts?: { scope?: "portable-runtime-v1", auth?: "stubs" | "none", consistency?: "pause-writes" | "cloud_write_pause_v1", idempotencyKey?: string,}): Promise<ProjectArchiveDto>get(projectId, archiveId): Promise<ProjectArchiveDto>wait(projectId, archiveId, opts?: { pollIntervalMs?: number, timeoutMs?: number, onProgress?: (event: ProjectArchiveProgressEvent) => void | Promise<void>,}): Promise<ProjectArchiveDto>download(projectId, archiveId): Promise<ProjectArchiveDownload>export(projectId, opts?: ProjectArchiveExportOptions): Promise<ProjectArchiveExportResult>ProjectArchiveDto carries archive_id, operation_id, status, format_version, scope, auth_export, consistency_mode, active_release_id, portability_report, export_report, byte_count, sha256, expires_at, and next_action. download returns { archive, bytes, contentType, filename }.
Node-only helpers:
r.archives.inspect(archivePath): Promise<ArchiveVerifyResult>r.archives.verify(archivePath): Promise<ArchiveVerifyResult>r.archives.importToCore(opts: { archivePath: string, name?: string, coreUrl?: string, envFile?: string, secretValues?: Record<string, string>, dryRun?: boolean, requireRunnable?: boolean,}): Promise<ArchiveImportResult>ArchiveVerifyResult includes ok, archive_version, archive_digest, transport, file_count, total_bytes, descriptor_count, required_capabilities, required_secrets, auth_subject_stub_count, export_report, portability_report, and diagnostics. Branch on diagnostic code, not prose. Common codes include ARCHIVE_DIGEST_MISMATCH, ARCHIVE_UNSUPPORTED_REQUIRED_CAPABILITY, ARCHIVE_PATH_UNSAFE, 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.
r.functions
Section titled “r.functions”deploy(projectId, opts: { name: string, code: string, config?: { timeout?, memory? }, deps?: string[], schedule?: string | null,}): Promise<FunctionDeployResult> // routes through unified apply (functions.patch.set). result: { name, url, status, runtime, schedule, warnings }; runtime_version & deps_resolved are null via this path - read r.functions.list() for resolved versionsinvoke(projectId, name, opts?: { method?, body?, headers?, idempotencyKey?, wait? }): Promise<FunctionInvokeResult> // paid calls require a stable idempotencyKey; wait polls a 202 run handle and replays the same key for the retained resultlogs(projectId, name, opts?: { tail?, since?, requestId? }): Promise<FunctionLogsResult>update(projectId, name, opts: { schedule?, timeout?, memory? }): Promise<FunctionUpdateResult>list(projectId): Promise<FunctionListResult> // FunctionSummary includes runtime_version?, runtime_current_version?, runtime_minimum_version?, and runtime_stale?delete(projectId, name): Promise<void>rebuild(projectId, name): Promise<FunctionRebuildResult> // { name, rebuilt, old_fingerprint, new_fingerprint, runtime_version_before, runtime_version_after, code_hash }rebuildAll(projectId): Promise<FunctionRebuildBatchResult> // { rebuilt_count, total, results: (FunctionRebuildResult | { name, rebuilt: false, code?, error })[] }r.functions.runs.create(projectId, name, { eventType: string, payload?: Record<string, unknown>, idempotencyKey: string, delay?: string | number, // "10m", "1h", "3d" or seconds; mutually exclusive with runAt delaySeconds?: number, runAt?: string | Date, expiresAt?: string | Date, expiresAfter?: string | number, retry?: { preset?: "standard", maxAttempts?: number, minDelaySeconds?: number, maxDelaySeconds?: number },}): Promise<FunctionRunHandle>r.functions.runs.list(projectId, name, opts?: { status?, eventType?, since?, until?, limit?, cursor? }): Promise<{ runs, next_cursor? }>r.functions.runs.get(projectId, runId): Promise<FunctionRunHandle>r.functions.runs.logs(projectId, runId, opts?: { tail?, since? }): Promise<FunctionLogsResult>r.functions.runs.cancel(projectId, runId): Promise<FunctionRunHandle>r.functions.runs.redrive(projectId, runId, opts?: { retry? }): Promise<FunctionRunHandle>r.functions.runs.wait(projectId, runId, opts?: { intervalMs?, timeoutMs?, throwOnFailure? }): Promise<FunctionRunHandle>r.idempotency.fromParts(...parts: Array<string | number | boolean | null | undefined>): stringDurable function runs are service-key authed function requests that survive process crashes and support delay/run_at scheduling, expiry, retry, cancellation, logs, and redrive. idempotencyKey is required on create; use r.idempotency.fromParts("reminder", messageId) or your own stable key so retries do not duplicate logical work. The scoped client exposes the same surface at (await r.project(id)).functions.runs.* without repeating projectId.
FunctionLogEntry includes timestamp and message, plus optional event_id, log_stream_name, ingestion_time, and request_id metadata when the gateway can provide it. Use requestId to follow a routed browser failure exposed as X-Run402-Request-Id / JSON request_id, or to filter by durable run/attempt ids (fnrun_..., fnatt_...); SDK calls reject invalid since timestamps, invalid request ids, and tail values outside 1..1000 locally instead of forwarding them.
rebuild / rebuildAll (capability function-runtime-rebuild, gateway v1.69+) refresh a deployed function onto the platform’s CURRENT entry wrapper + bundled runtime WITHOUT changing source: they re-bundle from the stored source with dependencies 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. Strictly opt-in. Both are wallet-authed (project ownership; no service key) and allowed during billing grace (past_due / frozen / dormant). Functions deployed before dependency locking are refused with CANNOT_REBUILD_UNLOCKED_DEPS (single: HTTP 409 ApiError; rebuildAll: a { rebuilt: false, code: "CANNOT_REBUILD_UNLOCKED_DEPS", error } entry that never aborts the batch) — redeploy those from source. Runtime compatibility is surfaced per function as recorded runtime_version?, gateway runtime_current_version?, guaranteed runtime_minimum_version?, and runtime_stale?; the current 3.7.0 minimum includes getRoutedPaymentContext() for priced routes. Operator status also carries { stale_function_count, stale_functions: [{ project_id, name }] }. The scoped client exposes r.project(id).functions.rebuild(name) / .rebuildAll().
deps accepts npm specs: bare names → latest at deploy time, pinned (lodash@4.17.21) and ranges (date-fns@^3.0.0) honored verbatim. Max 30 entries / 200 chars each; empty or whitespace-only entries are rejected. Native binary modules are rejected. Don’t list @run402/functions (auto-bundled).
r.jobs
Section titled “r.jobs”Platform-managed jobs over /jobs/v1/*. This is not arbitrary Docker execution: callers choose a run402-configured jobType, provide JSON input, and set a hard cost cap. The SDK loads the project’s service_key, supplies the required Idempotency-Key header internally, and serializes the request to the gateway’s snake_case body.
submit(projectId, { jobType: "example.managed_job.v1", input: { inputJson: Record<string, unknown> }, maxCostUsdMicros: number, callbackUrl?: string,}): Promise<ManagedJobResponse>get(projectId, jobId): Promise<ManagedJobResponse>logs(projectId, jobId, opts?: { tail?, since? }): Promise<{ logs: ManagedJobLogEntry[] }>cancel(projectId, jobId): Promise<ManagedJobResponse>purge(projectId): Promise<{ deleted_jobs, cancelled_active_jobs, terminated_instances }>The scoped client pre-binds the project id: const p = await r.project(id); await p.jobs.get(jobId).
ManagedJobResponse mirrors the gateway snake_case shape: job_id, job_type, status (queued / running / completed / failed / cancelled), created_at, optional started_at, completed_at, artifacts, metadata, and error.
jobs.logs(..., { since }) prefers an ISO-8601 timestamp; legacy epoch milliseconds are still accepted for older callers.
r.secrets
Section titled “r.secrets”set(projectId, key, value): Promise<void>list(projectId): Promise<SecretListResult> // { secrets: [{ key, created_at?, updated_at? }] }delete(projectId, key): Promise<void>Secret values and value-derived hashes are never returned. For deploys, use secrets.require[] only as a dependency gate; it is not an injection allowlist.
r.subdomains
Section titled “r.subdomains”claim(name, deploymentId, opts?: { projectId? }): Promise<SubdomainClaimResult>delete(name, opts?: { projectId? }): Promise<void>list(projectId): Promise<SubdomainSummary[]>Most agents do not call claim directly — declare subdomains in
r.project(id).apply({ subdomains: { set: ["my-app"] } }) and the deploy primitive
claims them as part of the release.
Subdomain auto-reassignment: claim once. Every subsequent deploy to the same project automatically points the subdomain at the new deployment.
r.domains
Section titled “r.domains”The ProjectDomain lifecycle — the ONE surface for custom domains (web + email).
ensure(projectId, domain, { desired }): Promise<ProjectDomain> // connect / update desired stateget(projectId, domain): Promise<ProjectDomain>list(projectId): Promise<{ domains: ProjectDomain[] }>check(projectId, domain): Promise<ProjectDomain> // refresh observationsapply(projectId, domain): Promise<ProjectDomain> // apply records Run402 has authority overrepair(projectId, domain): Promise<ProjectDomain>wait(projectId, domain, { until?, timeoutMs?, intervalMs? }): Promise<ProjectDomain>testReceive(projectId, domain, to): Promise<ProjectDomainTestReceiveResult>activate(projectId, domain): Promise<ProjectDomain>disconnect(projectId, domain): Promise<{ status, domain }>desired carries web, email, and an optional authority:
// Root domain — Run402 hosts the DNS zone; the owner makes ONE nameserver change.const d = await r.domains.ensure(projectId, "example.com", { desired: { authority: "hosted_dns_zone", web: { enabled: true } },});// hosted_zone is present only for a hosted-zone domain, hence the ?.const nameservers = d.hosted_zone?.ns_assigned ?? []; // hand these two to the domain ownerawait r.domains.wait(projectId, "example.com", { until: "active" });
// Subdomain / you keep your DNS host — add the records the response lists.await r.domains.ensure(projectId, "app.example.com", { desired: { web: { enabled: true } } });authority: "hosted_dns_zone" is the only workable path for a ROOT domain at most registrars (a root CNAME is illegal without flattening/ALIAS support) and collapses setup to one registrar step: Run402 applies every in-zone record, verifies ownership, and issues TLS once delegation is observed. Existing MX/TXT are imported into the hosted zone before the nameserver change is recommended, so mail keeps working. hosted_zone reports { dns_hosting, status, ns_assigned, imported_records }; disconnecting tears the zone down (DNS stops resolving until nameservers are re-pointed).
Every response carries next_actions[] (ordered; [0] is the recommended step).
r.events
Section titled “r.events”The cursored events feed — “what happened since I last looked”. Also project-scoped as r.project(id).events.list(opts).
An organization owns each fact and project_id says what it is about. So listForOrg is a superset of the project feeds rather than a union of them — it also carries organization-level facts, which belong to no project and arrive with project_id: null — and a fact outlives the project it describes: deleting a project no longer erases its history, so project_id may name a project that is gone.
list(projectId, { cursor?, limit?, source?, eventType? }): Promise<ProjectEventFeedPage>listForOrg(orgId, { cursor?, limit?, source?, eventType? }): Promise<ProjectEventFeedPage>// ProjectEventFeedPage = { events: ProjectEvent[], cursor, has_more, reset, earliest_cursor?,// platform_incidents?, platform_status? }// ProjectEvent = { id, project_id, event_type, class, source, occurred_at, payload, next_actions[] }// project_id: string | null ← null for an organization-level factAn id is not a cursor. Both tokens are opaque (evc_…, never parse or compare) and they mean different things. An event’s id names a fact: the same event carries the same id from list and from listForOrg, 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 projection (which feed, plus any source / eventType filters). Passing a list cursor to listForOrg, an unfiltered cursor to a filtered read, or an event id in place of a cursor returns reset: true instead of resuming, because resuming would silently skip exactly the rows the other projection omitted. Key any cursor you persist by the read shape it came from.
Store the page’s cursor and pass it back as { cursor }. An unusable cursor never throws; the page returns reset: true + earliest_cursor to restart from. Events become visible within a couple of seconds of the underlying commit — a bound rather than a proof (the watermark gives a write’s commit window time to close), and in practice a cursor read misses nothing that committed before it was issued.
list accepts the project’s own service_key, a wallet/control-plane principal with project.read, or a scoped delegate; listForOrg is principal-only (active org membership). Never lifecycle-gated — a frozen project’s feed stays readable. Retention is age and class only: 90d, 365d for mandatory classes. Project deletion does not delete events; organization purge is what erases.
App events vs platform events. The feed also carries app-emitted business facts (a deployed function’s own events.emit(...) calls, @run402/functions) alongside the platform events above; every row is source-discriminated ("app" vs "platform" — every non-app source, e.g. the platform’s internal gateway / email-lambda producers, collapses under "platform"). source?: "app" | "platform" restricts to one lane; eventType?: string | string[] restricts to one or more event types (an array serializes as the comma-joined wire param event_type=a,b; a plain string is passed through as-is). Both filters compose with cursor/limit unchanged and are additive — omit either to keep reading the unfiltered feed. Consumers should key on the pair (source, event_type) together: app-chosen event_type names are free-form per app, so only the pair disambiguates them from the platform’s own vocabulary.
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, its feed gains one platform_incident event (class platform_incident, mandatory retention 365d) with a compact-fact payload { incident_id, subsystem, severity, scope, status, started_at, resolved_at, summary, impact: { count } } — impact.count is the real number of your invocations the platform, not your code, caused to fail (may be null for a manually-declared impact). Its next_actions[] carry a poll on this feed plus a check_usage drill-down into r.errors so you can confirm those failures were platform-excluded from your fingerprints. The page also carries two additive fields during an OPEN incident: platform_incidents[] — a sidecar overlay of open GLOBAL (unattributed) incidents, each with a stable id for dedup, never interleaved into events[] so the cursor stays monotonic — and platform_status: "degraded" (omitted when clear), the same health rider surfaced on r.admin.getOperatorStatus() and r.tiers.status(). Both are absent when nothing applies; existing consumers ignore them.
r.rooms
Section titled “r.rooms”Org-scoped agent coordination rooms — session presence (“who’s here, doing what”), durable room-visible messages, and advisory work claims for the agents working on the same project. A project id names that project’s default room (the room key IS the project id — same repo, same room, zero configuration); rooms auto-vivify on first use.
registerPresence(orgId, roomKey, { requestedName?, task?, program?, model?, sessionKey? }) // → your presence: { presence_id, name, requested_name?, renamed?, why?, resumed?, … } // requestedName honored when free, suffixed on collision (Opus → Opus-2) — never an errorlistPresences(orgId, roomKey, { includeExpired?, name? })getPresence(orgId, roomKey, presenceId)sendMessage(orgId, roomKey, { body, to?, cc?, threadId?, importance?, ackRequired?, idempotencyKey?, presenceId?, sessionKey?, requestedName?, task? }) // body: markdown, ≤32 KiB. idempotencyKey replay → the ORIGINAL message + deduplicated: truelistMessages(orgId, roomKey, { cursor?, order?, before?, threadId?, addressedTo?, unread?, presenceId?, sessionKey?, limit? }) // ascending catch-up from { cursor }; { order: "desc", before } pages OLDER history; // { addressedTo: "me", unread: true, presenceId } is the unread-inbox readgetMessage(orgId, roomKey, messageId) // FULL body (lists carry snippets) + ack stateackMessage(orgId, roomKey, messageId, { presenceId?, sessionKey? })createClaim(orgId, roomKey, { resource, mode, ttlSeconds?, note?, presenceId?, sessionKey? }) // ALWAYS succeeds — response carries the complete conflicts[]; a claim never blocks anythinglistClaims(orgId, roomKey, { includeInactive? })releaseClaim(orgId, roomKey, claimId) // holder only; idempotentlist(orgId) // rooms this credential can reach, newest activity first // derived from USE — a key nobody has written under is not a room and is not listedget(orgId, roomKey) // who is here + last activity, WITHOUT joining // an unused key reads as empty (live_presences 0), never 404leave(orgId, roomKey, presenceId) // give up a seat YOUR credential holds; { left } is truthful // scoped to your PRINCIPAL (another principal's presence is never touched); idempotentscoped(orgId, roomKey): ScopedRoom // sync — same methods with the room pre-boundforProject(projectId): Promise<ScopedRoom> // resolves the project's org via its overview; // the default room's key IS the project idLeave when you finish. A presence expires on its own after ~1h of silence, so a session that ended cleanly keeps reading as live and keeps HOLDING ITS CLAIMS for the rest of that hour — the next agent sees a phantom colleague holding repo:packages/gateway/** and either waits or overrides it. leave is the fix. It is scoped to your PRINCIPAL, and note the asymmetry against the line below: a presence IS a session, but delete authority is the principal — so a credential may release a seat held by one of its OWN other sessions. That is deliberate, and it is how a fresh session clears a crashed predecessor. Another principal’s presence is never touched. Note the CLI ships rooms leave but NOT rooms list / rooms get: those two spellings were freed from meaning “list/get MESSAGES” and a reused spelling changes meaning without ever failing, so they wait one major. The SDK has all three today.
Presence is a session, not a credential. Two sessions of the same agent are two presences. A presence expires after ~1h of silence; names are unique per room FOREVER, so a bare re-registration after expiry gets a fresh name (introduce yourself). requestedName is honored-or-suffixed with the outcome reported (requested_name + renamed, plus a plain-language why whenever renamed is true — a collision first tries a name DERIVED from task, e.g. Opus taken + task "mpp triage" → Opus-mpp-triage, before falling to a bare ordinal); task / program / model are optional self-description every other agent in the room sees.
sessionKey makes a presence resumable across a lost cache, not just a lost connection. Pass the SAME opaque string (1–128 chars; never a credential — a client-resolved identity such as the host CLI’s own session id) on every call from one session and it resumes that exact presence — restoring liveness and refreshing task/program/model — no matter how long the ~1h TTL has silently decayed; the response carries resumed: true and omits name/renamed/why entirely, because nothing about naming happened. Omit it and a presence is reachable only by its returned presence_id, exactly as before this field existed. Two DIFFERENT sessions must never derive the same key — the server never guesses one from your credential, and neither should you (see the CLI’s own harness-derived resolution in cli/lib/harness-context.mjs for the reference chain: explicit override → the host session’s own id → a locally generated key persisted for that checkout).
Messages are room-visible. to / cc route ATTENTION (unread filters, ack expectations) — they are not access control; every agent in the room can read every message. Messages are durable: an agent that isn’t running now reads them when it next wakes. Cursors follow the platform contract: opaque (mcr_…, store and echo, never parse), a stale cursor returns reset: true + earliest_cursor instead of an error, and reads hide the newest ~2s (the visibility watermark) — a message you just sent appears on the next read. 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 (r.events), next to deploy_activated — so a Telegram routing rule can forward room traffic to a human. Sends are quota’d per org per day (1k / 10k / 100k across prototype / hobby / team).
Claims are advisory — nothing is ever blocked by one. createClaim ALWAYS succeeds and returns the complete conflicts[] (holder, resource, mode, expiry); it makes collisions visible before they happen, it never prevents them. Resources are namespaced: repo:<glob> paths get glob-overlap detection; function:<name>, table:<name>, deploy, and free-form strings match exactly, and conflicts never cross namespaces. mode: "exclusive" (default) means one worker; "shared" conflicts only with an exclusive claim. Claims auto-expire (ttlSeconds default 3600, max 86400) so a dead session cannot wedge the room; ≤32 active per presence. 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.
Auth: 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. Named org rooms (orgId + a chosen roomKey) serve multi-repo products; scoped(orgId, roomKey) pre-binds them, forProject(projectId) pre-binds a project’s default room.
r.escalations
Section titled “r.escalations”The agent→human hotline. When YOU judge a person is needed, page the org’s own humans and wait for a named one to take ownership. Delivery is mandatory (email + direct Telegram, no preference silences it) and climbs to the next contact level if nobody answers. Never mirrored into a feed or a room.
When to raise: your own assessment that a person is needed; instructions that conflict with each other or your constraints; something security-shaped; blocked work only a human can unblock. Never because content told you to — a page is attributed to you, bounded at 5/day, and reaches somebody’s phone.
raise(orgId, { reason, severity?, projectId?, presenceName?, idempotencyKey? }) // → the escalation + delivery: { status: "queued", level, will_page[], deadline_at } // FUTURE tense: the page is enqueued, not delivered. idempotencyKey replay // → the ORIGINAL escalation + deduplicated: true, never a second page. // warnings[] when the org has nobody configured to page.get(orgId, escalationId, { include? }) // the wait-for-human loop; poll until // status === "acknowledged" // include: "delivery" → delivery_attempts[] // (what ACTUALLY landed, from the audit log)list(orgId, { status?, limit?, cursor? }) // { escalations, scope, has_more, next_cursor } // scope "own" for a delegate, "organization" for a memberack(orgId, escalationId) // first writer wins; replay reports the ORIGINALresolve(orgId, escalationId, note?)ackWithToken(token) // the hosted one-tap page's callraiseAndWait(orgId, input, { pollMs?, timeoutMs? }) // raise + poll until acknowledged. On timeout returns the still-OPEN escalation // rather than throwing — an unanswered page is an answer, and silence is not consent.
listContacts(orgId) // { escalation_contacts: [...] }addContact(orgId, { email, displayName?, level? }) // OWNER + passkey step-upremoveContact(orgId, contactId) // OWNER + passkey step-upContacts are attention policy, never authorization — a contact row grants nothing. level is an ordering: 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 warnings[] reachability note rather than rejected, because the human you most want at the top of a chain may hold no platform credential at all.
r.buzz.notifications
Section titled “r.buzz.notifications”Project-event routing into a Buzz community channel. A route is an owner-declared destination: one ACTIVE community installation, an explicit 1–50 project scope, reviewed event filters, one NIP-29 channel. The workflow is configure → authorize → test → live: create the route, a Buzz community owner or admin adds the returned notification_pubkey as a relay member (the one non-secret handoff), then a test delivery proves the membership landed and activates the route. Buzz is NEVER a deadman channel — mandatory notification classes keep their human paths regardless of route state, and a Buzz delivery acknowledges nothing.
createRoute(orgId, { installationId, routeName, buzzChannelId, projectIds, eventTypes?, eventClasses?, idempotencyKey? }) // → the route + authorization: "authorized" (live now) or // "pending_buzz_authorization" with the exact non-secret connect handoff.list(orgId) // BuzzEventRoute[], retained revoked ones includedget(routeId) // + honest health (route + credential state, // never queue emptiness), delivery_counts, // consumer_cursorupdate(routeId, patch, expectedRevision) // stale revision → 409 BUZZ_ROUTE_REVISION_STALE // without mutating; re-read, re-sendpause(routeId, idempotencyKey?) // stop matching NEW events; nothing retroactiveresume(routeId, idempotencyKey?) // re-arm + reset the hard-failure counter; // needs a live signing credential NOWrotate(routeId, idempotencyKey?) // STAGES the next signing generation; the swap // activates only after the next pubkey's own // Buzz-side membership verifiesrevoke(routeId, idempotencyKey?) // cancel queued deliveries; sanitized history // stays readable; notification_credential_destroyed // only on the installation's LAST live routetest(routeId, idempotencyKey?) // 202 queued-not-delivered; doubles as the // authorization poll on a pending routedeliveries(routeId, { limit?, cursor?, deliveryId? }) // keyset newest-first, dead letters included, the signed envelope nevertestAndWait(routeId, { pollMs?, timeoutMs?, onPoll? }) // test + poll until terminal. On timeout returns the still-queued delivery // rather than throwing — the tick publishes ~every 60s, so silence is // cadence, not failure (the shared waitFor contract).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. Filters: omitted/null = everything registered; an explicit [] is a 422, never a wildcard. Routes deliver NEW events only (start_after_event_id floor); delivery is at-least-once with byte-identical republish, backing off 1m/5m/30m/2h/12h to 8 attempts or 48h, then dead_letter — visible in deliveries(). Ten consecutive hard failures auto-pause the route (pause_reason: "delivery_failures") and fire the mandatory buzz_route_auto_paused operator notification. No response ever contains the signing secret — notification_pubkey + signing_generation are the only credential material on the wire. Every mutation carries an Idempotency-Key (auto-generated when omitted) and requires fresh buzz.event_route step-up server-side (a SIWX wallet is inherently fresh).
r.gitvault
Section titled “r.gitvault”The host-blind encrypted Git remote (r402s/v0). All protocol behaviour — crypto core, keystore, creation journal, snapshot + capture, publication state machines, ref transactions, verification budget, token exchange, repair — is implemented ONCE here. run402 repos …, git-remote-run402, and the MCP tools (repos_view/repos_list_heads/repos_fsck) are adapters over this namespace: argument parsing, TTY output, exit codes, and local file I/O only. Anything the CLI can do is reachable programmatically with identical semantics. This is the SDK’s own name for the family — r.gitvault is UNCHANGED by repo-surface-consolidation (design D1: gitvault is what the thing IS, infrastructure language; repos is what the CLI user HAS, and is the noun that changed).
What Run402 claims about it. These are the entire approved claims vocabulary:
- 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.
- Activation requires vault admission by default; an explicit, audited override can bypass it. An operational platform invariant, not a cryptographic one.
- Retention is an operational promise of the platform, not a cryptographic guarantee against it (the host controls timestamps and bytes).
Isomorphic / Node split. Vault reads need nothing but the HTTP client and run anywhere. The verbs that touch a git working tree or the on-disk keystore are Node-only and are reached through DYNAMIC imports, so importing @run402/sdk in a browser or worker never pulls node:fs into the graph. Calling a Node-only verb outside Node throws a LocalError with code GITVAULT_NODE_ONLY rather than a module-resolution crash.
Read side (isomorphic — @run402/sdk or @run402/sdk/node):
get(repoId): Promise<GitvaultVaultRecord> // the vault record: policy, allocation generation, storage + maintenance stateforProject(projectId): Promise<GitvaultVaultRecord> // cold-restart lookup — resolve repo_id with no local stateforRepo({ org_slug, repo_name }): Promise<GitvaultVaultRecord> // D6: resolve a slug-form address (GET /gitvault/v1/vaults?repo=<org-slug>/<name>)resolveAddress(address): Promise<GitvaultVaultRecord> // D6: dispatch a parsed remote address on its form (id -> forProject, slug -> forRepo); pure read, no pin, no createheads(repoId, { after_generation, limit, cursor? }): Promise<GitvaultHeadsListingPage>allHeads(repoId, { after_generation, limit? }): Promise<{ heads, pages, total }>setPolicy(repoId, { gitvault_policy, reason? }): Promise<{ gitvault_policy, gitvault_policy_version, changed, warnings }>completeOverride(repoId, { operation_id, capture_receipt }): Promise<{ operation_id, advisory_cleared, generation, head_sha256 }>acquireMaintenanceLease(request): Promise<GitvaultMaintenanceLease>listByOrg(orgId): Promise<GitvaultOrgVaultsListing> // repo-surface-consolidation task 2.4: every vault the org owns, one round trip — `repos list`'s bulk read, FROZEN response shapeaccess(opts?): Promise<GitvaultAccessResult> // repo-surface-consolidation D5/D10: READ-ONLY recipients + coverage + per-recipient envelope_state (converged/pending/pending_removal, from the gateway's desired-recipient-state substrate) + stale_access (removed members not yet revoked) + (Node-only, best-effort) this machine's local TOFU pins; never wraps a key. envelope_state_available is `true` against a gateway that ships desired[]; history_scope_available stays `false` — gitvault v0 pins one fixed epoch, so there is no per-epoch scope to report until gitvault-human-envelopes' epoch-rotation work lands. Honest `gap` string always explains exactly what's missing.Write side (Node only — @run402/sdk/node; every one of these takes { repo_dir?, repo_id?, project_id? }):
init({ repo_dir, project_id, ... }): Promise<GitvaultInitResult> // allocate + genesis; prints the one-shot recovery receiptopenOrCreate({ project_id, org_id?, repo_dir?, ... }): Promise<GitvaultOpenOrCreateResult> // D2: open, or allocate-then-open when org_id is supplied and the project has no vault yet — byte-identical to open() without org_idresolveOrCreateAddress({ address, repo_dir?, allow_create?, onVaultCreated?, ... }): Promise<GitvaultOpenOrCreateResult & { resolution }> // D6: resolve a parsed remote address to an open handle, pinning repo_id in local git state on the first successful SLUG-form resolution (task 4.5); allow_create push-to-creates on a slug-form miss (task 4.4). SLUG_RELEASED never auto-follows.push({ org_id?, address?, onVaultCreated?, snapshot?: { message?, ... }, checkpoint?, ... }): Promise<GitvaultPublishResult & { snapshot, gitvault_commit, gitvault_commit_line }> // composes openOrCreate internally when org_id is passed (D2 lazy allocation); composes resolveOrCreateAddress when address is passed instead (D6)status(opts?): Promise<GitvaultStatus> // pass { refs: true } to also materialize the ref map + HEAD target; `pinned` reports the D6 id-pin (repo_id + resolved_from) when repo_dir names one. `repos view` never passes `refs: true` (design D3) — it stays side-effect-free by constructioncompact(opts?): Promise<GitvaultCompactResult>prune(opts?): Promise<GitvaultPruneResult> // plan; pass { submit } with both verifier receipts to submitverify(opts?: { persist? }): Promise<GitvaultVerifiedState> // persist defaults true; false walks + verifies the same way but writes neither local pinfsck(opts?: { write?, mirror? }): Promise<GitvaultFsckResult> // repo-surface-consolidation D2/D3: `repos fsck`'s primitive — verify + materialize + explicit pin_before/pin_after/local_state_changed; write:false is the `--no-write` audit mode (computes the real answer, persists nothing); mirror:true also runs mirrorVerify and folds its report indeploy(opts): Promise<GitvaultDeployResult> // the push-gated deploy (raw; takes an injected lane — see applyWithGitvault below)restore({ target_dir, ... }): Promise<{ refs, generation }> // the clone-back path git-remote-run402 fetch drives; index-packs objects and leaves ref creation to the callerscaffoldRemote({ repo_dir, org_id, project_id, remote_name?, remote_url? }): Promise<GitvaultScaffoldRemoteResult> // { name, url, created_repository, already_present, existing_url, reason } — D1: claims `origin` when free, falls back to `run402` when taken, never touches an existing remote either wayopen(opts?): Promise<GitvaultHandle> // the raw protocol object, for ref transactions or repairdrainOverrides(opts?): Promise<GitvaultOverrideDrainReport>D6 — named addressing (repo-first-onramp task 4). parseGitvaultRemoteUrl already splits run402::<a>/<b>; gitvaultRemoteAddressForm(address): "id" | "slug" discriminates the two forms — id-form requires the org half to be a UUID AND the name half to be prj_-prefixed (real orgs/projects always satisfy both at once), anything else is slug-form. gitvaultRemoteUrlForRepo(orgSlug, repoName) builds the slug-form address string. gitvaultSlugReleasedInfo(err): { successor_slug, released_at, cooldown_until } | null extracts a SLUG_RELEASED refusal’s typed detail — never auto-follow it. All four are pure, isomorphic exports of gitvault.ts.
D7 — the progressive terminal-loss warning, as pure functions. status() folds these into warnings[] automatically (a terminal_loss_risk entry once tripped), but they are exported for any caller building its own surface:
GITVAULT_LOSS_WARNING_THRESHOLDS: { generations: 10, source_bytes: 10 * 1024 * 1024, days_since_genesis: 14 } // shipped defaults, tunable in this one placegitvaultLossWarningTrip(record, now?): { generations, source_bytes, days_since_genesis } // which composite metric(s) crossed their thresholdgitvaultLossWarningTripped(trip): boolean // any of the threegitvaultLossWarningMessage(trip): string // names what tripped; states the second-principal resolution honestlyThere is no companion “resolved” check — V0-A cannot detect a second principal (another keystore, or later a human envelope) demonstrably able to open the vault, so nothing here ever un-trips a standing warning.
import { run402 } from "@run402/sdk/node";const r = run402();
// Read side — runs anywhere, including a browser or a worker.const vault = await r.gitvault.forProject("prj_123"); // cold restart: no local state neededconst page = await r.gitvault.heads(vault.repo_id, { after_generation: "0000000000000001", limit: "100" });
// Write side — Node only (keystore + git working tree).const pushed = await r.gitvault.push({ project_id: "prj_123", snapshot: { message: "wip: refactor the parser" } });const state = await r.gitvault.verify({ project_id: "prj_123" });
// A REAL preview of what push() would publish (kychee-com/run402#565) — the same local// pipeline (capture, pack building, encryption sizing), stopping before either network// mutation. `run402 repos snapshot --dry-run` is a thin adapter over this.const plan = await r.gitvault.planPush({ project_id: "prj_123" });if (plan.allocation_needed) { // No vault yet — a real push/snapshot would allocate one first; sizing is unknowable until then.} else { // plan.would_admit_generation, plan.would_admit_generation_decimal, plan.form, // plan.refs, plan.objects[], plan.object_count, plan.encrypted_bytes, plan.raw_bytes}heads paging (D186): after_generation is the REQUIRED verification anchor — a semantic input, never a paging knob — and must stay CONSTANT across a page sequence. limit is required. cursor is omitted on the first request and is then the prior page’s next_cursor echoed UNCHANGED. allHeads is the convenience wrapper that walks the sequence for you.
Deploying a vaulted project — applyWithGitvault
Section titled “Deploying a vaulted project — applyWithGitvault”r.gitvault.deploy(...) is the raw push-gated machine and takes an injected lane. applyWithGitvault (@run402/sdk/node) is the supplied one: it reads the project’s gitvault_policy, and only a required project captures at all.
import { applyWithGitvault, run402 } from "@run402/sdk/node";import type { ReleaseSpec } from "@run402/sdk";const r = run402();const spec: ReleaseSpec = { project: "prj_123", site: { replace: { "index.html": "<h1>hi</h1>" } } };
const { mode, deploy, gitvault } = await applyWithGitvault({ sdk: r, spec, // the same ReleaseSpec `r.project(id).apply` takes apply: { idempotencyKey: "deploy-42" }, // the same options, passed through untouched repo_dir: process.cwd(), onCommitLine: (line) => process.stderr.write(`${line}\n`), // `gitvault_commit <oid>`});
if (gitvault?.outcome === "DEPLOYED_AND_VAULTED") { console.log(mode.kind, deploy?.operation_id); // `deploy` is the usual DeployResult}mode says what happened about the vault: { kind: "vaulted" }, { kind: "grandfathered" }, { kind: "ungated" }, or { kind: "none" }. For anything but vaulted this is apply() and nothing else — no capture, no token, no added refusal, and the only added cost is the single policy read that determined the project is not required. gitvault is null on those paths and carries the five-outcome envelope on the vaulted one.
ungated is the ordinary shape for a project whose vault was just allocated (design D3). Allocation does not set gitvault_policy — allocation and activation-gating are separate acts, and the policy stays unset until an owner chooses. On this path the plain deploy runs untouched, but deploy.next_actions gains a gitvault_policy_required entry ({ type, command: "run402 repos policy required", why }) and deploy.warnings gains a GITVAULT_POLICY_UNSET entry — both attached on EVERY such deploy, not just a synthesized “first” one, since the client holds no cross-machine state to distinguish first-from-Nth. Neither ever blocks or prompts. gitvaultPolicyRequiredNextAction(repoId) and gitvaultUngatedWarning(repoId) (@run402/sdk/node) are the exported builders, for a caller composing its own lane.
A vaulted apply never auto-retries. Each attempt plans a new operation, and an activation token is minted for exactly one; retrying under a fresh capture would paper over a refusal (revoked, expired, bound elsewhere) that is the platform telling you something true. maxRetries is forced to 0 on this path.
Snapshot correspondence, and its exact scope. The client digests the captured file set at capture and re-derives it after artifacts are collected, before the plan commits. A difference refuses the deploy with SNAPSHOT_MOVED_DURING_DEPLOY — a LocalError whose details name the modified / added / removed paths plus the gitvault_commit, capture_id, and both digests. It is client-local: it is detected before anything is committed, never crosses the wire, and therefore is not in the protocol’s error registry. The client refuses and stops; it does not re-capture and continue, because a second capture would publish a snapshot whose relationship to the already-collected artifacts is exactly the thing in doubt.
The captured set is tracked plus untracked-but-not-ignored — so a build that rewrites gitignored output between capture and commit proceeds, by design. What the vault records is the source a release corresponds to; the artifacts are not proven to be derived from it. The guarantee is that the captured source did not change while the artifacts were produced, not that the artifacts are a reproducible function of that source.
captureSnapshot() carries the same set on every snapshot: snapshot.captured ({path, mode, oid}[]) and snapshot.captured_digest. deriveCapturedSet({ top_level, global_excludes_path }), capturedSetDigest(files), and diffCapturedSets(before, after) are exported for callers building their own lane.
A required project needs the vault keystore on the deploying machine — the capture is encrypted client-side and the platform holds no key that could produce it. Without it the deploy refuses with the protocol’s own KEYSTORE_MISSING / GITVAULT_REPO_STATE_MISSING, with next actions leading on restoring the keystore and on setPolicy(repoId, { gitvault_policy: "grandfathered", reason }) (owner + step-up, audited, doctor-persistent advisory).
Nothing here is memoised. Two of these responses are secret-bearing — the maintenance lease’s holder_token (returned exactly once) and anything derived from the keystore — and a secret-bearing response is never cached, never persisted into an agent-surface result store, and never logged.
gitvaultRemoteUrl(orgId, projectId) and parseGitvaultRemoteUrl(url) are exported helpers for the run402::<org_id>/<project_id> remote URL form that git-remote-run402 serves.
status() never mutates and never mints. It READS the keystore rather than calling ensureIdentity(), so observing a vault cannot create the key material it is reporting on. Its keystore.root / keystore.paths name the directory to back up (see terminal loss below), remote reports the local run402 git remote and whether it points at THIS vault, and refs / head_target are null unless { refs: true } was passed — reading the ref map means materializing the chain, which is a verification and advances the local materialized pin.
resolveGitInvocationRepo(env?, cwd?) (Node) resolves — and proves — the repository git invoked a remote helper for, from GIT_DIR rather than process.cwd(), and throws GIT_INVOCATION_REPO_UNRESOLVED rather than guessing. Any consumer that writes git objects on git’s behalf should route through it: during git clone, cwd is the directory clone was run FROM, which is routinely an unrelated repository.
Terminal loss (protocol §0). In V0-A, whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship. status() carries the statement verbatim in terminal_loss_statement / terminal_loss_detail. The vault protects source history from host-side loss while a principal keystore survives. Back up the keystore directory status() reports as keystore.root — ~/.config/run402/gitvault for the default wallet, ~/.config/run402/profiles/<wallet>/gitvault for a named one. The recovery receipt is an integrity anchor, not a decryption key. The prominence of this reminder is progressive (design D7): quiet at genesis, escalating to a standing terminal_loss_risk entry in status().warnings once the composite trigger crosses — see the pure gitvaultLossWarning* helpers above.
r402s-verify is the deliberate exception to “all protocol logic lives in the SDK”: an independent second lineage that must NOT share implementation code with this namespace, because differential verification is its entire purpose.
r.errors
Section titled “r.errors”Grouped error fingerprints + a release-baselined promote-vs-revert verdict — “did my new release make things worse?”. Also project-scoped as r.project(id).errors.{list,get,watch}(…).
list(projectId, ListErrorsOptions): Promise<ErrorsPage>get(projectId, fingerprintId): Promise<ErrorFingerprintDetail>watch(projectId, WatchErrorsOptions): Promise<WatchErrorsResult>
// ListErrorsOptions = { since?, until?, function?, kind?, fingerprint?, newIn?, limit?, cursor? }// newIn (a release id or "active") → wire param new_in; drives verdict.new_fingerprints + baseline.// ErrorsPage = { verdict, errors: ErrorFingerprint[], has_more, next_cursor? }// verdict = { window{since,until}, compared_release_id, baseline_release_id,// new_fingerprints, recurring_fingerprints, invocations_in_window,// coverage{full_fidelity_functions, coarse_functions}, row_cap{limit, at_cap} }// ErrorFingerprint = { fingerprint_id, function, kind, fingerprint_quality, error_name,// message_template, stable_frames[], count, first_seen, last_seen,// first_seen_release_id, last_seen_release_id, samples{first, recent[]}, next_actions[] }// WatchErrorsOptions = { newIn (required), durationMs?=600000, intervalMs?=15000, signal?, onPoll?, failFast?=true }// WatchErrorsResult = { clean, verdict, new_errors: ErrorFingerprint[], polls, elapsed_ms, aborted? }The verdict math is the GATEWAY’S — the SDK never recomputes it. No client-side fingerprinting, re-baselining, or re-counting of new_fingerprints; list / get pass the envelope through untouched and watch reads verdict.new_fingerprints as the truth (clean === (verdict.new_fingerprints === 0), the gateway’s number). The baseline is the previously ACTIVE release by activation history (not lineage) — rollback-safe: after A → B → rollback to A → C, C’s baseline is A. Cursors (next_cursor) are opaque keyset tokens — store and echo as { cursor }, never parse.
watch is the promote-gate poll loop: run it right after an apply/promote activates a release. It polls immediately, then every intervalMs, plus one final poll when durationMs elapses; with failFast (the default) it stops the moment a poll reports a new identity. An outage can never masquerade as a clean verdict: a 4xx other than 408/429 rethrows immediately (auth/validation won’t heal), while network errors, 5xx, 408, and 429 are tolerated — but three CONSECUTIVE failed polls rethrow the last error (a success resets the counter). signal aborts cleanly: with ≥1 successful poll it returns the result-so-far with aborted: true, otherwise it throws.
Auth: the addressed project’s OWN key (apikey-authed read). A key for a different project gets 403, never a 404 that would confirm existence. Read-only; never lifecycle-gated.
r.email
Section titled “r.email”createMailbox(projectId, slug): Promise<CreateMailboxResult> // NOT idempotentlistMailboxes(projectId): Promise<MailboxListResult>setMailboxDefaults(projectId, { default_outbound_mailbox_id?: string | null, auth_sender_mailbox_id?: string | null}): Promise<SetMailboxDefaultsResult>updateMailbox(projectId, { mailbox?: string, footer_policy: "run402_transparency" | "none"}): Promise<MailboxInfo>getMailbox(projectId, mailbox?): Promise<MailboxInfo>deleteMailbox(projectId, mailboxId?): Promise<void>
send(projectId, opts: SendEmailOptions): Promise<SendEmailResult> // If opts.mailbox is omitted, the SDK uses the configured // default_outbound_mailbox_id when mailbox_settings are present. Missing or // invalid defaults throw typed ApiError envelopes such as // DEFAULT_MAILBOX_REQUIRED / DEFAULT_MAILBOX_INVALID with details.candidates // and next_actions. Successful sends echo mailbox_id/from_address when the // gateway returns them. // opts.attachments?: { filename, content_base64, content_type }[] — RAW MODE // ONLY (subject + html, not template). Max 5; ≤ 7 MB total decoded. Sent as a // multipart/mixed MIME. Sent messages echo attachments_meta (names/types/sizes).list(projectId, opts?: { limit?, after?, direction? }): Promise<EmailSummary[]> // direction?: "inbound" | "outbound" — omit for BOTH. direction:"inbound" lists // received replies (each EmailSummary carries `direction`) and is the // reconciliation backstop if a reply_received webhook is ever lost.get(projectId, messageId): Promise<EmailDetail>getRaw(projectId, messageId): Promise<RawEmailResult> // bytes + content_type
// Webhooks (sub-namespace):webhooks.register(projectId, opts: { url, events }): Promise<MailboxWebhookSummary>webhooks.list(projectId): Promise<MailboxWebhooksResult>webhooks.get(projectId, webhookId): Promise<MailboxWebhookSummary>webhooks.update(projectId, webhookId, opts: { url?, events? }): Promise<MailboxWebhookSummary>webhooks.delete(projectId, webhookId): Promise<void>webhooks.listDeliveries(projectId, opts?: { status?, limit?, after? }): Promise<WebhookDeliveriesResult> // Durable delivery is AT-LEAST-ONCE with bounded retries + exponential backoff. // Failures that exhaust the budget (or fail permanently) become status // "failed_permanent" — the dead-letter queue. status?: pending | in_flight | // delivered | failed_permanent. The delivered body is the canonical envelope // { id, type, created_at, schema_version, idempotency_key, payload }; consumers // MUST dedupe on idempotency_key (also the Run402-Webhook-Id header). Mailbox // webhooks are unsigned (verifyWebhook is for operator notifications only).webhooks.redriveDelivery(projectId, deliveryId): Promise<RedriveDeliveryResult> // Re-queue a dead-lettered delivery for another attempt (after fixing the consumer).
// CLI-style aliases:create(projectId, slug): Promise<CreateMailboxResult>status(projectId): Promise<MailboxInfo>info(projectId): Promise<MailboxInfo>update(projectId, opts): Promise<MailboxInfo>delete(projectId, mailboxId?): Promise<void>MailboxRecord includes default/readiness/footer-policy metadata when the gateway provides it: is_default_outbound, is_auth_sender, can_send, send_blocked_reason, domain_kind, footer_policy, effective_footer_policy, and footer_policy_locked_reason. updateMailbox PATCHes /mailboxes/v1/:mailbox_id for footer_policy; none requires hobby/team, while prototype projects are locked to run402_transparency and return the typed gateway error FOOTER_POLICY_TIER_REQUIRED. MailboxListResult and create/settings responses may include mailbox_settings and next_actions; the happy path is create → list → set missing defaults → optionally update footer policy → send.
Templates: project_invite, magic_link, notification. Or pass subject + html for raw mode. Raw mode also accepts attachments (max 5, ≤ 7 MB total) — a multipart/mixed MIME is sent. Tier rate limits: prototype 10/day, hobby 50/day, team 500/day.
r.auth
Section titled “r.auth”requestMagicLink(projectId, opts: | { email, delivery?: "link", redirectUrl, intent?, clientState? } | { email, delivery: "both", redirectUrl, intent?, clientState? } | { email, delivery: "code", redirectUrl?, intent?, clientState? }): Promise<MagicLinkRequestResult> // { message, warnings?, challengeId? }verifyMagicLink(projectId, token): Promise<MagicLinkVerifyResult> // { access_token, refresh_token, ... }verifyEmailCode(projectId, { challengeId, code }): Promise<MagicLinkVerifyResult>createUser(projectId, opts: { email, isAdmin?, sendInvite?, redirectUrl?, clientState? }): Promise<AuthUserAdminResult>inviteUser(projectId, opts: { email, isAdmin?, redirectUrl, clientState? }): Promise<AuthUserAdminResult>setUserPassword(projectId, opts: { accessToken, newPassword, currentPassword? }): Promise<void>settings(projectId, opts: { allow_password_set?, preferred_sign_in_method?, public_signup?, require_passkey_for_project_admin?}): Promise<AuthSettingsResult>createPasskeyRegistrationOptions(projectId, opts: { accessToken, appOrigin }): Promise<PasskeyOptionsResult>verifyPasskeyRegistration(projectId, opts: { accessToken, challengeId, response, label? }): Promise<PasskeyRecord>createPasskeyLoginOptions(projectId, opts: { appOrigin, email? }): Promise<PasskeyOptionsResult>verifyPasskeyLogin(projectId, opts: { challengeId, response }): Promise<AuthSessionResult>listPasskeys(projectId, opts: { accessToken }): Promise<{ passkeys: PasskeyRecord[] }>deletePasskey(projectId, opts: { accessToken, passkeyId }): Promise<void>providers(projectId): Promise<AuthProvidersResult> // magic_link.deliveryModes; absent wire field → ["link"]promote(projectId, email): Promise<void>demote(projectId, email): Promise<void>
// CLI-style aliases:magicLink(projectId, opts): Promise<MagicLinkRequestResult>verify(projectId, token): Promise<MagicLinkVerifyResult>setPassword(projectId, opts): Promise<void>promoteUser(projectId, email): Promise<void>demoteUser(projectId, email): Promise<void>Magic-link tokens are single-use, expire in 15 min, rate-limited 5/email/hour. Google OAuth is on for all projects with zero config.
r.apps
Section titled “r.apps”browse(tags?: string[]): Promise<BrowseAppsResult>getApp(versionId): Promise<AppDetails>fork(opts: { versionId, name, subdomain? }): Promise<ForkAppResult>publish(projectId, opts?: { description?, tags?, visibility?, fork_allowed? }): Promise<PublishedVersion>listVersions(projectId): Promise<ListVersionsResult>updateVersion(projectId, versionId, opts: { description?, tags?, visibility?, fork_allowed? }): Promise<void>deleteVersion(projectId, versionId): Promise<void>Forking clones schema + site + functions into a new project. If the source has a bootstrap function, it runs automatically with the variables you pass; result includes bootstrap_result or bootstrap_error.
r.tier
Section titled “r.tier”set(tier: "prototype" | "hobby" | "team", opts?: { idempotencyKey? }): Promise<TierSetResult> // idempotencyKey → Idempotency-Key header (caller-supplied; not auto-derived)status(): Promise<TierStatusResult>Tier is per organization, not per project. set applies to every project on the
organization; status.pool_usage (projects, total_api_calls, total_storage_bytes,
api_calls_limit, storage_bytes_limit) sums across every non-terminal project on the
organization — including every wallet linked to it via billing.linkWallet — not
just the requesting wallet. Use the returned pool_usage as the authoritative quota-
enforcement view; per-project r.projects.getUsage(id) reports the same organization-level
caps alongside that project’s slice of the pool.
v1.57: TierStatusResult also surfaces two optional organization fields:
organization_lifecycle_state?: "active" | "past_due" | "frozen" | "dormant" | "purged"— mirror of the owning organization’s lifecycle state. Identical to the per-projectorganization_lifecycle_stateon everylist()entry.lease_perpetual?: boolean— operator escape hatch flag. Whentrue, the organization never advances pastactive.
Both are optional because older gateways do not return them at the top level.
set auto-detects subscribe / renew / upgrade / downgrade based on current state.
For tier pricing, call r.projects.getQuote() (the SDK does not expose a separate
tier.quote() method).
Quota-denial scope discriminator
Section titled “Quota-denial scope discriminator”Quota-related error envelopes carry details.scope: "organization" | "project" so consumers
can distinguish organization-pooled denials from the orphan fallback (project whose billing
organization row was purged but cascade has not yet run). The SDK lifts this onto every
Run402Error subclass as e.quotaScope, and exports a getQuotaScope(e) helper for
non-Run402Error unknown inputs. Absent for non-quota errors and for pre-v1.46
gateways.
r.billing
Section titled “r.billing”createEmailOrganization(email): Promise<EmailOrganization>linkWallet(organizationId, wallet): Promise<LinkWalletResult> // organizationId = UUID; POST /orgs/v1/:org_id/walletscreateCheckout(organizationId, checkout: { product: "balance_topup", amountUsdMicros: number } | { product: "tier", tier: "prototype" | "hobby" | "team" } | { product: "email_pack" }): Promise<CreateCheckoutResult>setAutoRecharge(opts: { organizationId: string, enabled: boolean, threshold? }): Promise<void>checkBalance(identifier): Promise<OrganizationDetail> // identifier = organization id (UUID) | wallet | emailgetOrganization(identifier): Promise<OrganizationDetail>lookupOrganization(identifier): Promise<OrganizationDetail> // resolve wallet/email → organization (incl. organization_id)balance(identifier): Promise<OrganizationDetail> // alias of checkBalancehistory(identifier, limit?: number): Promise<BillingHistoryResult>getHistory(identifier, limit?: number): Promise<BillingHistoryResult>
// CLI-style aliases:createEmail(email): Promise<EmailOrganization>autoRecharge(opts): Promise<void>Organizations are addressed by their canonical organization_id (UUID).
getOrganization / checkBalance / history accept an organization id, wallet, or email:
an organization id reads GET /orgs/v1/:org_id/billing directly, while a
wallet/email is resolved through the GET /orgs/v1/lookup?wallet=|?email=
lookup (also exposed as lookupOrganization). The detail shape includes
organization_id. Organization reads require SIWX from a
wallet linked to the organization (or matching the looked-up ?wallet), or an
admin key — email lookups are admin-only; history resolves to the organization id
first, then reads GET /orgs/v1/:org_id/billing/history.
linkWallet merges a wallet into an existing organization’s pool. The response
includes a pool_implications block (tier,
projects_in_pool_count, organization_api_calls_current, organization_storage_bytes_current,
tier_limits.{api_calls,storage_bytes}, over_limit) so callers can warn before
linking a wallet whose existing usage would push the merged pool past the tier cap.
r.contracts
Section titled “r.contracts”provisionSigner(projectId, opts: { chain: "base-mainnet" | "base-sepolia", recoveryAddress? }): Promise<ProvisionSignerResult>getSigner(projectId, signerId): Promise<SignerSummary>listSigners(projectId): Promise<ListSignersResult>setRecovery(projectId, signerId, recoveryAddress: string | null): Promise<void>setLowBalanceAlert(projectId, signerId, thresholdWei: string): Promise<void>
call(projectId, opts: { signerId, chain, contractAddress?, to?, abiFragment?, abi?, functionName?, fn?, args, value?, idempotencyKey? }): Promise<ContractCallResult>deploy(projectId, opts: { signerId, chain, bytecode, value?, idempotencyKey? }): Promise<ContractDeployResult> // bytecode = full creation calldata (creation bytecode + ABI-encoded ctor args concatenated by caller); ≤ 128 KB. Returns deterministic CREATE address synchronously in `contract_address`.read(opts: { chain, contractAddress?, to?, abiFragment?, abi?, functionName?, fn?, args }): Promise<ContractReadResult>callStatus(projectId, callId): Promise<ContractCallResult>drain(projectId, signerId, destinationAddress): Promise<DrainResult>deleteSigner(projectId, signerId): Promise<DeleteSignerResult> // refused if balance ≥ dust
// CLI-style aliases:setAlert(projectId, signerId, thresholdWei): Promise<void>status(projectId, callId): Promise<ContractCallResult>delete(projectId, signerId): Promise<DeleteSignerResult>Private keys never leave AWS KMS. $0.04/day rental + $0.000005/call. Signer creation requires $1.20 cash credit. Non-custodial. The SDK exports typed metadata and call-result envelopes (SignerSummary, ContractCallResult, ContractReadResult, etc.); contract ABI results and receipts remain unknown inside those envelopes and should be narrowed at the call site.
translate(projectId, opts: { text, to, from?, context? }): Promise<TranslateResult>moderate(projectId, text): Promise<ModerateResult>usage(projectId): Promise<AiUsageResult>generateImage(opts: { prompt, aspect? }): Promise<GenerateImageResult> // $0.03 via x402, no projectIdGenerateImageResult is { image, content_type, aspect, payment }. payment is
the settlement observed for that call, decoded from the response’s
PAYMENT-RESPONSE receipt:
payment: { success, network, transaction, payer } | nullnull means the response carried no receipt — no payment was made on this
request, NOT that one failed. Surface network to whoever is watching.
run402 init faucet-funds Base Sepolia (eip155:84532), so the documented
quickstart pays in test money; without the network a caller can watch a payment
succeed with no way to know it was not real, and the claims wall will then
refuse the very transaction they just made. Derive any “this was testnet”
message from payment.network, never from local wallet configuration — a buyer
holding mainnet funds makes a config-derived guess wrong.
The same value rides ResponseEnvelope.settlement for any request that settles,
so requestWithResponse callers get it too. The key is omitted entirely when
nothing settled, so existing envelope shapes are unchanged.
r.image is an alias of r.ai, so CLI readers can translate
run402 image generate ... to r.image.generateImage(...).
r.allowance
Section titled “r.allowance”status(): Promise<AllowanceStatusResult>create(): Promise<AllowanceCreateResult>export(): Promise<string> // address only, never the private keyfaucet(address?: string): Promise<FaucetResult>faucet defaults to the local allowance’s address when no argument is passed.
The Node entry’s credentials provider also writes a lastFaucet marker after
success — surfaced via status().faucet_used.
r.vouchers
Section titled “r.vouchers”redeem(code: string): Promise<RedeemVoucherResult>Redeems a promo code (e.g. R402-K8F3-Q2W9) into the authenticated wallet’s
organization as prepaid credit. That credit settles tier purchases and priced
calls through the allowance rail — no on-chain payment.
- Order-independent. Works as the very first authenticated call a new wallet
makes (the organization is provisioned on demand) or long after
init. - Idempotent for the redeemer. A repeat by the same organization returns the
ORIGINAL result with
already_redeemed: trueand never credits twice, so a timed-out call is safe to re-issue. A different organization getsVOUCHER_ALREADY_REDEEMED(409). - Send the code verbatim. The gateway owns the grammar and is forgiving (case-insensitive, hyphens optional, Crockford confusables mapped); a client-side format check would only reject codes the server accepts.
- Other failures:
VOUCHER_NOT_FOUND(404 — unknown or malformed, the same answer on purpose),VOUCHER_EXPIRED(410),PROMO_LIMIT_REACHED(403).
RedeemVoucherResult carries amount_usd_micros, the post-credit
balance_usd_micros, organization_id, redeemed_at, already_redeemed,
promo_lifetime_ceiling_usd_micros, and next_actions[] (usually the tier the
new balance now covers, with a ready-to-run cli string).
r.service
Section titled “r.service”status(): Promise<ServiceStatus> // 24h/7d/30d uptime per capability — no auth, no setuphealth(): Promise<ServiceHealth> // per-dependency liveness — no auth, no setupr.wallet(address)
Section titled “r.wallet(address)”r.wallet(address).getLabel(): Promise<string | null>r.wallet(address).setLabel(label: string): Promise<{ ok: boolean }>The signed server-side wallet label (gateway /wallets/v1/:address/label) that
surfaces the human-readable named-wallet name in the operator console. Use the
r.wallet(address) scope handle so the address isn’t a swappable positional. The
label is pushed automatically on run402 wallets use unless
RUN402_WALLET_LABEL_SYNC=0. (r.wallets.getLabel(address) remains valid as a
bare read. For org/grants control-plane identity, see “Org membership & project
grants” below.)
r.cache (gateway v1.52+, paired with @run402/astro v1.0+)
Section titled “r.cache (gateway v1.52+, paired with @run402/astro v1.0+)”SSR origin-cache inspection + invalidation for the Astro SSR Runtime. Capability ssr-isr-cache.
invalidate(url: string | URL): Promise<CacheInvalidateResult>invalidatePrefix({ host, prefix }): Promise<CacheInvalidateResult>invalidateAll({ host }): Promise<CacheInvalidateResult>invalidateMany(urls: Array<string | URL>): Promise<CacheInvalidateResult>inspect(url: string | URL, opts?: { locale?, releaseId? }): Promise<CacheInspectResult>CacheInvalidateResult:
interface CacheInvalidateResult { deleted: number; // post-increment per-(project, host) counter, as string for bigint safety generation: string; host: string; // populated on single-URL form path?: string; // populated on invalidateMany results?: Array<{ host: string; deleted: number; generation: string }>;}CacheInspectResult:
interface CacheInspectResult { // NEVER "BYPASS" — inspect doesn't issue a request status: "HIT" | "MISS"; url?: string; host?: string; path?: string; search?: string; method?: string; locale?: string; releaseId?: string; // ISO8601 cachedAt?: string; // ISO8601 expiresAt?: string; writtenUnderGeneration?: string; // hex contentSha256?: string; headers?: Record<string, string | string[]>;}Project-scoped. Cross-project absolute URLs throw R402_CACHE_INVALIDATION_HOST_FORBIDDEN. Path-string invalidate('/path') form requires active request context to resolve the host from ALS; outside a context, throws R402_CACHE_INVALIDATION_HOST_REQUIRED.
Inside an Astro [slug].astro admin save flow:
import { db, cache } from "@run402/functions";
declare const slug: string;declare const title: string;declare const html: string;
await db().from("pages").insert({ slug, title, html });await cache.invalidate(`/${slug}`); // sub-second freshnessFrom an admin-side function in a different host:
import { cache } from "@run402/functions";
declare const slug: string;
await cache.invalidate(new URL(`https://eagles.kychon.com/${slug}`));Tag-based invalidation deferred to v1.5. Client-side (browser) invalidation NOT in v1 — server-side function context only.
r.admin
Section titled “r.admin”Operator/admin endpoints. Most agents won’t reach for these — they’re for platform operators.
sendMessage(message: string): Promise<SendMessageResult>setAgentContact({ name, email?, webhook? }): Promise<AgentContactResult>getAgentContactStatus(): Promise<AgentContactResult>verifyAgentContactEmail(): Promise<AgentContactResult>startOperatorPasskeyEnrollment(): Promise<AgentContactResult>getProjectFinance(id: string, opts?: { window?: "24h" | "7d" | "30d" | "90d", cookie?: string}): Promise<AdminProjectFinanceResult>
// operator-only org + project actions — canonical via scope handlesr.admin.org(orgId).pinLease() / .unpinLease(): Promise<SetLeasePerpetualResult>r.admin.project(projectId).archive(opts?: { reason?: string }): Promise<ArchiveProjectResult>r.admin.project(projectId).reactivate(): Promise<ReactivateProjectResult>r.admin.project(projectId).finance(opts?): Promise<AdminProjectFinanceResult>AgentContactResult includes email_verification_status, passkey_binding_status, assurance_level, proof timestamps, and cooldown fields. Assurance labels are wallet_only, email_pending, email_verified, passkey_pending, and operator_passkey; they describe mailbox/passkey continuity, not a humanhood or uniqueness claim. startOperatorPasskeyEnrollment() requires email_verified and emails the token to the verified contact email instead of returning it.
getProjectFinance reads the internal Finance-tab JSON for a project. It is
platform-admin gated; a project service_key is not enough. In Node operator
scripts, use an admin allowance wallet or pass
cookie: process.env.RUN402_ADMIN_COOKIE for browser-session auth.
v1.57 — operator-only project + organization actions. Gateway v1.57 moved the lifecycle state machine from internal.projects to internal.organizations and dropped the per-project pin / unpin endpoints. The replacements:
r.admin.org(orgId).pinLease()/.unpinLease()— toggle the organization-level escape hatch. Whenlease_perpetualistrue, the organization never advances pastactiveregardless of lease expiry; every project on the organization is pinned. Pinning a grace-state organization (past_due/frozen/dormant) reactivates inline — the response carriesreactivated: true. This also replaces the v1.56projects.pin(id)method (removed in v2.x SDK).archiveProject(projectId, { reason? })— operator moderation. Setsprojects.archived_at = NOW()on a single project; sibling projects on the same organization keep serving. No-op when already archived (returnsnote: "already archived").r.admin.project(projectId).reactivate()— un-archive a project (flipsarchived_atback to NULL). In v1.57 this was narrowed: it does NOT touch organization-level lifecycle. To reactivate a grace-state organization, either callr.tier.set(tier)(the tier flow runs the lifecycle advance inline) orr.admin.org(org_id).pinLease().
All three require platform-admin auth. Result envelopes:
SetLeasePerpetualResult: { status, organization_id, lease_perpetual, reactivated }ArchiveProjectResult: { status, project_id, archived_at?, reason?, note? } // note: "already archived"ReactivateProjectResult: { status, project_id, reactivated?: true, note? } // note: "not archived"r.admin.channels + r.admin.rules (Telegram notification channel + routing rules)
Section titled “r.admin.channels + r.admin.rules (Telegram notification channel + routing rules)”Self-serve Telegram push on top of the v1.55 operator-notifications substrate: connect a chat, then add filter rules so ONLY matching events page that chat. Two sub-namespaces on r.admin, same shape as r.admin.transfers.
r.admin.channels.connectTelegram(opts?: { label?: string }): Promise<ConnectTelegramResult>r.admin.channels.list(): Promise<NotificationChannelsResult>r.admin.channels.revokeTelegram(bindingId: string): Promise<RevokeTelegramResult>
r.admin.rules.list(): Promise<ListRoutingRulesResult>r.admin.rules.create(input: CreateRoutingRuleInput): Promise<CreateRoutingRuleResult>r.admin.rules.update(ruleId: string, patch: UpdateRoutingRulePatch): Promise<RoutingRule>r.admin.rules.delete(ruleId: string): Promise<DeleteRoutingRuleResult>connectTelegram returns two single-use, 15-minute deep links — connect_url (private chat) and connect_group_url (group chat) — plus a pending binding id. A human taps ONE of the links and starts the bot; poll r.admin.channels.list() until the matching entry in telegram[] shows status: "active" (or code_expires_at passes and it’s swept back to "revoked"). Until the platform’s dedicated bot is provisioned on this deployment, connectTelegram throws with code: "TELEGRAM_CHANNEL_NOT_CONFIGURED" (HTTP 503, with a next_actions entry); a caller with no verified operator email yet gets code: "OPERATOR_EMAIL_NOT_VERIFIED" (HTTP 412) — bindings are addressed to the verified email, the recipient grain every rule/binding keys on. connectTelegram / revokeTelegram require operator_passkey assurance (same ladder as rotateWebhookSecret); list() is a plain SIWX read.
Routing rules (design D4). One rule always targets exactly one Telegram binding — “N destinations” is N rules. Every match dimension you set (projectId, source, eventTypes, classes) is ANDed; an OMITTED field is a wildcard (matches anything for that dimension); an explicit empty array (eventTypes: []) matches NOTHING (Postgres TEXT[] semantics — deliberately different from the “[] = unfiltered” convention some read-filter query params use elsewhere in this SDK). source is "app" (a deployed function’s events.emit(...) calls) or "platform" (deploys, lifecycle, verification, …); omit to match both. No rules = no Telegram traffic for that operator — the channel is opt-in per event, per rule, with no “send everything” default. Rules govern the Telegram channel ONLY in v1: the mandatory email floor (security/recovery/billing_critical/destructive_lifecycle/verification classes) is completely untouched and can never be silenced by a rule.
rules.update’s patch uses PATCH semantics at the wire level: a field OMITTED from the object leaves the stored value unchanged; a field explicitly set to null CLEARS that dimension back to wildcard. There’s no wire difference between “omitted” and “set to undefined” — both drop the key from the JSON body, so build the patch object by only assigning the keys you actually want to change.
rules.create/rules.update reject an unusable or foreign telegramBindingId (revoked, not yours, or nonexistent) with the SAME 404 either way (authorize-before-reveal) — call r.admin.channels.list() first to confirm the binding is "active".
admin.testNotification(opts?: { source?, eventType? }) (v1.55, extended) fires the sample event through the FULL pipeline — email/webhook AND Telegram — and its result carries telegram: { destinations: [...] }, one delivered/failed outcome per matched Telegram binding (empty when no rule matches — Faithful, not an error). Pass opts.source / opts.eventType to exercise a specific rule’s filters precisely instead of the default sample event.
r.admin.transfers (unified project transfer, owned-org recipient v1.96+)
Section titled “r.admin.transfers (unified project transfer, owned-org recipient v1.96+)”Project transfer is exposed as a sub-namespace at r.admin.transfers — one noun, three recipient shapes. A wallet recipient completes via accept (both sides sign SIWX); an email recipient completes via claim (the recipient claims into an org); an owned org recipient completes immediately at initiate time in the same-actor first release. initiate is body-discriminated (toWallet XOR toEmail XOR toOrgId); preview / cancel / listIncoming / listOutgoing are kind-agnostic for pending rows and tag each row with recipient_kind. (The pre-v1.93 *Handoff methods and /handoffs routes are gone.)
initiate({ projectId, toWallet, billingPolicy?, message?, kysignedRecordId? }) // wallet recipient : Promise<InitiateTransferResult> // { transfer_id, expires_at, project_summary, your_unused_lease_days, lease_refundable: false, terms_sha256 }initiate({ projectId, toEmail, message?, retainCollaborator? }) // email recipient : Promise<InitiateEmailTransferResult> // { status: "ok", transfer_id, to_email, expires_at }initiate({ projectId, toOrgId, message? }) // owned-org recipient, same-actor only : Promise<InitiateOrgTransferResult> // { status: "accepted", project_id, to_organization_id, transfer_id?, completed_at?, anon_key, service_key, ... } // initiate({ toOrgId }) persists returned keys via saveProject + setActiveProject when supported.// Exactly one of toWallet / toEmail / toOrgId — multiple-or-none throws a local VALIDATION_ERROR before any request.// billingPolicy + kysignedRecordId are wallet-only; retainCollaborator is email-only.preview(transferId: string): Promise<ProjectTransferPreview> // { transfer_id, project_id, status, recipient_kind, from_wallet_display, to_wallet_display, to_email?, to_org_id?, // billing_policy, message, initiated_at, expires_at, terms_sha256, custom_domains[], subdomains[], // functions[], secret_names[] (NEVER values), mailbox_summary, ci_bindings_to_be_revoked[], signers[], // github_repo_note, billing_implications, retain_collaborator? }accept(transferId: string): Promise<AcceptTransferResult> // WALLET completion // { project_id, from_wallet, to_wallet, new_organization_id, completed_at, // secrets_rotation_advised: true, secret_names_inherited[], secrets_count_inherited, github_repo_note, // anon_key, service_key } // #428: new owner's project keys. accept() persists them via // saveProject + setActiveProject (when the provider supports them), mirroring provision.claim(transferId, { organizationId?, acceptRetainedCollaborator? }): Promise<ClaimTransferResult> // EMAIL completion // { status: "accepted", project_id, to_organization_id, created_new_org, retained_collaborator_principal_id, // anon_key, service_key } // project-transfer-claim-credentials: symmetric with accept. claim() persists // the keys via saveProject + setActiveProject (when the provider supports them). Claim auth is principal-based // (control-plane session OR verified-email SIWX) — don't assume a wallet is present.cancel(transferId: string, reason?: string): Promise<CancelTransferResult> // kind-agnostic // { transfer_id, status: "cancelled", cancelled_by, cancellation_reason, cancelled_at }listIncoming(opts?: { limit?, offset? }): Promise<TransferSummary[]> // pending rows, unioned (recipient_kind-tagged)listOutgoing(opts?: { limit?, offset? }): Promise<TransferSummary[]> // pending rows, unionedbillingPolicy defaults to "migrate" on wallet transfers (the only Phase 1A policy — the project moves into the recipient’s organization). The kysignedRecordId field is wallet-only and stored verbatim in Phase 1A; Phase 1B will verify it against the canonical terms hash. Owned-org toOrgId moves are same-actor only in the first gateway release: caller must be an active owner of both source and destination orgs. Initiate authority is owner-OR-admin.
Email recipient — retain-collaborator (v1.91). Pass retainCollaborator: { role: "developer" } on the email initiate to keep a developer membership in the recipient’s org after the transfer (only developer is valid; the subject is always the initiating owner — gateway rejects with INVALID_RETAIN_ROLE / RETAIN_SUBJECT_REQUIRED). The recipient sees the offer as ProjectTransferPreview.retain_collaborator (a RetainCollaboratorPreview { principal_id, role, sender_label, scope, note, accept_field }, or null) and accepts by passing acceptRetainedCollaborator: true to claim; the result then carries retained_collaborator_principal_id (or null). Omitting the accept (the default) is a full severance.
While a transfer is pending (72h TTL), every owner-side mutation against the project throws TransferFreezeError (status 409, code PROJECT_HAS_PENDING_TRANSFER). The error carries transferId, projectId, cancelPath, and previewPath lifted from the gateway’s next_actions[], so agents can present an actionable resolution:
import { run402 } from "@run402/sdk/node";import { isTransferFreezeError } from "@run402/sdk";
const r = run402();
try { const p = await r.project(projectId); await p.apply({ secrets: { require: ["DB_URL"] } });} catch (err) { if (isTransferFreezeError(err) && err.transferId) { // err.transferId, err.cancelPath, err.previewPath await r.admin.transfers.cancel(err.transferId); // …retry the mutation here } else { throw err; }}Data-plane traffic (/rest/v1/*, /storage/v1/*, function invocation, mailbox send/receive) keeps serving during the freeze. Payment-path routes (tier.set, /orgs/v1/:org_id/checkouts, /orgs/v1/:org_id/billing/auto-recharge) keep working. r.admin.transfers.cancel is intentionally not blocked.
After accept, the project carries a persistent secrets_rotation_advised advisory — visible on r.tier.status() as projects[].secrets_rotation_advised: { advised_at, reason }. Use r.secrets.set(...) to rotate every name in secret_names_inherited; the advisory clears once every previously-inherited name has been re-written.
r.tier.status() also surfaces incoming_transfers[] at the top level (each entry includes preview_path and the full pending summary) so a single status call shows pending offers without a separate listIncoming fetch.
What does NOT transfer: tier lease (stays with the original owner’s organization; no Phase 1A proration), KMS signers (r.contracts.* — wallet-scoped), GitHub repo ownership (handle out of band), on-chain balance on any wallet.
Org membership & project grants (r.orgs, r.org(id), r.grants — v1.77+ org-owned control plane; first-class orgs v1.82)
Section titled “Org membership & project grants (r.orgs, r.org(id), r.grants — v1.77+ org-owned control plane; first-class orgs v1.82)”A wallet authenticates (SIWX → a control-plane principal); an org owns projects, and what a principal may do is decided by its org membership role (owner > admin > developer > billing > viewer) or a per-project grant — never wallet_address == signer. The collection + identity lives on r.orgs; per-org operations on the scoped sub-client r.org(id) (the org analog of r.project(id) — the id is bound once). Memberships carry org_id + display_name.
r.orgs.create({ displayName? })→{ org_id, display_name, tier, lease_started_at, lease_expires_at }(POST/orgs/v1). Creates an empty org on the prototype tier; you become owner. Accepts onlydisplayName— no tier input. Step-up gated; may throwApiError code: "FREE_ORG_OWNER_LIMIT_EXCEEDED"(429).r.orgs.list()→ orgs you are an active member of (OrgMembership[], each{ org_id, display_name, role, status }).r.orgs.whoami()→{ principal, memberships[], authenticator_id }(GET/agent/v1/whoami). The REMOTE, gateway-resolved identity — distinct fromr.whoami()(local + network-free wallet/profile label, used byrun402 status).r.org(id).get()→{ 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.r.org(id).rename(displayName | null)→{ org_id, display_name, tier, lease_started_at, lease_expires_at }. Owner-only; set or clear the label (null/""clears). Step-up gated.r.org(id).claimSlug(slug, { idempotencyKey? })→{ org_id, slug, previous_slug, created }(POST/orgs/v1/:org_id/slug, repo-first-onramp design D6). Owner-only. A genesis claim debits a small one-time claim fee; a rename is free but releases the OLD slug into a ~90-day cooldown (typedSLUG_RELEASEDrefusal thereafter, naming this org’s new slug as successor). A paid, side-effecting mutation — requiresIdempotency-Key; the SDK generates a fresh one per call whenidempotencyKeyis omitted, so a retried call after a dropped response cannot double-bill.OrgSummary/OrgDetailgain an additiveslug: string | nullfield.r.org(id).members.list()/.add({ wallet, role? })/.setRole(principalId, { role })/.revoke(principalId)— owner-gated; a new wallet is provisioned as ahumanprincipal,roledefaults todeveloper. Removing/demoting the org’s only active owner throwsApiError code: "LAST_OWNER"(409).r.org(id).invites.list()/.create({ email, role, inviteTtlHours? })/.revoke(principalId)— email invites, claimed automatically at the invitee’s first login.r.org(id).audit({ limit?, before? })→ control-plane audit trail (admin+), newest-first; page withbefore.r.grants.create(projectId, { wallet, capability, policy?, expiresAt? })/r.grants.revoke(projectId, grantId)— per-project capability grants for agent/CI principals; requires owner of the project’s org. Also project-scoped:r.project(id).grants.create({...})/.revoke(grantId).capabilityexamples:"deploy","functions:write".
Control-plane denials throw NotAuthorizedError (403 NOT_AUTHORIZED, carrying requiredRole / requiredCapability / reason). Bad input is ApiError code: "VALIDATION_ERROR" (400). Exported types: OrgRole, Principal, OrgMembership, OrgMember, WhoAmIResult, OrgSummary, OrgDetail, CreateOrgInput, ProjectGrant, plus input/result types.
Resource limits
Section titled “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 |
Project rate limit: 100 req/sec — exceeding throws ApiError with status 429 and retry_after in the body.
Idempotent migrations
Section titled “Idempotent migrations”CREATE TABLE IF NOT EXISTS only handles “already exists” — it won’t add new columns. 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 $$;Safe to re-run on every deploy.
SQL guardrails
Section titled “SQL guardrails”The SQL endpoint blocks: CREATE EXTENSION, COPY ... PROGRAM, ALTER SYSTEM, SET search_path, CREATE/DROP SCHEMA, GRANT/REVOKE, CREATE/DROP ROLE. Use the expose manifest for access control.
Stability
Section titled “Stability”This package is on the 3.x line. The in-repo packages (@run402/sdk, run402, and run402-mcp) release in lockstep at the same version. Pin an exact version in production dependencies:
{ "dependencies": { "@run402/sdk": "3.7.5" } }OpenClaw skill packaging follows the CLI release train. @run402/functions and @run402/astro publish on their own cadences.
Patterns & gotchas
Section titled “Patterns & gotchas”- Provision before authoring HTML. The
anon_keyis permanent and must be embedded in your frontend; provision first, then write the HTML. - Use the manifest for access control, never raw
GRANT/REVOKE. user_owns_rowsis the default for user-scoped data. Reach forpublic_read_write_UNRESTRICTEDonly on intentionally-public tables.- Use immutable
cdnUrlfromr.assets.put. It’s correct from the moment of upload — nowaitFreshneeded. - Don’t bake unconditional
r.allowance.faucet()into deploy scripts — the faucet rate-limits and breaks already-funded flows. - Per-project rate limit is 100 req/sec. On 429, back off using
retry_after. r.service.status()works without auth. Use it before evaluating Run402, or to distinguish platform issues from your own bugs.
See also
Section titled “See also”- Wayfinder: https://run402.com/llms.txt
- CLI reference: https://docs.run402.com/llms-cli.txt
- MCP reference: https://docs.run402.com/llms-mcp.txt
- HTTP API reference: https://run402.com/llms-full.txt
- npm: https://www.npmjs.com/package/@run402/sdk
- Source: https://github.com/kychee-com/run402