Skip to content

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.

Terminal window
npm install @run402/sdk

Two 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.

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.

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.

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.

Authentication and payment are separate authorities. A custom credentials provider controls API authentication; the x402 payer is resolved exactly once in this order:

  1. paymentSigner — an explicit async EVM signer provider (KMS/HSM friendly).
  2. allowancePath — an explicit local allowance file.
  3. credentials.readAllowance() — when a supplied provider implements it.
  4. 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.

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:

  1. Send the intended request with a stable Idempotency-Key.
  2. On 402, base64url-decode PAYMENT-REQUIRED, verify its exact scheme, network, asset, atomic amount, recipient, and your local spend ceiling.
  3. Sign one x402 payload and retry the same request with that base64url JSON in PAYMENT-SIGNATURE (X-PAYMENT for v1). Do not follow redirects with a payment proof.
  4. On success, base64url-decode PAYMENT-RESPONSE and require success: true, a non-empty transaction, and the expected network before reporting funds moved.
  5. 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.

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"],
},
});
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).

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 Run402Error on 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.

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, then app.json under dir / cwd; explicit manifest wins.
  • 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 manifest project_id, then approved project creation from name, then approved active-project fallback.
  • For app manifests with verify.http[], fetch verification URLs after apply and write per-check details to result.app_result.verification.http[]. Fresh edge sentinel misses (x-run402-edge or JSON codes such as SUBDOMAIN_NOT_CONFIGURED) and non-settled deploy-resolve diagnostics become propagation_pending instead of permanent failures while the binding is fresh.
  • Set propagationBudgetSeconds to control the wait for edge convergence (default 120). Set propagationWait: false to return status: "propagation_pending" immediately with verify.status, propagation_wait_ms, warnings, next_action, and diagnostic edge_propagation / resolve payloads.
  • Set verifyOnly: true to rerun app HTTP verification without upload, deploy, resource mutation, or project creation. This is the SDK equivalent of run402 up verify.
  • name is only project creation/link metadata. It is not a manifest field and never renames an existing project.
  • Write .run402/project.json atomically when up needs 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 because up ran.
  • 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 normalized ReleaseSpec in result.spec; CLI prints only that JSON.
  • mode: "plan" creates a gateway-reviewed non-deploying plan. It returns result.plan.plan_id, plan_fingerprint, plan_expires_at, warnings, diff, and same-surface next_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/--yes to "yes" and TTY prompts to interactive approval. If allowance/tier/project/link are already configured, r.up() can run the requested deploy without approval.
  • autoPrerequisites defaults to true for up and false for direct actions.
  • idempotencyKey supplies 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 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 has manifestPath, rootDir, and env. Use env.get("NAME"), env.required("NAME"), or env.RUN402_* property reads; executable manifest loads report config.env_accessed metadata 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? }) derives id from 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 with name MUST be idempotent because it re-runs whenever content changes against a database where prior versions may already exist.
  • nodeFunction(path, opts) creates a Node 22 FunctionSpec from built JavaScript. TypeScript function sources (.ts, .tsx, .mts, .cts) currently fail locally with TYPESCRIPT_FUNCTION_REQUIRES_BUNDLE; build them first and point at .js.

Executable trust policy:

  • loadDeployManifest("run402.deploy.ts") can load .ts/.mts/.cts/.js/.mjs/.cjs configs only when the path is explicit.
  • Auto-discovery for up checks only data manifests: run402.deploy.json, then app.json.
  • If a repo only contains run402.deploy.ts, up fails with EXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST and 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).

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, every DeployEvent variant’s discriminator (type, plus per-variant fields like releaseId, 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.

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).

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 only
await r.credentials.rotate(projectId, cred.credential_id); // replace in one tx; new secret once
await 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 project
const keys = await r.projects.keys(projectId);
const info = await r.projects.info(projectId);

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 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.

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 any Run402Error subclass 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) — narrow unknown to the named subclass. isPaymentAttemptError is the automatic x402 failure guard; branch on safeToRetry before doing anything. isUnauthorized (authentication missing/invalid) and isNotAuthorized (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 sets retryable: false. safeToRetry alone 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. Returns false for non-Run402 inputs so it’s safe to call from any catch block.
  • 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, and undefined for non-quota errors or pre-v1.46 gateways. Safe to call with any unknown; reads Run402Error.quotaScope, which is lifted from details.scope on the gateway envelope.
  • isCiBindingRevoked(e) — true for the CI token-exchange binding_revoked denial (HTTP 403): a subject-matching binding existed but was revoked (most often the project was transferred/handed off). Distinct from access_denied (no binding ever matched), which shares the same canonical code: "FORBIDDEN" — the guard reads the OAuth-style error field for you. The error stays an Unauthorized (no regression). Fix: re-run run402 ci link github; do NOT set-asset-scopes (409 on a revoked binding). Safe to call with any unknown.

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 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.

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>" ETag
logo.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 Uint8Array
await (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 immutable

Don’t call waitFresh on immutable URLs — they’re correct from upload time.

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); // null
console.log(debugPreview.operation_id); // null

Release 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.

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_paths is the browser reachability table and is separate from release asset paths: { mode: "explicit", replace: { "/events": { asset: "events.html", cache_class: "html" } } } serves public /events from release asset events.html; in explicit mode /events.html is not public unless separately declared. { mode: "implicit" } restores filename-derived public reachability and can widen access. Public-path-only site specs are deployable. Known static cache_class inputs are html, immutable_versioned, and revalidating_asset; preserve unknown strings returned by observability APIs. functions has the same replace/patch split. Secrets are declaration-only: use r.project(id).secrets.set(key, { value }) for values, then secrets.require[] to assert keys exist and secrets.delete[] to remove keys at activation. subdomains supports exactly one mode per spec: set replaces the release’s managed subdomain list, add appends without removing existing entries, and remove deletes named entries. Today subdomains.set accepts at most one subdomain per project; multi-subdomain set is rejected locally with SUBDOMAIN_MULTI_NOT_SUPPORTED. Top-level absence = leave untouched.
  • FunctionSpec. functions.replace is the complete desired function map; functions.patch.set updates only listed functions, and functions.patch.delete removes listed names. Each function accepts runtime?: "node22", exactly one code source (source for a single bundled module, or files plus entrypoint for multi-file functions), optional config.timeoutSeconds, optional config.memoryMb, optional required-id triggers[], and the auth-gate fields requireAuth and requireRole. Schedule triggers use { id, type: "schedule", cron, run }; run contains at least event_type and may include payload, retry, and expires_after_seconds. Each scheduled tick creates a durable function run. Email triggers use { id, type: "email", mailbox, events, run }, where mailbox is a mailbox slug/id and events is any of reply_received, delivery, bounced, complained, mailbox_suspended; each matching email event creates a durable function run with the canonical event payload under payload.event. A mailbox_suspended trigger fires when the mailbox is abuse-suspended (payload event carries suspended_reason, suspended_at, evidence, recovery_actions) 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, prefer scheduleTrigger(...) and emailTrigger(...). FunctionSpec also accepts deps?: string[] (npm specs) under capability apply-v1-function-deps; the gateway installs and bundles them. r.functions.deploy(..., { deps }) builds a one-function functions.patch.set and rides this same unified-apply path.
  • Function-level auth gates. Each FunctionSpec carries two optional declarative fields enforced by the gateway before invocation:
    • requireAuth?: boolean — when true, gateway rejects callers without a valid project user JWT with Run402DeployError-shaped envelope or 401 at request time. No DB lookup. Independent from requireRole.
    • requireRole?: RequireRoleSpec | null — gateway resolves the caller’s role from the project-schema table and rejects callers whose role is not in allowed with 403. Implies authentication (no JWT → 401). Pass null in patch mode to remove an existing gate. RequireRoleSpec is { table: string; idColumn: string; roleColumn: string; allowed: string[]; cacheTtl?: number }. All identifiers are unqualified; cacheTtl is seconds (default 60, max 600, 0 disables caching for instant-revocation paths). When a gate passes, the gateway injects x-run402-user-id (always when any gate ran) and x-run402-user-role (only when requireRole ran) into the request. In-function code reads them directly from req.headers.get("x-run402-user-id") / req.headers.get("x-run402-user-role"). The legacy getUserId(req) / getRole(req) bare exports were retired in @run402/functions v3.0 — they now throw R402_AUTH_UNKNOWN_EXPORT. For the canonical cookie-session flow, use the auth.* namespace (see below). All requireRole blocks in a single release must share the same (table, idColumn, roleColumn) triple; mixed-table specs are rejected at plan time with Run402DeployError.code === "INVALID_SPEC". The SDK does not validate gate shape — the gateway is authoritative. Missing table or column at activation throws Run402DeployError.code === "DEPLOY_INVALID_ROLE_GATE" (HTTP 422) before flipping the live release.
  • Routes. ReleaseRoutesSpec is undefined | null | { replace: RouteSpec[] }. Omitted and null carry forward base routes; { replace: [] } clears dynamic routes; { replace: [...] } replaces the route table. RouteSpec is 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. Prefer site.public_paths for clean static URLs e.g. /events -> events.html; use static routes for cases like static GET /login plus function POST /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. methods omitted means all supported methods for function routes; methods: [] is invalid. Supported methods are exported as ROUTE_HTTP_METHODS and RouteHttpMethod (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS). Function target names are materialized release function names.
  • Strict validation is SDK-owned. apply.plan/start/apply reject unknown raw ReleaseSpec fields before normalization can drop them; only top-level $schema metadata 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 are Run402DeployError.code === "INVALID_SPEC" before any hash/upload/plan request. Use loadDeployManifest / normalizeDeployManifest for CLI/MCP JSON that legitimately uses project_id, { path }, base64 file entries, or migration sql_path.
  • No-op specs fail locally. A spec with only project / base, or empty containers e.g. site.replace: {}, functions.patch.delete: [], secrets.require: [], or subdomains.set: [], throws Run402DeployError.code === "MANIFEST_EMPTY" before any network call. Delete-only patches with non-empty delete arrays 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/apply check literal function config.timeoutSeconds, config.memoryMb, schedule-trigger cron minimum interval, and scheduled-trigger count when computable. Failures are Run402DeployError.code === "BAD_FIELD" with details.field, details.value, details.tier, tier_max or min_interval_minutes, and details.limit_source (tier_status or local_static_fallback). Current caps: prototype 10s / 128 MB / 1 scheduled trigger / 15 min, hobby 30s / 256 MB / 3 / 5 min, team 60s / 512 MB / 10 / 1 min. tier.status() exposes function_limits / limits.functions when returned, including current scheduled usage.
  • Plan warnings are structured. PlanResponse.warnings and DeployResult.warnings are WarningEntry[]; apply() emits plan.warnings and aborts before upload/commit when a warning requires confirmation unless broad allowWarnings is set or every blocking warning code is listed in allowWarningCodes. MISSING_REQUIRED_SECRET means set the affected keys with r.secrets.set, then retry. For WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS, prefer route-level acknowledge_readonly: true on 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): DeploySummary is exported from @run402/sdk and @run402/sdk/node. It makes no gateway calls and summarizes only current reliable DeployResult.diff / DeployResult.warnings data: 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 retries BASE_RELEASE_CONFLICT only when the gateway marks the error safe_to_retry: true. Pinned base.release_id and { release: "empty" } stay caller-owned. Static activation/spec failures inside activation_pending throw immediately with gateway metadata preserved instead of polling until timeout. Pass maxRetries: 0 to opt out.
  • Plan envelopes are normalized. New gateways return kind: "plan_response" with is_noop, summary, warnings, expected_events, and resource buckets at top level. The SDK preserves those fields and also folds them into PlanResponse.diff for compatibility. Dry-run plans return plan_id: null and operation_id: null and cannot be uploaded or committed.
  • Modern plan/diff types are split. Deploy plans may expose PlanDiffEnvelope with 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 by DeployDiff for 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 ContentRef objects. When the spec exceeds 5 MB JSON, the SDK uploads the manifest itself as a CAS object and references it (manifest_ref escape 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.html is a release asset and /events is the public static URL declared by site.public_paths. In explicit mode, /events.html is 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 /admin and /admin/* for a dynamic section root. /admin and /admin/ are trailing-slash equivalents for exact matching.
  • Prefer site.public_paths for 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.file is 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.html directly 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 /login route can coexist with static GET /login HTML. Unsafe method mismatch returns 405, 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.method and full public req.url on managed subdomains, deployment hosts, and verified custom domains. The raw run402.routed_http.v1 envelope is internal; do not write browser route handlers against it. Direct /functions/v1/:name remains 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-store plus x-run402-cache: dynamic-bypass when 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.html root mapping — and SPA-fallback derivation is independent of the route table. So GET / serves home.html (match route_static_alias), unmatched app routes e.g. /dashboard still serve the index.html shell (match spa_fallback), and named static pages keep serving unchanged (match static_exact).
  • Root placement of home.html keeps its relative asset URLs resolving identically to the direct file and avoids the STATIC_ALIAS_RELATIVE_ASSET_RISK warning.
  • 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) and STATIC_ALIAS_DUPLICATE_CANONICAL_URL (info — /home.html stays directly reachable in implicit public-path mode; add <link rel="canonical" href="https://<your-site>/"> to home.html if duplicate-content SEO matters).
  • Omitting routes on later deploys carries the alias forward (informational ROUTE_TARGET_CARRIED_FORWARD); a pipeline that sends routes.replace must include the alias every time because replace is total.
  • Verify with p.apply.resolve({ url: "https://<your-site>/", method: "GET" }) (below) and confirm match: "route_static_alias" with target_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).

  • r.sites.deployDir(...) — Node-only thin wrapper that uses fileSetFromDir(dir), delegates to apply, and emits unified DeployEvent shapes.

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-exchange without 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 apikey headers
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 trigger
LANGUAGE 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.

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() returns Actor | null; auth.requireUser() returns Actor and throws (303 redirect for HTML / 401 envelope for JSON, decided by the gateway from the Accept header). Actor has id, 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 from auth.requireUser() — the platform decides response shape. Bare getUser / getUserId / getRole / getSession / currentUser / getCurrentUser / getServerSession exports were retired in @run402/functions v3.0 — they throw R402_AUTH_UNKNOWN_EXPORT at runtime AND fail run402 doctor source scan at deploy.
  • For per-user gating in functions OUTSIDE the cookie-session flow (a requireAuth / requireRole deploy-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 inbound x-run402-* headers before injection, so the values are trustworthy. Returns null when no corresponding gate ran (function has no gate, only requireAuth declared without requireRole, or local-invoke outside the gateway).
  • getRoutedPaymentContext(req) (@run402/functions 3.7+) — confirmed x402 payment context for priced routed function requests. Returns { scheme, paymentId, amountUsdMicros, payer, network, asset, payTo, transaction, settledAt } or null for unpriced/direct/malformed calls. Use payment.paymentId for app-side idempotency.
  • ai.generateImage({ prompt, aspect? }) — project-billed runtime image generation for deployed functions. aspect is "square" | "landscape" | "portrait"; result is { image, content_type, aspect } with base64 image bytes. Uses RUN402_SERVICE_KEY against /ai/v1/generate-image, not the wallet/x402 /generate-image/v1 endpoint. 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. Uses RUN402_SERVICE_KEY, shares the deploy-time CAS/activation substrate, and returns an SDK-compatible AssetRef.

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.

The Run402 class exposes focused namespaces. Click into the SDK source for full method signatures.

Reference tables below use plain fences, not ts fences. They document the type surface in compact form — they are not runnable programs. Runnable example snippets in this document still use ```ts and are type-checked by CI against the published @run402/sdk and @run402/sdk/node types.

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.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.

provision(opts?: { tier?, name?, orgId?, idempotencyKey? }): Promise<ProvisionResult> // idempotencyKey → Idempotency-Key header; retry-safe re-runs
delete(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 getUsage
schema(id): Promise<SchemaReport> // alias of getSchema
quote(): Promise<QuoteResult> // alias of getQuote
promoteUser(id, email): Promise<void> // project-admin role helper
demoteUser(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; null only for orphan wallets with no organization row.
  • lease_perpetual: boolean | null — operator escape hatch flag. When true, the organization never advances past active regardless 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 rides next_actions[]: register and verify an operator contact via POST /agent/v1/contact (r.admin.setAgentContact). Reachability is also machine-checkable on r.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.

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): DeploySummary

Example:

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.

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.*.

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): NormalizedCiDelegationValues
buildCiDelegationStatement(values): string
buildCiDelegationResourceUri(values): string
validateCiSubjectMatch(subject): string
validateCiNonce(nonce): string
normalizeCiRouteScopes(values): string[]
validateCiRouteScope(value): string
assertCiDeployableSpec(specOrPlanBody): void

Node-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): boolean

CI 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.

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 send
operator.session.verifyEmail({ token }): Promise<ControlPlaneSession> // verifies email, AUTO-CLAIMS invites, mints (amr ["email"])
operator.session.passkeyOptions({ email }) / passkeyVerify({ email, response }) // WebAuthn login → session
operator.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 write
operator.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 + memberships

Invite → 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.

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>

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? }): LocalDirRef

source 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).

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 SDK accepts three input shapes for the assets slice but the gateway sees only one wire shape:

  1. LocalDirRef — returned by dir(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 a LocalDirRef — submitting one in a JSON body is rejected with HTTP 400 INVALID_WIRE_SCHEMA. The SDK normalizes via entriesFromLocalDir(ref) before any plan request.
  2. AssetPutEntry[] — wire-shaped ({ key, sha256, size_bytes, content_type, visibility, immutable }). What the gateway sees.
  3. In-memory ContentSource — accepted by putMany; hashed locally and converted to AssetPutEntry before submission.

This is enforced by the wire-schema validator and verified by three-schema fidelity tests under sdk/src/.

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/.

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.

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 versions
invoke(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 result
logs(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>): string

Durable 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).

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.

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.

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.

The ProjectDomain lifecycle — the ONE surface for custom domains (web + email).

ensure(projectId, domain, { desired }): Promise<ProjectDomain> // connect / update desired state
get(projectId, domain): Promise<ProjectDomain>
list(projectId): Promise<{ domains: ProjectDomain[] }>
check(projectId, domain): Promise<ProjectDomain> // refresh observations
apply(projectId, domain): Promise<ProjectDomain> // apply records Run402 has authority over
repair(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 owner
await 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).

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 fact

An 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.

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 error
listPresences(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: true
listMessages(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 read
getMessage(orgId, roomKey, messageId) // FULL body (lists carry snippets) + ack state
ackMessage(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 anything
listClaims(orgId, roomKey, { includeInactive? })
releaseClaim(orgId, roomKey, claimId) // holder only; idempotent
list(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 listed
get(orgId, roomKey) // who is here + last activity, WITHOUT joining
// an unused key reads as empty (live_presences 0), never 404
leave(orgId, roomKey, presenceId) // give up a seat YOUR credential holds; { left } is truthful
// scoped to your PRINCIPAL (another principal's presence is never touched); idempotent
scoped(orgId, roomKey): ScopedRoom // sync — same methods with the room pre-bound
forProject(projectId): Promise<ScopedRoom> // resolves the project's org via its overview;
// the default room's key IS the project id

Leave 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.

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 member
ack(orgId, escalationId) // first writer wins; replay reports the ORIGINAL
resolve(orgId, escalationId, note?)
ackWithToken(token) // the hosted one-tap page's call
raiseAndWait(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-up
removeContact(orgId, contactId) // OWNER + passkey step-up

Contacts 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.

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 included
get(routeId) // + honest health (route + credential state,
// never queue emptiness), delivery_counts,
// consumer_cursor
update(routeId, patch, expectedRevision) // stale revision → 409 BUZZ_ROUTE_REVISION_STALE
// without mutating; re-read, re-send
pause(routeId, idempotencyKey?) // stop matching NEW events; nothing retroactive
resume(routeId, idempotencyKey?) // re-arm + reset the hard-failure counter;
// needs a live signing credential NOW
rotate(routeId, idempotencyKey?) // STAGES the next signing generation; the swap
// activates only after the next pubkey's own
// Buzz-side membership verifies
revoke(routeId, idempotencyKey?) // cancel queued deliveries; sanitized history
// stays readable; notification_credential_destroyed
// only on the installation's LAST live route
test(routeId, idempotencyKey?) // 202 queued-not-delivered; doubles as the
// authorization poll on a pending route
deliveries(routeId, { limit?, cursor?, deliveryId? })
// keyset newest-first, dead letters included, the signed envelope never
testAndWait(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).

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:

  1. Run402 cannot decrypt your gitvault or repository history. Deployment artifacts remain a disclosed plaintext custody boundary. Cryptographic, against Run402 itself: source payload and repository-history content are ciphertext-only; the substrate retains only enumerated plaintext metadata and holds zero vault keys.
  2. Activation requires vault admission by default; an explicit, audited override can bypass it. An operational platform invariant, not a cryptographic one.
  3. 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 state
forProject(projectId): Promise<GitvaultVaultRecord> // cold-restart lookup — resolve repo_id with no local state
forRepo({ 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 create
heads(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 shape
access(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 receipt
openOrCreate({ 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_id
resolveOrCreateAddress({ 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 construction
compact(opts?): Promise<GitvaultCompactResult>
prune(opts?): Promise<GitvaultPruneResult> // plan; pass { submit } with both verifier receipts to submit
verify(opts?: { persist? }): Promise<GitvaultVerifiedState> // persist defaults true; false walks + verifies the same way but writes neither local pin
fsck(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 in
deploy(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 caller
scaffoldRemote({ 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 way
open(opts?): Promise<GitvaultHandle> // the raw protocol object, for ref transactions or repair
drainOverrides(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 place
gitvaultLossWarningTrip(record, now?): { generations, source_bytes, days_since_genesis } // which composite metric(s) crossed their threshold
gitvaultLossWarningTripped(trip): boolean // any of the three
gitvaultLossWarningMessage(trip): string // names what tripped; states the second-principal resolution honestly

There 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 needed
const 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.

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.

createMailbox(projectId, slug): Promise<CreateMailboxResult> // NOT idempotent
listMailboxes(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.

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.

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.

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-project organization_lifecycle_state on every list() entry.
  • lease_perpetual?: boolean — operator escape hatch flag. When true, the organization never advances past active.

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-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.

createEmailOrganization(email): Promise<EmailOrganization>
linkWallet(organizationId, wallet): Promise<LinkWalletResult> // organizationId = UUID; POST /orgs/v1/:org_id/wallets
createCheckout(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 | email
getOrganization(identifier): Promise<OrganizationDetail>
lookupOrganization(identifier): Promise<OrganizationDetail> // resolve wallet/email → organization (incl. organization_id)
balance(identifier): Promise<OrganizationDetail> // alias of checkBalance
history(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.

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 projectId

GenerateImageResult 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 } | null

null 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(...).

status(): Promise<AllowanceStatusResult>
create(): Promise<AllowanceCreateResult>
export(): Promise<string> // address only, never the private key
faucet(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.

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: true and never credits twice, so a timed-out call is safe to re-issue. A different organization gets VOUCHER_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).

status(): Promise<ServiceStatus> // 24h/7d/30d uptime per capability — no auth, no setup
health(): Promise<ServiceHealth> // per-dependency liveness — no auth, no setup
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 freshness

From 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.

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 handles
r.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. When lease_perpetual is true, the organization never advances past active regardless of lease expiry; every project on the organization is pinned. Pinning a grace-state organization (past_due / frozen / dormant) reactivates inline — the response carries reactivated: true. This also replaces the v1.56 projects.pin(id) method (removed in v2.x SDK).
  • archiveProject(projectId, { reason? }) — operator moderation. Sets projects.archived_at = NOW() on a single project; sibling projects on the same organization keep serving. No-op when already archived (returns note: "already archived").
  • r.admin.project(projectId).reactivate() — un-archive a project (flips archived_at back to NULL). In v1.57 this was narrowed: it does NOT touch organization-level lifecycle. To reactivate a grace-state organization, either call r.tier.set(tier) (the tier flow runs the lifecycle advance inline) or r.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, unioned

billingPolicy 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 only displayName — no tier input. Step-up gated; may throw ApiError 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 from r.whoami() (local + network-free wallet/profile label, used by run402 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 (typed SLUG_RELEASED refusal thereafter, naming this org’s new slug as successor). A paid, side-effecting mutation — requires Idempotency-Key; the SDK generates a fresh one per call when idempotencyKey is omitted, so a retried call after a dropped response cannot double-bill. OrgSummary/OrgDetail gain an additive slug: string | null field.
  • r.org(id).members.list() / .add({ wallet, role? }) / .setRole(principalId, { role }) / .revoke(principalId) — owner-gated; a new wallet is provisioned as a human principal, role defaults to developer. Removing/demoting the org’s only active owner throws ApiError 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 with before.
  • 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). capability examples: "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.

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.

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.

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.

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.

  • Provision before authoring HTML. The anon_key is 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_rows is the default for user-scoped data. Reach for public_read_write_UNRESTRICTED only on intentionally-public tables.
  • Use immutable cdnUrl from r.assets.put. It’s correct from the moment of upload — no waitFresh needed.
  • 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.