@run402/sdk
v4.74.0
Published
Typed TypeScript client for the Run402 API. Kernel shared by the run402-mcp server, the run402 CLI, and user-deployed run402 functions.
Readme
@run402/sdk
Typed TypeScript client for the Run402 API. The kernel shared by run402-mcp, the run402 CLI, and (eventually) user-deployed functions. Most operations are project-scoped — bind once with r.project(id) and call .apply() for atomic mixed writes, .assets.put() for blob uploads, .functions.deploy(), etc.
Run402 callers are first-class principals, whether they are people or agents. The SDK preserves the acting principal and authenticator separately from authority: organization roles, grants, delegates, freshness, and spend policy determine which operations are allowed. An agent should act as itself, never through a borrowed human credential.
npm install @run402/sdkTwo entry points
| Import | Use when |
|---|---|
| @run402/sdk/node | Running in Node 22 with the local profile state, project-key credential cache, and allowance. Auto-loads the configured API base, profile credentials/project-keys.v1.json, 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 | Isomorphic — works in Node, Deno, Bun, V8 isolates. No filesystem access. Bring your own CredentialsProvider (a session-token shim, a remote vault, anything that resolves project keys + auth headers). |
The Node entry sends bounded client-version metadata on gateway requests using the unprefixed Run402-Client header, for example surface="sdk", version="3.7.14", sdk="3.7.14". The CLI passes surface: "cli", so gateway compatibility hints can distinguish CLI-created SDK traffic from direct SDK callers. Metadata never includes local paths, package manager details, wallet/org/project ids, secrets, or install confidence. The isomorphic entry does not send this header by default; pass clientMetadata explicitly only in runtimes where custom headers are expected.
Quick start (Node)
import { run402 } from "@run402/sdk/node";
const r = run402();
const project = await r.projects.provision({ tier: "prototype" });
await (await r.project(project.project_id)).assets.put("hello.txt", { content: "hi" });That's it — credentials are read, x402 payments are signed, results are typed.
Public Buzz/Nostr identity links
r.identityLinks represents public, proof-backed Nostr attribution for both human and agent principals. Agent creation uses the EOA-plus-kind-1 ceremony below. Human creation is the normal browser/passkey/Buzz flow at https://console.run402.com/identity-links/connect; it never asks a human to paste an event or handle a passkey/session credential. Both return a common idlnk_… shape discriminated by proof_protocol. One principal may hold multiple distinct active subjects, while an active subject is linked to only one principal. Identity links never authenticate and never change organization authority or ownership.
import { readFile } from "node:fs/promises";
const begin = await r.identityLinks.nostr.begin({
nostrPubkey: "npub1...",
visibility: "public",
});
// Publish begin.proof_content as a standalone kind-1 event through Buzz.
const rawEvent = await readFile("buzz-event.json", "utf8");
const proof = await r.identityLinks.nostr.complete({ rawEvent });
await r.identityLinks.getProof(proof.identity_link_id); // public, no auth
await r.identityLinks.revoke(proof.identity_link_id); // agent EOA or direct human session, by protocolThe SDK locally rejects secret-shaped inputs and verifies the exact seven-field NIP-01 event, event id, and BIP-340 signature before agent completion. list() preserves every active and revoked link plus its proof protocol; public proof reads expose independently verifiable Nostr evidence separately from Run402-attested human-session checks. Human link creation and revocation remain browser-canonical, and link revocation never removes an org membership. Buzz operators should use the self-contained run402-buzz package; the SDK ceremony is a lower-level agent API, not a separate getting-started path.
r.buzz.status() capability-detects the community control plane and the additive human-adoption offer flow. r.buzz.offerAdoption(...) and typed humanAdoptionOffers.create/get/cancel/createAttempt expose the durable HTTPS handoff without treating offer creation as consent or authority; the attempt method is for an explicitly authenticated human client. Polling a completed offer returns three distinct effects: the terminal consent receipt, public human identity attribution, and ordinary owner membership. The membership is the only organization-authority source; link and membership revocation are independent and neither rewrites the receipt. Existing humanAdoptions direct methods remain advanced compatibility. communityInstallations and enrollments expose their independent lifecycles; install(...) and enroll(...) are goal aliases. The SDK generates idempotency keys when omitted, rejects nested secret-shaped fields before network access, never signs Buzz events, and represents enrollment authority only as expiring grants to named existing projects. MCP intentionally omits offer/attempt mutations and renders the exact handoff. Buzz itself remains unchanged. Gateway failures retain their stable code, exact repair field, complete nextActions, and safeToRetry. See the Fizz/Honey lifecycle.
r.buzz.notifications routes selected project events (deploy_activated, error_fingerprints_observed, platform_incident — the three reviewed projectors; security/billing_critical/destructive_lifecycle/verification/recovery classes may never be routed) into a Buzz community channel as signed NIP-29 messages. The workflow is configure → authorize → test → live: createRoute returns an authorization block whose pending_buzz_authorization state carries the one exact non-secret handoff (a Buzz community owner adds the notification_pubkey as a relay member), and testAndWait queues a signed probe and polls it to a terminal state — on timeout it RETURNS the still-queued delivery rather than throwing (the tick publishes ~every 60s; silence is cadence, not failure). list/get/deliveries are reads (get reports honest health derived from route + credential state, never queue emptiness); update is revision-guarded (409 BUZZ_ROUTE_REVISION_STALE without mutation); pause/resume/rotate/revoke are the lifecycle. No response carries the signing secret, an empty filter array is rejected rather than treated as a wildcard, routes deliver NEW events only, and Buzz is never a deadman channel — mandatory notification classes keep their human paths regardless of route state.
Before creating an x402 payment payload, the Node entry confirms USDC with
bounded retry/backoff and independent RPC failover on Base and Base Sepolia.
RPC exhaustion is never treated as a zero balance. Branch on the exported
X402BalanceError.code: X402_RPC_TIMEOUT, X402_RPC_RATE_LIMITED, and
X402_RPC_UNAVAILABLE are pre-payment failures with safeToRetry === true
and mutationState === "not_started"; X402_INSUFFICIENT_FUNDS means the
relevant balance reads succeeded and the confirmed funds do not cover any
accepted requirement. After a retryable preflight failure, the next request
refreshes only mutable RPC balance state while retaining the originally
selected signer and payer provenance. Error details contain provider indexes
and failure classes, never RPC credentials, wallet keys, or signed proofs.
Payment signer selection (Node)
Authentication and payment are separate authorities. A custom credentials
provider controls API authentication; the x402 payer is resolved exactly once
in this order:
paymentSigner— an explicit async EVM signer provider (KMS/HSM friendly).allowancePath— an explicit local allowance file.credentials.readAllowance()— when a supplied provider implements it.- The Node default provider's active-profile allowance — only when the caller did not supply a custom credentials provider.
Once a source is selected, the SDK never falls back to the ambient/global
wallet. paymentSigner and allowancePath together throw
PAYMENT_SOURCE_CONFLICT. Passing both credentials and allowancePath is
valid: auth uses credentials, while payment intentionally uses that file.
fetch still takes precedence over built-in paid fetch, and
disablePaidFetch: true disables automatic payment entirely.
An opaque signer returns only its public payer address and signing operation; raw keys and replayable payment authorizations do not cross the provider boundary:
import {
run402,
type CredentialsProvider,
type EvmPaymentSigner,
type EvmPaymentSignerProvider,
type PaymentPublicClient,
type X402PaymentNetwork,
} from "@run402/sdk/node";
declare const sessionCredentials: CredentialsProvider;
declare function kmsSignerFor(
network: X402PaymentNetwork,
publicClient: PaymentPublicClient,
): Promise<EvmPaymentSigner>;
const paymentSigner: EvmPaymentSignerProvider = {
async getSigner({ network, publicClient }) {
return kmsSignerFor(network, publicClient); // address + signTypedData
},
};
const r = run402({ credentials: sessionCredentials, paymentSigner });
const payer = await r.paymentPayer();
// { source: "payment_signer", rail: "x402", payers: [{ address, network }, ...] }The provider may return null for an unsupported Base network. Paid-fetch
initialization is lazy and retries after missing/recoverable local state, so a
long-lived client can start paying after its selected allowance/provider
becomes available without being reconstructed. r.paymentPayer() initializes
the selected source if necessary and returns only its source, rail, public
address(es), and network(s); it never returns a key, signed authorization, or
replayable proof. It returns null when automatic paid fetch is disabled, a
custom fetch owns payment, or the selected source is not currently available.
Buy arbitrary x402 URLs
The Node entry exposes a bounded buyer for any HTTP(S) endpoint:
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());The default ceiling is 100,000 USD micros ($0.10). Unpriced URLs pass through
with payment: null. Set requireReceipt: true to require a verified
wallet-rooted offer before payment and a matching merchant receipt afterward.
The buyer checks the exact URL, scheme, network, asset, amount, recipient,
validity, settlement, payer, transaction, and signer relationship. The result
separates settlement from the merchant's service_delivered claim and carries
complete portable evidence; payFetchResultToJson renders the canonical
snake_case x402-commerce-result.v1 envelope.
Failures throw PaymentBuyerError with PAYMENT_EXCEEDS_MAX,
PAYMENT_WALLET_UNFUNDED, PAYMENT_NETWORK_UNSUPPORTED, exact Run402
pending/drain/destination/fence/lifetime/key-reuse codes, or
PAYMENT_SETTLEMENT_FAILED, plus fundsMoved, paymentId, intent/delivery
facts, and nextActions. Successful results preserve paymentId,
deduplicated, fundsMoved, delivery, settledAt, and intentState when
Run402 supplies them.
If no eligible offer exists, required policy fails before signing with
MERCHANT_RECEIPT_REQUIRED and fundsMoved: false. If payment settles but the
receipt is absent, invalid, untrusted, or unavailable, PaymentPolicyError
uses MERCHANT_RECEIPT_UNAVAILABLE, preserves the upstream Response and
commerce result, reports the true mutation state, and supplies exactly one
retry or reconcile_payment action. It never recommends a second payment.
The durable attempt journal and MCP result never store payment proofs, cookies,
authorization headers, bodies, private keys, or tenant secrets.
After an ambiguous transport failure, retry the identical request on the same
SDK instance with the same idempotency key. pay.fetch retains the original
proof in memory and re-presents it; it never signs a replacement. A used-proof
response becomes outcome: "already_settled" without fabricating a receipt.
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
Idempotency-Key. On trusted PAYMENT_INTENT_PENDING, wait for Retry-After
and repeat exactly that call; never change payer, binding, or key. Custom and
arbitrary sellers remain ambiguous and require reconciliation.
PAYMENT_CALLER_IDENTITY_NOT_ACTIVE is a rollout fail-closed response: retain
the same key and retry after activation; do not remove the key to force a
proof-only charge.
Automatic x402 attempt recovery
Automatic paid requests persist a redacted mode-0600 intent before sending a signed payment. A PaymentAttemptError before provider dispatch has mutationState: "not_started" and safeToRetry: true; check retryable separately because persistent local-journal corruption is safe from duplicate payment but requires repair rather than an automatic retry. After dispatch, an unknown outcome is mutationState: "ambiguous", safeToRetry: false, with reconcile_payment and poll actions. Reconcile paymentAttemptId before authorizing another payment. The only proof-replay exception is an identical r.pay.fetch retry on the same live SDK instance, which re-presents its retained proof rather than authorizing a new payment.
Use readPaymentAttempt(id) or listPaymentAttempts({ limit }) from @run402/sdk/node to inspect the active profile's local journal. Trusted pending records use state: "intent_pending" and may contain payment_id, retry timing, and only a SHA-256 caller-key digest. The journal never stores raw caller keys, signed headers/proofs, raw paths, request bodies, query strings, wallet keys, signatures, or raw causes. X-Run402-Payment-Attempt-Id is reserved atomically and sent only on the payment-bearing call, with redirects disabled so payment metadata cannot cross to another target. Existing ids fail with X402_ATTEMPT_ID_ALREADY_EXISTS; malformed ids fail with INVALID_PAYMENT_ATTEMPT_ID, both before network dispatch.
For repo-level app deploys, the Node entry also exposes the action runner used by run402 up:
import { Run402Action, run402 } from "@run402/sdk/node";
const r = run402();
await r.up({ name: "my-app" }, { approval: "yes" });
await r.up({ verifyOnly: true, propagationWait: false });
await r.actions.run({
type: Run402Action.ProjectsProvision,
name: "my-app",
});Action identifiers are exported constants plus a string-literal union, so inputs narrow by type. up validates run402.deploy.json / app.json before any mutation, resolves the project as explicit projectId → .run402/project.json → manifest project_id → approved creation from name → approved active-project fallback, then delegates to r.project(id).apply(...). name is only project creation/link metadata; it is not a manifest field and never renames an existing project. If allowance/tier/project/link are already configured, r.up() can run the requested deploy with the default approval policy; pass { approval: "yes" } only when you want recursive prerequisites/local writes to proceed unattended.
App manifests can define verify.http[]. r.up() verifies those URLs after deploy, treats fresh Run402 edge sentinel misses as propagation_pending instead of permanent failures while the host binding converges, and returns app_result.verify plus per-check diagnostics. Use propagationBudgetSeconds to tune the default 120 second wait, propagationWait: false to return the pending state immediately, and verifyOnly: true to rerun verification without upload, deploy, project creation, or resource mutation.
Typed-config workflows use one execution-mode union:
await r.up({ manifest: "run402.deploy.ts" }, { mode: "check" });
await r.up({ manifest: "run402.deploy.ts" }, { mode: "printSpec" });
await r.up({ manifest: "run402.deploy.ts" }, { mode: "plan" });
await r.up(
{ manifest: "run402.deploy.ts" },
{ mode: { kind: "applyReviewed", planId: "plan_...", planFingerprint: "pfp_..." } },
);check and printSpec are local-only. plan calls the gateway in reviewed-plan mode and returns plan_id / plan_fingerprint; applyReviewed verifies before upload and again at commit.
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 the same target/profile store instead of parsing target.json or 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"],
},
});Project-scoped sub-client
Most operations are project-scoped. Bind once and skip the id arg on every call:
r.projects.list() and r.projects.get(id) are server-authoritative project reads. r.projects.use(id) validates the project with the current principal and stores only an active project id in profile state; it does not require local project-key cache membership. r.project(id) binds the id without local lookup. Each namespace then follows its declared auth mode: control-plane operations such as custom domains default to principal/delegate auth with explicit project_id, while true data-plane/key operations use local project credentials and fail with PROJECT_CREDENTIAL_NOT_FOUND when the selected profile lacks cached keys.
const p = await r.useProject(projectId); // persists active project + returns scoped handle
await p.assets.put("hello.txt", { content: "hi" }); // no projectId arg
await p.functions.list();
await p.apply({ site: { replace: files({ "index.html": "<h1>hi</h1>" }) } });r.useProject(id) writes the active project to the keystore (shared with concurrent CLI runs). For transient in-script scoping that does NOT mutate that state, use r.project(id) (or r.project() with no arg to resolve from whatever the keystore currently considers active).
Local project keys live behind an explicit credential-cache namespace. These helpers are local/offline and are not authoritative project reads:
const status = await r.credentials.projectKeys.status(projectId); // redacted
const serviceKey = process.env.RUN402_SERVICE_KEY!;
await r.credentials.projectKeys.import(projectId, { serviceKey });
const keys = await r.credentials.projectKeys.export(projectId, { reveal: true });
await r.credentials.projectKeys.remove(projectId);status/list report source: "local_cache", profile/cache-path provenance, key presence, prefixes, and fingerprints without full secrets. export(..., { reveal: true }) is the only SDK helper that emits cached secret key material.
Quick start (isomorphic)
import { Run402 } from "@run402/sdk";
const r = new Run402({
apiBase: "https://api.run402.com",
credentials: {
async getAuth() { return { Authorization: `Bearer ${session.token}` }; },
async getProject(id) { return session.projects[id] ?? null; },
},
});The CredentialsProvider interface has two required methods (getAuth, getProject) plus optional ones (saveProject, removeProject, setActiveProject, readAllowance, saveAllowance, …) for hosts that want full sticky-default behavior.
Namespaces
| Namespace | Highlights |
|---|---|
| actions | Node entry only (@run402/sdk/node). Generic recursive action runner: actions.run({ type: Run402Action.Up | ProjectsProvision | TierSet, ... }); r.up(input, opts) is the convenience for repo-level manifest deploys. Recursive mutations are approval-gated; mode: "check" | "printSpec" | "plan" | { kind: "applyReviewed" } distinguishes local validation, gateway review, and exact reviewed apply. Child gateway mutations derive idempotency keys from the root action. |
| pay | fetch(url, init?, { maxUsdMicros?, idempotencyKey?, requireReceipt? }) — bounded arbitrary-URL x402 buyer; Node uses the selected allowance/signer and returns the response plus settlement and independently verified merchant evidence. |
| projects | provision, delete, list, get, use, active, sql, rest, validateExpose, applyExpose, getExpose, getUsage, getSchema, info, keys, pin, getQuote. list/get/use are server-authoritative; local key reads are moving to credentials.projectKeys. |
| snapshots | Internal project restore points: create, list, get, restorePlan, restore, delete. Restore is a two-step plan/confirm handshake. |
| branches | Contained project data branches: create, list, renew, delete. Branches default to expiring, noindex, sandboxed-email copies. |
| credentials | projectKeys.list, projectKeys.status, projectKeys.import, projectKeys.export, projectKeys.remove for explicit local project-key cache management. |
| r.project(id).apply | The unified apply primitive. Callable hero — r.project(id).apply(spec) for atomic mixed writes (release slices + assets slice). Sub-methods: .plan, .start, .resume, .upload, .commit, .rehearse, .status, .list, .events, .resolve, .getRelease, .getActiveRelease, .diff. Underlying engine routes to /apply/v1/*. |
| ci | GitHub Actions OIDC federation over /ci/v1/*: createBinding, listBindings, getBinding, revokeBinding, exchangeToken; plus canonical delegation helpers. createBinding accepts asset_key_scopes for per-key CI write authorization. |
| r.project(id).sites | deployDir — Node entry only (@run402/sdk/node); thin wrapper over r.project(id).apply({ site: dir(...) }) |
| r.project(id).assets | put (single asset), putMany, uploadDir (Node, additive), syncDir (Node, destructive only with prune: true + confirm token), prepareDir (returns { manifest, applySlice } for pre-commit URL injection), get, ls, rm, sign, diagnoseUrl, waitFresh, diff. Returns AssetRef (single) or AssetManifest (batch). |
| cache | SSR origin ISR cache: invalidate(url), invalidatePrefix({ host, prefix }), invalidateAll({ host }), invalidateMany(urls), inspect(url). Project-scoped (host ownership validated server-side; cross-project hosts throw R402_CACHE_INVALIDATION_HOST_FORBIDDEN). Generation-guarded — in-flight MISS renders started before an invalidate cannot overwrite the freshly-cleared state. |
| functions | deploy, invoke, logs, update, list, delete, rebuild, rebuildAll, runs.* durable function requests |
| jobs | submit, get, logs, cancel, purge for platform-managed jobs |
| secrets | set, list, delete |
| subdomains | claim, list, delete (most agents declare subdomains in r.project(id).apply({ subdomains: { set: [...] } }) instead) |
| domains | The ProjectDomain lifecycle — the one surface for custom domains (web + email): ensure (connect / update desired state), get, list, check (refresh observations), apply (records Run402 has authority over), repair, wait, testReceive, activate, disconnect. desired carries web, email, and an optional authority — "hosted_dns_zone" is the root-domain path (one nameserver change at the registrar; Run402 applies every in-zone record and issues TLS). Every response carries next_actions[]. |
| email | createMailbox, listMailboxes, setMailboxDefaults, updateMailbox, getMailbox, deleteMailbox, send, list, get, getRaw, webhooks.* |
| auth | requestMagicLink (link/code/both), verifyMagicLink, verifyEmailCode, createUser, inviteUser, setUserPassword, settings, passkey registration/login/list/delete helpers, typed providers, promote, demote |
| apps | browse, getApp, fork, publish, listVersions, updateVersion, deleteVersion |
| tier | set, status (tier pricing lives on r.projects.getQuote()) |
| billing | createEmailOrganization, linkWallet, createCheckout, setAutoRecharge, checkBalance, getOrganization, lookupOrganization, getHistory, balance, history |
| vouchers | redeem (promo code → prepaid credit; safe to retry) |
| contracts | provisionSigner, getSigner, listSigners, setRecovery, setLowBalanceAlert, call, read, callStatus, drain, deleteSigner |
| ai | translate, moderate, usage, generateImage |
| allowance | status, create, export, faucet |
| service | status, health (no auth, no setup — works on a fresh install) |
| admin | Operator/admin endpoints: messages/contact, per-project finance (getProjectFinance) |
| operator | The human / email principal — distinct from the agent's per-wallet SIWX identity (and from platform-admin). Read session: deviceStart, devicePoll, overview({ token }), revoke({ token }) — browser-delegated device-authorization (RFC 8628, the aws sso login model); overview returns the email-union across every wallet that verified the email. Write session (v1.78): buildCliAuthorizeUrl/exchangeCliToken (loopback-PKCE CLI login) + the hosted operator.session.* surface (email magic-link / passkey / OAuth login, whoami/refresh/revoke, step-up, authenticators, recovery) — carry a minted session SDK-wide with controlPlaneSessionCredentials({ token }). Drives run402 operator login[/--loopback]/overview/whoami/logout. No MCP tool by design — MCP authenticates as the agent, not the human. |
| identityLinks | Public human/agent external identity attribution with a discriminated proof protocol. Agent nostr.begin/complete uses EOA + kind 1; human creation/revocation is browser-canonical. list, getProof, and revoke preserve multiple active and revoked records. Never accepts a Nostr secret and never grants authority. |
| buzz | Capability-detecting Buzz control plane. offerAdoption plus humanAdoptionOffers creates/reads/cancels durable HTTPS handoffs and creates human-bound attempts; direct humanAdoptions is advanced compatibility. install/enroll and typed community/enrollment methods preserve separate principals, bounded named-project grants, drift, and scoped revocation. notifications routes project events into a Buzz channel (createRoute/list/get/update/pause/resume/rotate/revoke/test/deliveries/testAndWait — configure → authorize → test → live; the signing secret never leaves the gateway). Buzz signing stays outside the SDK. |
| wallet(address) | getLabel(), setLabel(label) — the signed server-side wallet label (gateway /wallets/v1/:address/label) surfaced in the operator console; pushed on wallets use unless RUN402_WALLET_LABEL_SYNC=0. Use the r.wallet(address) handle; r.wallets.getLabel(address) remains a bare read |
| orgs | Org-owned control plane (first-class orgs). create, list, whoami (the gateway-resolved control-plane identity) on the collection; the scoped r.org(id) sub-client (org analog of r.project(id)) adds get, rename, setPayoutWallet, members.* (list/add/setRole/revoke), invites.* (list/create/revoke), audit. Org create/read/rename summaries include tier, lease_started_at, and lease_expires_at. |
| grants | create, revoke — per-project capability grants (e.g. "deploy", "functions:write") for agent/CI principals; owner-gated, also reachable project-scoped as r.project(id).grants |
| events | list, listForOrg — the cursored project events feed ("what happened since I last looked"): deploy activations, suspensions, transfers, lifecycle cliffs, each with platform-suggested next_actions, plus app-emitted business facts (source: "app") alongside the platform's own (source: "platform") — filter with { source?, eventType? }. Opaque store-and-echo cursor; reset: true + earliest_cursor instead of errors on expiry. Also reachable project-scoped as r.project(id).events |
| rooms | registerPresence, listPresences, getPresence, sendMessage, listMessages, waitForMessages (kygit-invite — the agent's ear: blocks until a matching message lands past the cursor or a timeout elapses, using the gateway's held read wait=<1..25> when it is observed to hold and degrading to bounded polling the instant a page comes back with no waited_ms; silence is an answer, never a throw — returns the last observed page with settled: false), getMessage, ackMessage, createClaim, listClaims, releaseClaim, plus scoped(orgId, roomKey) (sync) and forProject(projectId) (async — resolves the project's org; the default room's key IS the project id) returning a room-bound ScopedRoom (which also carries waitForMessages). Org-scoped agent coordination rooms: per-session presence (~1h silence decays LIVENESS only — an opaque sessionKey on every call RESUMES the same presence no matter how long it was silent, reported via resumed: true; requestedName honored-or-suffixed on a fresh registration, reported via requested_name + renamed + a plain-language why when a collision was task-qualified; program/model labels ride every registration when the harness or an explicit override names them), room-visible ≤32 KiB markdown messages (to/cc route attention, not access control; idempotencyKey replay returns the ORIGINAL + deduplicated: true) with opaque mcr_… cursors (reset: true + earliest_cursor on expiry, never an error), and ADVISORY claims — createClaim always succeeds with a complete conflicts[]; nothing is ever blocked by a claim |
| gitvault | The host-blind encrypted Git remote (r402s/v0). Isomorphic reads: get, forProject (cold-restart lookup — resolves repo_id with no local state), forRepo (slug-form resolution), resolveAddress (form dispatch), heads, allHeads, setPolicy, completeOverride, acquireMaintenanceLease, listByOrg (the bulk vaults-by-org read — repos list's primary route), access (READ-ONLY recipients/coverage/local-TOFU-pin report; never wraps a key). Node-only writes (keystore + git working tree, reached through dynamic imports so a browser build never pulls node:fs): init, openOrCreate (lazy allocation on first open — the primitive push and git-remote-run402's push path compose on GITVAULT_VAULT_UNRESOLVED), resolveOrCreateAddress (named-address resolve/push-to-create/id-pin), push, handoff (kygit-handoff — captures the working tree into a stash-shaped checkpoint and mints a single-use bearer kgh1_… key), resume (claims a Handoff Key, clones fresh, restores the checkpoint), listHandoffs, revokeHandoff, invite (kygit-invite — the second claim kind: captures the checkpoint, registers the inviter's presence, mints a single-use bearer kgi1_… key, posts one room fact), join (claims an Invite Key, folds the caller's own cold-start chain first, clones fresh, restores the checkpoint, pins the invite's room, registers this session's presence, posts one arrival fact), listInvites, revokeInvite, status, compact, prune (plans; submits with both verifier receipts), verify (accepts {persist:false} to walk without writing), fsck (verify + materialize + explicit pin_before/pin_after/local_state_changed; {write:false} is the audit mode), deploy, restore, scaffoldRemote (claims origin additively, falls back to run402), open, drainOverrides. Also exports the pure gitvaultLossWarningTrip/gitvaultLossWarningTripped/gitvaultLossWarningMessage and gitvaultRemoteAddressForm/gitvaultRemoteUrl/gitvaultRemoteUrlForRepo/gitvaultRemoteScheme/gitvaultSlugReleasedInfo helpers — gitvaultRemoteUrl/gitvaultRemoteUrlForRepo render kygit:: instead of run402:: when RUN402_REMOTE_SCHEME=kygit is set (design D8, kygit-handoff); parseGitvaultRemoteUrl accepts either prefix into the same scheme-less {org_id, project_id} shape. All protocol behaviour lives here once; run402 repos … (the SDK's own name, r.gitvault), git-remote-run402, and the MCP tools are adapters. Run402 cannot decrypt your gitvault or repository history. Deployment artifacts remain a disclosed plaintext custody boundary. |
| errors | list, get, watch — the release-error-rollup query surface. Verdict-first: each page leads with a gateway-computed promote-vs-revert verdict (new_fingerprints / recurring_fingerprints / invocations_in_window, baselined against the previous ACTIVE release), then grouped, deploy-stable error fingerprints with fetch_logs drill-downs. watch({ newIn }) is the promote-gate poll loop — run it right after apply/promote; clean === (verdict.new_fingerprints === 0), the gateway's count (no client-side identity math). Opaque keyset cursor. Also reachable project-scoped as r.project(id).errors |
CLI-style aliases are available for agent ergonomics: r.image aliases r.ai,
and common command names such as r.billing.balance, r.auth.magicLink,
r.projects.schema, r.email.create, and r.contracts.setAlert point at the
canonical camelCase methods.
Durable function requests live under r.functions.runs and the scoped project handle. They require an idempotency key and support immediate, delayed, or absolute-time execution, retry policy, logs, cancellation, redrive, and polling:
const p = await r.project(projectId);
const run = await p.functions.runs.create("worker", {
eventType: "reminder.send",
payload: { message_id: "msg_123" },
idempotencyKey: r.idempotency.fromParts("reminder", "msg_123"),
delay: "10m",
retry: r.functions.retry.standard({ maxAttempts: 3 }),
});
await p.functions.runs.wait(run.run_id);Casing in returned shapes
Two casings coexist by design — agents reading the type surface should classify a field by the SHAPE it belongs to:
- Raw API result shapes preserve the gateway's snake_case fields. Examples:
ProvisionResult.project_id,ProvisionResult.anon_key,ProvisionResult.service_key,ProvisionResult.schema_slot,ProjectInfo.project_id,ProjectSummary.lease_expires_at,UsageReport.api_calls,SchemaReport.schema. These mirror the HTTP response bodies one-to-one. - SDK-specific helper shapes use camelCase. Examples:
AssetRef.cdnUrl/AssetRef.cacheKind/AssetRef.contentSha256,Run402DeployError.safeToRetry/operationId/mutationState, everyDeployEventvariant's discriminator (type, plus per-variant fields likereleaseId,urls).
This split is intentional and stable across the 3.x line. Doc examples in this
README and in llms-sdk.txt use the exact field names the types export —
copy them verbatim. CI fails any TypeScript-fenced example that accesses a
field that does not exist on the actual type.
Reference tables (in
llms-sdk.txt) use plain code fences, nottsfences. They document the type surface in compact form for visual scanning — they are not runnable programs and are exempt from CI type-checking. Runnable example snippets still use```tsand are CI-gated against the published types.
Patterns
Paste-and-go assets — content-addressed URLs with SRI
(await r.project(id)).assets.put returns an AssetRef. The cdnUrl is content-addressed (pr-<public_id>.run402.com/_blob/<key>-<8hex>.<ext>), served through CloudFront, and never needs cache invalidation. The browser refuses execution on byte mismatch via SRI:
const logo = await (await r.project(projectId)).assets.put("logo.png", { bytes });
// logo.cdnUrl → drop into <img src="…">
// logo.sri → "sha256-…" for <script integrity="…">
// logo.etag → strong "sha256-<hex>"
// logo.cacheKind → "immutable" | "mutable" | "private"immutable: true is the default. The SDK always computes and sends the object SHA-256; pass false only when you specifically need mutable URL/cache semantics.
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.
Image variants
Image uploads (jpeg/png/webp/heic/heif) trigger automatic generation of three WebP variants — thumb 320w, medium 800w, large 1920w — plus dimensions, a blurhash placeholder, and (for HEIC/HEIF sources) a JPEG display variant. Everything ships on the returned AssetRef:
const p = await r.project(projectId);
const ref = await p.assets.put("hero.jpg", bytes, { contentType: "image/jpeg" });
// Image-conditional fields, undefined on non-image AssetRefs:
ref.width_px; // 4032 — display-oriented (post-EXIF rotate)
ref.height_px; // 3024
ref.blurhash; // "LEHV6nWB2yk8pyo0adR*.7kCMdnj" — decode client-side for LQIP
ref.variants?.thumb?.cdn_url; // 320w WebP — for grid thumbnails
ref.variants?.medium?.cdn_url; // 800w WebP — for cards
ref.variants?.large?.cdn_url; // 1920w WebP — for heroes
// SDK convenience fields, also undefined on non-images:
ref.thumbUrl; // = variants.thumb.cdn_url ?? displayUrl (single-field thumbnail)
ref.displayUrl; // = display_url ?? cdn_url (browser-renderable for any image)
// Render with responsive srcset (sizes is required):
const html = ref.imgTagWithSrcSet({
alt: "Hero",
sizes: "(max-width: 800px) 100vw, 1920px",
});
// → <picture>
// <source type="image/webp" srcset="<thumb> 320w, <medium> 800w, <large> 1920w" sizes="…">
// <img src="<display_url>" alt="Hero" width="4032" height="3024" loading="lazy" decoding="async">
// </picture>
// Quick thumbnail (TypeScript narrows thumbUrl on non-images):
// <img src={ref.thumbUrl} alt={ref.key} loading="lazy" />HEIC/HEIF uploads (from iPhones) preserve the source bytes verbatim — cdn_url serves the original HEIC, and a JPEG display variant is generated automatically and surfaced at display_url. The imgTag / imgTagWithSrcSet helpers default the <img src> to displayUrl so apps render correctly without HEIC-specific code.
Foolproof guards keep non-images from rendering broken layouts:
thumbUrlanddisplayUrlareundefined(not a fallback tocdn_url) on non-image AssetRefs — TypeScript narrows them, so a<img src={pdfRef.thumbUrl}>is a compile error rather than a broken thumbnail at runtime.imgTagWithSrcSetthrows at call time whenopts.sizesis missing or empty (browsers over-fetch the largest candidate without it), AND when the AssetRef has novariants(useimgTag()instead — see the error message). No silent fallback.imgTagopportunistically emitswidth/heightattributes when present (eliminates CLS) and silently omits them on non-image refs.
Variants apply to BOTH write paths — single-shot r.assets.put(...) AND the unified apply hero r.project(id).apply({ assets: { put: [...] } }) return the same AssetRef shape with variants populated.
AVIF was deferred from v1 — <picture> browsers select sources by type precedence, not best size, so a single 1920w AVIF would be picked for thumbnails by AVIF-capable browsers. AVIF, if it returns, will land at all three sizes simultaneously or via a separate imgTagHero() helper.
Mixed apply — site + assets in one atomic activation
Drop a per-key asset put into the same release as your site files. Both promote inside the same activation transaction that flips live_release_id, so the asset URLs are live the moment the new release is. Source shorthand: bare strings for text, Uint8Array for bytes, or any other ContentSource (Blob, FsFileSource from fileSetFromDir, { data, contentType? } wrapper). The SDK normalizer hashes once and dedups across slices — same SHA in site and assets uploads as a single byte stream.
import { run402, fileSetFromDir } from "@run402/sdk/node";
const r = run402();
const p = await r.project(projectId);
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
const siteFiles = await fileSetFromDir("./dist");
const result = await p.apply({
site: { replace: siteFiles },
assets: {
put: [
{ key: "static/logo.png", source: imageBytes, content_type: "image/png" },
{ key: "static/styles.css", source: "/* inline css */" },
],
},
});
const logo = result.assets?.byKey["static/logo.png"];
console.log(logo?.cdn_url); // hot the moment the release activatesFor bulk asset uploads, use the Node-only helpers uploadDir (additive), syncDir (destructive with explicit prune: true + confirmation token), and prepareDir (returns { manifest, applySlice } so the agent can render HTML against resolved URLs before committing in one apply transaction):
import { run402, type AssetManifest, type FileSet } from "@run402/sdk/node";
const r = run402();
const p = await r.project(projectId);
const renderHtml = (_m: AssetManifest) => "<h1>hi</h1>";
const siteFiles: FileSet = {};
const { manifest, applySlice } = await r.assets.prepareDir("./assets", { project: projectId, prefix: "static/" });
const html = renderHtml(manifest); // urls already populated
await p.apply({
site: { replace: { ...siteFiles, "index.html": html } }, // atomic with assets
assets: applySlice,
});Expose manifest validation
Validate the auth/expose manifest used by manifest.json, database.expose, and apply_expose before mutating a project:
const manifest = { version: "1" as const, tables: [] };
const result = await r.projects.validateExpose(manifest, {
project: projectId, // optional live-schema context
migrationSql: "create table items (id bigint primary key);",
});
if (result.hasErrors) console.log(result.errors);migrationSql is reference context only; it is not executed as a PostgreSQL dry run. This method validates authorization manifests, not deploy manifests.
Unified apply — r.project(spec.project).apply
The canonical primitive for any deploy (database + migrations + manifest + value-free secret declarations + functions + site + subdomain). Three layers:
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>",
"events.html": "<h1>Events</h1>",
},
},
public_paths: {
mode: "explicit",
replace: { "/events": { asset: "events.html", cache_class: "html" } },
},
},
};
// One-shot — most agents use this.
const result = await (await r.project(spec.project)).apply(spec);
const summary = summarizeDeployResult(result);
console.log(summary.headline);
// 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);
const final = await op.result();
// Resume a previously-started deploy by id.
const resumed = await (await r.project(projectId)).apply.resume("op_...");All bytes ride through CAS. The plan request body never carries inline bytes — only
ContentRefobjects. When the spec exceeds 5 MB JSON, the SDK uploads the manifest itself as a CAS object (manifest_refescape hatch).Per-resource semantics on the spec.
site.replace= "this is the whole site" (files absent are removed).site.patch.put/patch.deleteare surgical updates.site.public_pathscontrols browser-visible static paths separately from backing release asset paths: explicit mode uses a complete map such as{ "/events": { asset: "events.html", cache_class: "html" } }, so/eventsservesevents.htmlwhile/events.htmlis not public unless separately declared. Implicit mode restores filename-derived reachability and can widen access. A public-path-only site spec is deployable.functions.replace/functions.patch.set/functions.patch.deletemirror that. Secrets are value-free: set values first withr.secrets.set(project, key, { value }), then deploy withsecrets.requireand/orsecrets.delete.subdomains.set/subdomains.add/subdomains.removeuse their own shape. Top-level absence = leave untouched.Same-origin web routes.
routesisundefined | null | { replace: RouteSpec[] }. Omit it or passnullto carry forward base routes, pass{ replace: [] }to clear routes, or pass route entries to replace the table. Function targets use{ type: "function", name }; exact static route targets use{ type: "static", file }with methods["GET"]or["GET","HEAD"], no wildcard pattern, and a relative deployed asset path with no leading slash.fileis not a public path, URL, CAS hash, rewrite, or redirect. Prefersite.public_pathsfor ordinary clean static URLs like/events -> events.html; use static route targets for method-aware aliases such as staticGET /loginplus functionPOST /login. Function routes may add fixed tenant x402 pricing:pricing: { mode: "always", amount_usd_micros: 250000, pay_to: "org_default_payout", receipt: "on_fulfillment" }; omittednetworksmeans production mainnet only, and"testnet"must be opted in explicitly. Receipt intent requirespayment.fulfilled(response)from@run402/functionsafter completed delivery on compatible hosts; Run402-hosted advertising remains gated until the interoperable delegated-signer carrier exists and never silently downgrades. Static aliases cannot be priced. Set the org payout wallet withr.org(orgId).setPayoutWallet({ walletAddress }); audit payments withr.projects.listTenantPayments(projectId)or scopedr.project(id).projects.listTenantPayments(). Routed browser ingress invokes Node 22 Fetch Request -> Response handlers;req.urlis the full public URL on managed subdomains, deployment hosts, and verified custom domains. On priced routes, handlers should importgetRoutedPaymentContextfrom@run402/functionsand usepayment.paymentIdfor idempotency. Direct/functions/v1/:nameinvocation remains API-key protected. Runtime route failure codes includeROUTE_MANIFEST_LOAD_FAILED,ROUTED_INVOKE_WORKER_SECRET_MISSING,ROUTED_INVOKE_AUTH_FAILED,ROUTED_ROUTE_STALE,ROUTE_METHOD_NOT_ALLOWED,PAYOUT_WALLET_REQUIRED,PAYOUT_WALLET_AMBIGUOUS,PAYOUT_WALLET_UNRESOLVED,PAYMENT_PROOF_MISMATCH, andROUTED_RESPONSE_TOO_LARGE.Strict spec validation happens before network calls. Raw
ReleaseSpecobjects reject unknown fields (for exampleproject_idorsubdomain) instead of silently dropping them during normalization, and project/base-only or empty nested specs fail withRun402DeployError.code === "MANIFEST_EMPTY". Use the Node manifest helpers when starting from CLI/MCP-style JSON.Tier preflight happens before apply side effects. After normalization and before manifest CAS upload or
/apply/v1/plans, apply checks literal function timeout, memory, schedule-trigger cron minimum interval, and scheduled-trigger count when known. Violations throwRun402DeployError.code === "BAD_FIELD"withdetails.field,details.value,details.tier, the relevant cap, anddetails.limit_source; gateway validation remains authoritative.Warnings are structured.
DeployResult.warningscontainsWarningEntry[](code,severity,requires_confirmation,message, optionalaffected/details/confidence); the type preserves legacy low/medium/high plan warnings and modern deploy-observability info/warn/high warnings.apply()emitsplan.warningsand stops before upload/commit on confirmation-required warnings unless broadallowWarningsis set or every blocking code is listed inallowWarningCodes. ForMISSING_REQUIRED_SECRET, set the affected keys withr.secrets.set, then retry.Deploy summaries are SDK-owned convenience.
summarizeDeployResult(result)returnsDeploySummary(schema_version: "deploy-summary.v1") with a headline plus reliable current buckets for site path counts, CAS new/reused bytes, functions, migrations, routes, secrets, subdomains, and warning counts. It is derived fromDeployResult.diff/DeployResult.warnings; it makes no extra gateway calls, omits sections the gateway did not return, and intentionally excludes timings, client-side duration estimates, and function old/new code hashes.Safe release-race retries are SDK-owned.
apply()automatically re-plans and retries omitted/current-base specs when the gateway returnsBASE_RELEASE_CONFLICTwithsafe_to_retry: true. Static activation/config failures reported fromactivation_pendingthrow immediately with gateway metadata preserved. The default retry budget is two retries after the initial attempt; pass{ maxRetries: 0 }to opt out.Planning has two explicit non-deploying modes.
(await r.project(spec.project)).apply.plan(spec, { mode: "reviewedPlan" })calls the gateway reviewed-plan route and returnsplan_id,plan_fingerprint,plan_expires_at, diff, warnings, andnext_actions[]without uploading bytes or committing. Exact apply passes{ requiredPlan: { planId, planFingerprint? } }toapply()/start()/commit(); the SDK verifies before upload and commit. Legacy{ dryRun: true }still calls the no-row debug route and returnsplan_id: null, but it is not require-able.Rehearsals run candidate plans on contained branches. Use the lower-level sequence when you want an explicit gate before commit:
const p = await r.project(spec.project); const { plan, byteReaders } = await p.apply.plan(spec); await p.apply.upload(plan, { byteReaders }); if (!plan.plan_id) throw new Error("Preview plans cannot be rehearsed"); const rehearsal = await p.apply.rehearse(plan.plan_id, { teardown: "on_pass" }); if (rehearsal.report.status !== "passed") throw new Error("Rehearsal failed"); const committed = await p.apply.commit(plan.plan_id);Plan responses may advertise
rehearsal: { available, rehearse_url }; commit results may carryrestore_pointorsnapshot_skipped_reason.Release observability is typed. Use
r.project(id).apply.getRelease(releaseId, { siteLimit? }),r.project(id).apply.getActiveRelease({ siteLimit? }), andr.project(id).apply.diff({ from, to, limit? })to inspect release inventory and release-to-release diffs (there is no barer.deploysurface). Inventories includerelease_generation,static_manifest_sha256, nullablestatic_manifest_metadata(file_count,total_bytes,cache_classes,cache_class_sources,spa_fallback), andstatic_public_paths[]when returned.site.pathslists release static assets;static_public_paths[]lists browser reachability withpublic_path,asset_path,reachability_authority,direct, cache class, and content type.diffreturnsReleaseToReleaseDiffwithmigrations.applied_between_releases; secret diffs expose keys only;static_assetsexposes unchanged/changed/added/removed files, CAS byte reuse, eliminated deployment-copy bytes, and immutable/CAS warning counts.Server-authoritative manifest digest — no byte-for-byte canonicalize requirement on the client.
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); await p.apply({ site: { replace: await fileSetFromDir("./dist") }, subdomains: { set: ["my-app"] }, });fileSetFromDirskips.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.Route manifests are ordinary deploy specs:
import { run402, type RouteSpec, type ReleaseSpec } from "@run402/sdk/node"; const r = run402(); const orgId = "43530623-da33-4905-b476-a78592d284ba"; await r.org(orgId).setPayoutWallet({ walletAddress: "0xabc0000000000000000000000000000000000001" }); const routes: RouteSpec[] = [ { pattern: "/api/*", methods: ["GET", "POST", "OPTIONS"], target: { type: "function", name: "api" } }, { pattern: "/api/credits", methods: ["POST"], target: { type: "function", name: "credits" }, pricing: { mode: "always", amount_usd_micros: 250000, pay_to: "org_default_payout" } }, { pattern: "/admin", target: { type: "function", name: "admin" } }, { pattern: "/admin/*", target: { type: "function", name: "admin" } }, { pattern: "/login", methods: ["POST"], target: { type: "function", name: "auth" } }, ]; const spec: ReleaseSpec = { project: projectId, functions: { replace: { api: { source: "export default async function handler(req) { const url = new URL(req.url); return Response.json({ ok: true, path: url.pathname }); }" }, credits: { source: "import { getRoutedPaymentContext } from '@run402/functions'; export default async function handler(req) { const payment = getRoutedPaymentContext(req); if (!payment) return new Response('payment missing', { status: 500 }); return Response.json({ ok: true, payment_id: payment.paymentId, amount_usd_micros: payment.amountUsdMicros }); }" }, admin: { source: "export default async () => new Response('admin')" }, auth: { source: "export default async () => new Response('login')" }, }, }, 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" } } } }, routes: { replace: routes }, }; await (await r.project(spec.project)).apply(spec);Matching is exact or final
/*prefix only./admin/*does not match/admin; deploy both/adminand/admin/*when the section root is dynamic. Release static asset paths and public browser paths are distinct. In the example,events.htmlis a release asset and/eventsis the public static URL declared bysite.public_paths;/events.htmlis not public in explicit mode unless separately declared. A route-only static alias looks like{ pattern: "/events", methods: ["GET", "HEAD"], target: { type: "static", file: "events.html" } }; prefersite.public_pathsfor ordinary clean URLs and reserve static route targets for exact method-aware route-table behavior. Avoid routing every static file, 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 publicreq.url. Exact beats prefix, longest prefix wins, and method-compatible dynamic routes beat static files. A method-specificPOST /loginroute lets staticGET /loginserve HTML. Unsafe method mismatch returns405; matched dynamic route failures do not fall back to static assets.Routed functions use Node 22 Fetch Request -> Response.
req.urlis the full public URL on managed subdomains, deployment hosts, and verified custom domains. The rawrun402.routed_http.v1envelope is internal; direct/functions/v1/:nameremains API-key protected.Recipe — static home page + SPA shell: a root alias
{ pattern: "/", target: { type: "static", file: "home.html" } }(withhome.htmlshipped at the site root) serves real static bytes atGET /(route_static_alias) while unmatched app routes such as/dashboardkeep theindex.htmlshell (spa_fallback) — route matching runs before all static resolution, including the implicit/->index.htmlroot mapping, and SPA-fallback derivation is independent of the route table. Expect non-blockingSTATIC_ALIAS_SHADOWS_STATIC_PATH(warn) andSTATIC_ALIAS_DUPLICATE_CANONICAL_URL(info) plan lints; omittedroutescarries the alias forward, androutes.replaceis total, so include the alias every time your pipeline sends it.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; void auth; void cas; void variant; console.log(summary.would_serve, summary.match, request.ignored);r.project(id).apply.resolve({ url, method })also accepts lower-level{ host, path?, method? }. URL query strings/fragments are ignored for lookup and surfaced inrequest.ignored. When returned,asset_path,reachability_authority, anddirectexplain which release asset backs the public URL and whether reachability came from implicit file-path mode, explicitsite.public_paths, or a route-only static alias. Stable-host diagnostics may also includeauthorization_result,cas_object(sha256,exists,expected_size,actual_size), hostname-specificresponse_variant, route/static fields such asallow,route_pattern,target_type,target_name, andtarget_file, plusedge_propagation(settled,propagating, orsync_pending). Current knownmatchliterals arehost_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, androute_method_miss; preserve unknown future strings. Knownauthorization_resultvalues includeauthorized,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, andunauthorized_cas_object. Knownfallback_statevalues includeactive_release_missing,unsupported_manifest_version, andnegative_cache_hit; preserve unknown future strings.resultis diagnostic body status, not SDK HTTP transport status, so host misses can be successful calls withwould_serve: false. Do not use resolve as a fetch, cache purge, or cache-policy oracle; branch on structured fields such ascache_class,allow,cas_object, andedge_propagation, and preserve unknown cache classes.Route warning recovery:
| Code | Why it matters | Recovery | |------|----------------|----------| |
PUBLIC_ROUTED_FUNCTION| Function becomes public same-origin browser ingress. | Review app auth, CSRF, CORS/OPTIONS, and cookies; direct/functions/v1/:nameremains API-key protected. PreferallowWarningCodesafter review; broadallowWarningsonly after every warning was reviewed. | |ROUTE_TARGET_CARRIED_FORWARD| Carried-forward route still targets a base-release function. | Inspect active routes and deployroutes.replaceif the target should change. | |ROUTE_SHADOWS_STATIC_PATH/WILDCARD_ROUTE_SHADOWS_STATIC_PATHS| Dynamic route shadows direct public static content. | Inspect warning details, active routes,static_public_paths, and resolve diagnostics; confirm only when intentional. | |METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK| Unmatched methods can serve static content. | Confirm fallback is intended or add method coverage. | |WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS| Wildcard function route only allowsGET/HEAD. | Add mutation methods such asPOST, omit methods for an API prefix, or setacknowledge_readonly: trueon an intentionally read-only GET/HEAD final-wildcard function route. | |ROUTE_TABLE_NEAR_LIMIT| Route table is near a limit. | Consolidate or remove routes. | |ROUTES_NOT_ENABLED| Routes are disabled for the project/environment. | Deploy withoutroutesor 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 backingasset_path; prefersite.public_pathsfor 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. |The Node entry also has the typed manifest adapter shared by CLI/MCP:
import { loadDeployManifest, run402 } from "@run402/sdk/node"; const r = run402(); const { spec, idempotencyKey } = await loadDeployManifest("./run402.deploy.json"); await (await r.project(spec.project)).apply(spec, { idempotencyKey });loadDeployManifest(path)parses JSON relative to the manifest file, maps agent-friendlyproject_idintoReleaseSpec.project, decodes base64 file entries, turns{ path }entries into lazyFsFileSourcevalues, and reads migrationsql_path/sql_file. It also loads explicit executable.ts/.mts/.cts/.js/.mjs/.cjsconfigs and rejects executable auto-discovery withEXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST. It rejects unknown manifest fields before they can become partial deploys. UsenormalizeDeployManifest(input)when the manifest object is already in memory.Minimal
run402.deploy.ts:import { defineConfig, dir, nodeFunction, sqlFile } from "@run402/sdk/config"; export default defineConfig(({ env }) => ({ project: env.required("RUN402_PROJECT_ID"), database: { migrations: [sqlFile("db/001_init.sql")] }, site: { replace: dir("dist"), public_paths: { mode: "implicit" } }, functions: { replace: { api: nodeFunction("dist/functions/api.js") } }, secrets: { require: ["OPENAI_API_KEY"] }, }));Helper semantics are explicit:
dir()walks deterministically, normalizes path separators, skips sensitive defaults unlessincludeSensitiveis set, and rejects symlinks;file()resolves relative paths from the manifest directory;sqlFile()derivesidfrom the filename unless supplied and preserves optional checksum/transaction metadata;nodeFunction()stages built JavaScript for Node 22. TypeScript function source paths are rejected withTYPESCRIPT_FUNCTION_REQUIRES_BUNDLEuntil the SDK owns a deterministic bundling path. Typed configs may declaresecrets.require[]/delete[], but never embed secret values. Config functions receive{ manifestPath, rootDir, env }; reading throughenv.get(),env.required(), orenv.RUN402_*records
