# @run402/sdk

Typed TypeScript client for the [Run402](https://run402.com) API. The typed, opinionated workflow layer shared by `run402-mcp` and the `run402` CLI. Most operations are project-scoped: bind with `await r.project(id)`, then compose `.apply()`, `.assets.put()` and other operations. Apply coordinates staged activation and resumable work; it does not promise to roll back committed SQL migrations. Application functions use the separate `@run402/functions` runtime package.

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.

```bash
npm install @run402/sdk
```

Deployment summaries share the SDK workflow view. CLI writes redacted detail under `.run402/diagnostics/`; MCP retains it through `expand_result`. Typed SDK callers keep the full result. Snapshot collection excludes platform runtime files automatically.

## Two 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)

```ts
import { run402 } from "@run402/sdk/node";

const r = run402();
// Prepare run402.json and all referenced files from the first-deploy guide.
const result = await r.up(
  { name: "my-app", manifest: "run402.json" },
  { approval: "yes" },
);
console.log(result); // Inspect deployment and verification evidence separately.
```

The shared workflow resolves credentials and prerequisites, then deploys the manifest. See the [complete first-deploy files](https://docs.run402.com/start/first-deploy/) before running this example. Use the CLI for ordinary operations; use the SDK for typed TypeScript/JavaScript composition.

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

```ts
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 protocol
```

The 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](../buzz/README.md); 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](../buzz/references/community-control-plane.md).

`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:

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:

```ts
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:

```ts
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`:

```ts
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`; global active state never selects a deployment target, 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.

Before any gateway call, in every mode, `up` verifies every local file the manifest references (migration `sql_path`/`sql_file`, function `source`/`files`, site `{ path }` entries and `dir()` targets, `assets.put[].source`) and throws `MANIFEST_FILE_MISSING` (`details.missing[]` of `{ field_path, path, kind }`, one `create_file` next action per file); `manifest` pointing at a missing path is `MANIFEST_NOT_FOUND`, and with no manifest in the directory `UP_MANIFEST_REQUIRED` lists `details.nearby_manifests[]` one level down with a read-only `run_in_directory` action for a single candidate, or one unranked `select_application` action for multiple apps. The same check is exported standalone from `@run402/sdk/node`: `collectLocalFileReferences(spec)`, `findMissingLocalFileReferences(spec)`, `assertLocalFileReferencesExist(spec, { manifestPath? })`, plus the `manifestFileMissingError` / `manifestNotFoundError` builders.

`up` also names the principal when it has none: `identityName` (or `RUN402_AGENT_NAME`; `identity.source: "explicit"`, overrides an existing name), else a detected client (`claude-code`, `codex`, `cursor`, `grok`; `RUN402_CLIENT=<name>` declares one with no marker of its own and is checked first; `identity.source: "detected"`), else nothing is written (`"undetected"`). `result.identity` always carries `detected` (the client seen this run, applied or not) and `detection: { applied, reason }` with `reason` one of `applied`, `name_already_set`, `explicit_name_wins`, `nothing_detected`. The scaffolded `run402` remote is never added inside another repository: the skip carries a `create_nested_repo` next action, and `nested: true` makes the app root its own nested repository (one line appended to the enclosing repository's local `.git/info/exclude`).

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:

```ts
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`, `printSpec`, and `printManifest` are local-only. `printManifest` returns reloadable snake_case authoring JSON relative to the original manifest directory; unsupported runtime/secret/build values fail explicitly. `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:

```ts
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:

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

```ts
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:

```ts
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)

```ts
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" | "printManifest" | "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.** Resolve `const project = await r.project(id)`, then use `project.apply(spec)` for staged multi-resource 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` (`{ tail?, since?, requestId?, origin? }`; every entry carries `origin: "app" \| "platform"`, computed by the exported `classifyFunctionLogLine`, and `origin` filters client-side with `hidden: { platform, app }` counts), `logsByRequestId(projectId, requestId, { tail?, since?, origin?, functionName? })` (project-wide search for a `req_`/`fnrun_`/`fnatt_` id, the `x-run402-request-id` response header: reads every function, merges oldest-first, tags each entry with its `function`; also `r.project(id).functions.logsByRequestId`), `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:

```ts
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`,
  every `DeployEvent` variant's discriminator (`type`, plus per-variant
  fields like `releaseId`, `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, not `ts`
> fences.** 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 ```` ```ts ```` and 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:

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

```ts
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`:

```ts
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:

- `thumbUrl` and `displayUrl` are `undefined` (not a fallback to `cdn_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.
- `imgTagWithSrcSet` throws at call time when `opts.sizes` is missing or empty (browsers over-fetch the largest candidate without it), AND when the AssetRef has no `variants` (use `imgTag()` instead — see the error message). No silent fallback.
- `imgTag` opportunistically emits `width`/`height` attributes 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.

```ts
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 activates
```

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

```ts
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:

```ts
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:

```ts
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 `ContentRef` objects. When the spec exceeds 5 MB JSON, the SDK uploads the manifest itself as a CAS object (`manifest_ref` escape hatch).
- **Per-resource semantics on the spec.** `site.replace` = "this is the whole site" (files absent are removed). `site.patch.put` / `patch.delete` are surgical updates. `site.public_paths` controls 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 `/events` serves `events.html` while `/events.html` is 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.delete` mirror that. Secrets are value-free: set values first with `r.secrets.set(project, key, { value })`, then deploy with `secrets.require` and/or `secrets.delete`. `subdomains.set` / `subdomains.add` / `subdomains.remove` use their own shape. Top-level absence = leave untouched.
- **Same-origin web routes.** `routes` is `undefined | null | { replace: RouteSpec[] }`. Omit it or pass `null` to 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. `file` is not a public path, URL, CAS hash, rewrite, or redirect. Prefer `site.public_paths` for ordinary clean static URLs like `/events -> events.html`; use static route targets for method-aware aliases such as static `GET /login` plus function `POST /login`. Function routes may add fixed tenant x402 pricing: `pricing: { mode: "always", amount_usd_micros: 250000, pay_to: "org_default_payout", receipt: "on_fulfillment" }`; omitted `networks` means production mainnet only, and `"testnet"` must be opted in explicitly. Receipt intent requires `payment.fulfilled(response)` from `@run402/functions` after 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 with `r.org(orgId).setPayoutWallet({ walletAddress })`; audit payments with `r.projects.listTenantPayments(projectId)` or scoped `r.project(id).projects.listTenantPayments()`. Routed browser ingress invokes Node 22 Fetch Request -> Response handlers; `req.url` is the full public URL on managed subdomains, deployment hosts, and verified custom domains. On priced routes, handlers should import `getRoutedPaymentContext` from `@run402/functions` and use `payment.paymentId` for idempotency. Direct `/functions/v1/:name` invocation remains API-key protected. Runtime route failure codes include `ROUTE_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`, and `ROUTED_RESPONSE_TOO_LARGE`.
- **Strict spec validation happens before network calls.** Raw `ReleaseSpec` objects reject unknown fields (for example `project_id` or `subdomain`) instead of silently dropping them during normalization, and project/base-only or empty nested specs fail with `Run402DeployError.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 throw `Run402DeployError.code === "BAD_FIELD"` with `details.field`, `details.value`, `details.tier`, the relevant cap, and `details.limit_source`; gateway validation remains authoritative.
- **Warnings are structured.** `DeployResult.warnings` contains `WarningEntry[]` (`code`, `severity`, `requires_confirmation`, `message`, optional `affected`/`details`/`confidence`); the type preserves legacy low/medium/high plan warnings and modern deploy-observability info/warn/high warnings. `apply()` emits `plan.warnings` and stops before upload/commit on confirmation-required warnings unless broad `allowWarnings` is set or every blocking code is listed in `allowWarningCodes`. For `MISSING_REQUIRED_SECRET`, set the affected keys with `r.secrets.set`, then retry.
- **Deploy summaries are SDK-owned convenience.** `summarizeDeployResult(result)` returns `DeploySummary` (`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 from `DeployResult.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 returns `BASE_RELEASE_CONFLICT` with `safe_to_retry: true`. Static activation/config failures reported from `activation_pending` throw 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 returns `plan_id`, `plan_fingerprint`, `plan_expires_at`, diff, warnings, and `next_actions[]` without uploading bytes or committing. Exact apply passes `{ requiredPlan: { planId, planFingerprint? } }` to `apply()` / `start()` / `commit()`; the SDK verifies before upload and commit. Legacy `{ dryRun: true }` still calls the no-row debug route and returns `plan_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:

  ```ts
  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, reason }` where `reason` is `null` when available, else `no_migrations`, `no_live_release`, or `migrations_unchanged`; commit results may carry `restore_point` or `snapshot_skipped_reason`. The automatic rehearsal inside `apply()` / `r.up()` reports `rehearsal: { status: "passed", … }` or `{ status: "skipped", reason: "no_live_release" | "no_migrations" | "migrations_unchanged" | "disabled" | "reviewed_plan" | "unsupported" }`; `migrations_unchanged` (gateway-supplied, or derived from the plan's `{ new, noop }` migration buckets when `new` is empty) means every migration is already applied with an identical checksum, so a page-only redeploy that still carries its migrations ships without a branch.
- **Release observability is typed.** Use `r.project(id).apply.getRelease(releaseId, { siteLimit? })`, `r.project(id).apply.getActiveRelease({ siteLimit? })`, and `r.project(id).apply.diff({ from, to, limit? })` to inspect release inventory and release-to-release diffs (there is no bare `r.deploy` surface). Inventories include `release_generation`, `static_manifest_sha256`, nullable `static_manifest_metadata` (`file_count`, `total_bytes`, `cache_classes`, `cache_class_sources`, `spa_fallback`), and `static_public_paths[]` when returned. `site.paths` lists release static assets; `static_public_paths[]` lists browser reachability with `public_path`, `asset_path`, `reachability_authority`, `direct`, cache class, and content type. `diff` returns `ReleaseToReleaseDiff` with `migrations.applied_between_releases`; secret diffs expose keys only; `static_assets` exposes 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:

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

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

- Route manifests are ordinary deploy specs:

  ```ts
  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 `/admin` and `/admin/*` when the section root is dynamic. 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`; `/events.html` is 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" } }`; prefer `site.public_paths` for 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 public `req.url`. Exact beats prefix, longest prefix wins, and method-compatible dynamic routes beat static files. A method-specific `POST /login` route lets static `GET /login` serve HTML. Unsafe method mismatch returns `405`; matched dynamic route failures do not fall back to static assets.

  Routed functions use Node 22 Fetch Request -> Response. `req.url` is the full public URL on managed subdomains, deployment hosts, and verified custom domains. The raw `run402.routed_http.v1` envelope is internal; direct `/functions/v1/:name` remains API-key protected.

  Recipe — static home page + SPA shell: a root alias `{ pattern: "/", target: { type: "static", file: "home.html" } }` (with `home.html` shipped at the site root) serves real static bytes at `GET /` (`route_static_alias`) while unmatched app routes such as `/dashboard` keep the `index.html` shell (`spa_fallback`) — route matching runs before all static resolution, including the implicit `/` -> `index.html` root mapping, and SPA-fallback derivation is independent of the route table. Expect non-blocking `STATIC_ALIAS_SHADOWS_STATIC_PATH` (warn) and `STATIC_ALIAS_DUPLICATE_CANONICAL_URL` (info) plan lints; omitted `routes` carries the alias forward, and `routes.replace` is total, so include the alias every time your pipeline sends it.

- URL-first public diagnostics:

  ```ts
  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 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 such as `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`, plus `edge_propagation` (`settled`, `propagating`, or `sync_pending`). 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 such as `cache_class`, `allow`, `cas_object`, and `edge_propagation`, and preserve unknown cache classes.

  Route warning recovery:

  | Code | Why it matters | Recovery |
  |------|----------------|----------|
  | `PUBLIC_ROUTED_FUNCTION` | Function becomes public same-origin browser ingress. | Informational (`requires_confirmation: false`): it never blocks `apply` and needs no `allowWarningCodes` entry. Review app auth, CSRF, CORS/`OPTIONS`, and cookies; direct `/functions/v1/:name` remains API-key protected. Only warnings with `requires_confirmation: true` need `allowWarningCodes`; broad `allowWarnings` only after every warning was reviewed. |
  | `ROUTE_TARGET_CARRIED_FORWARD` | Carried-forward route still targets a base-release function. | Inspect active routes and deploy `routes.replace` if 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 allows `GET`/`HEAD`. | Add mutation methods such as `POST`, omit methods for an API prefix, or set `acknowledge_readonly: true` on an intentionally read-only GET/HEAD final-wildcard function route. |
  | `ROUTE_TABLE_NEAR_LIMIT` | Route table is near a limit. | Consolidate or remove routes. |
  | `ROUTES_NOT_ENABLED` | Routes are disabled for the project/environment. | Deploy without `routes` or request enablement; direct function invoke is not a browser-route substitute. |
  | `STATIC_ALIAS_SHADOWS_STATIC_PATH` / `STATIC_ALIAS_RELATIVE_ASSET_RISK` | Route-only static alias conflicts with a direct public static path or has relative-asset risk. | Inspect active routes, `static_public_paths`, and the backing `asset_path`; prefer `site.public_paths` for ordinary clean URLs and confirm only when intentional. |
  | `STATIC_ALIAS_DUPLICATE_CANONICAL_URL` / `STATIC_ALIAS_EXTENSIONLESS_NON_HTML` | Route-only static alias may duplicate another direct public path or expose extensionless non-HTML. | Use one canonical public path per page and reserve exact static route targets for method-aware aliases. |
  | `STATIC_ALIAS_TABLE_NEAR_LIMIT` | Static route targets are near route-table limits. | Avoid one-static-route-target-per-page tables; consolidate. |

- The Node entry also has the typed manifest adapter shared by CLI/MCP:

  ```ts
  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-friendly `project_id` into `ReleaseSpec.project`, decodes base64 file
  entries, turns `{ path }` entries into lazy `FsFileSource` values, and reads
  migration `sql_path` / `sql_file`. It also loads explicit executable
  `.ts/.mts/.cts/.js/.mjs/.cjs` configs and rejects executable auto-discovery
  with `EXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST`. It rejects unknown
  manifest fields before they can become partial deploys, and a path that does
  not exist is a typed `MANIFEST_NOT_FOUND` (with a `create_manifest` next
  action), not a bare `LOCAL_ERROR`. Use
  `normalizeDeployManifest(input)` when the manifest object is already in memory,
  and `assertLocalFileReferencesExist(spec, { manifestPath? })` to fail with
  `MANIFEST_FILE_MISSING` before uploading when a referenced file is absent.

  Minimal `run402.deploy.ts`:

  ```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 unless `includeSensitive` is set,
  and rejects symlinks; `file()` resolves relative paths from the manifest
  directory; `sqlFile()` derives `id` from the filename unless supplied and
  preserves optional checksum/transaction metadata; `nodeFunction()` stages
  built JavaScript for Node 22. TypeScript function source paths are rejected
  with `TYPESCRIPT_FUNCTION_REQUIRES_BUNDLE` until the SDK owns a deterministic
  bundling path. Typed configs may declare `secrets.require[]` / `delete[]`,
  but never embed secret values. Config functions receive
  `{ manifestPath, rootDir, env }`; reading through `env.get()`,
	  `env.required()`, or `env.RUN402_*` records `config.env_accessed` metadata
	  on executable manifest loads so agents can explain spec drift.

### Snapshots and branches

Project snapshots are internal restore points. They are separate from portable archives and are never downloadable:

```ts
const snapshot = await r.snapshots.create(projectId);
const page = await r.snapshots.list(projectId, { limit: 20 });
const plan = await r.snapshots.restorePlan(projectId, snapshot.snapshot_id);
await r.snapshots.restore(projectId, snapshot.snapshot_id, plan.restore_plan.confirm.token, {
  includeAuth: true,
});
await r.snapshots.delete(projectId, snapshot.snapshot_id);
```

Branches are contained project copies for inspecting migrations and sharing temporary data state:

```ts
const branch = await r.branches.create(projectId, {
  ttlDays: 7,
  emailMode: "sandbox",
});
await r.branches.renew(projectId, branch.branch_project_id, { ttlDays: 7 });
await r.branches.delete(projectId, branch.branch_project_id);
```

Scoped handles expose the same surface as `p.snapshots.*` and `p.branches.*`.

### gitvault (`r.gitvault`) — the encrypted Git remote behind `run402 repos`, and its isomorphic/Node split

`r.gitvault` is the host-blind encrypted Git remote (`r402s/v0`) — the SDK's own name for it (`gitvault` is infrastructure language, `repos` is the CLI noun). Every piece of protocol behaviour — crypto core, keystore, creation journal, snapshot + capture, publication state machines, ref transactions, verification budget, repair — lives here once; `run402 repos …`, `git-remote-run402`, and the MCP tools (`repos_view`/`repos_list_heads`/`repos_fsck`) are adapters over this namespace with identical semantics.

**Three claims, three different strengths.** These are the entire approved claims vocabulary:

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

**The split.** Vault reads need only the HTTP client and run anywhere — a browser, a worker, an isolate. The verbs that touch a git working tree or the on-disk keystore are **Node-only** and are reached through dynamic imports, so `import { run402 } from "@run402/sdk"` in a browser 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.

```ts
// Isomorphic — @run402/sdk or @run402/sdk/node.
const vault = await r.gitvault.forProject(projectId);  // cold restart: resolves repo_id with no local state
const page = await r.gitvault.heads(vault.repo_id, { after_generation: "0000000000000001", limit: "100" });
await r.gitvault.setPolicy(vault.repo_id, { gitvault_policy: "required" });
```

```ts
// Node only — @run402/sdk/node (keystore + git working tree).
import { run402 } from "@run402/sdk/node";
const r = run402();

const pushed = await r.gitvault.push({ project_id: projectId, snapshot: { message: "wip: refactor the parser" } });
const state = await r.gitvault.verify({ project_id: projectId });
const plan = await r.gitvault.prune({ project_id: projectId }); // plans; plan.submitted is false until you submit
```

`heads` paging (D186): `after_generation` is the REQUIRED verification anchor — a semantic input, never a paging knob — and stays CONSTANT across a page sequence; `limit` is required; `cursor` is omitted on the first request and thereafter is the prior page's `next_cursor` echoed unchanged. `allHeads` walks that sequence for you.

**Nothing here is memoised.** Two responses are secret-bearing — the maintenance lease's `holder_token` (returned exactly once) and anything derived from the keystore — so they are never cached, never persisted into an agent-surface result store, and never logged.

`gitvaultRemoteUrl(orgId, projectId)` / `parseGitvaultRemoteUrl(url)` are exported helpers for the `run402::<org_id>/<project_id>` remote form that `git-remote-run402` serves; `r.gitvault.scaffoldRemote(...)` is what `run402 init` calls to add it — claiming `origin` when the repository has none yet, falling back to `run402` when `origin` is already taken by something else, and never modifying an existing remote either way. A `repo_dir` that lies inside another repository is reported `skipped` with a `create_nested_repo` next action; `scaffoldRemote({ …, nested: true })` (and `init({ …, nested: true })`) makes it its own nested repository instead (`git init -b main`, the remote added there) and appends exactly one line to the enclosing repository's local `.git/info/exclude`, reporting `nested`, `enclosing_toplevel`, and `excluded_in_enclosing`.

**Lazy allocation on first open.** `r.gitvault.openOrCreate({ project_id, org_id, repo_dir? })` resolves `repo_id` from `project_id` exactly like `open()`; when that resolution fails AND `org_id` was supplied, it runs the six-stage creation journal to allocate the vault before opening it. Without `org_id` it is byte-identical to `open()`. `push({ org_id, onVaultCreated })` composes this internally, so `git push` and `repos snapshot` allocate inline against an unregistered project — `onVaultCreated` fires with the one-shot recovery receipt the instant allocation lands, before capture/publish continue.

**Named addressing, id-pinning, and push-to-create.** `run402::<org-slug>/<name>` works alongside the id-form `run402::<org_id>/<project_id>` in the same slot — `gitvaultRemoteAddressForm(address)` discriminates them (a real org id is always a UUID and a real project id always `prj_`-prefixed, so an id-form address satisfies both at once). `r.gitvault.resolveOrCreateAddress({ address, repo_dir?, allow_create? })` resolves a parsed address, pinning the resolved `repo_id` into that checkout's LOCAL git config (`git config r402.repoId`) the first time a SLUG-form address resolves; every later open on that checkout follows the pin, skipping the resolution round-trip and surviving a later rename of either half. `allow_create: true` push-to-creates on a slug-form miss — the project and vault are allocated atomically, and `push({ address })` uses this instead of `openOrCreate` when `address` is passed. `REPO_CREATION_CONFLICT` (a race lost to a concurrent pusher) resolves to the winner's repo automatically; `SLUG_RELEASED` (`gitvaultSlugReleasedInfo(err)`) is NEVER auto-followed.

**Handoff / resume — pass a working tree to another agent (kygit-handoff).** `r.gitvault.handoff({ project_id, note, role?, ttlSeconds?, includeSensitive? })` captures the working tree into a synthetic stash-shaped checkpoint (same 3-parent shape real `git stash push -u` produces — an outer tracked-worktree-state commit, an index-tree parent, a parentless untracked-tree parent), pushes it as a retention root, and mints a single-use bearer `kgh1_<base64url(handoff_id[16] ‖ master_secret[32])>` key — returned in `handoff_key`, which is the ONLY copy; nothing else in the response, and nothing this library logs, ever carries it. `r.gitvault.resume({ key, to? })` claims the key (a same-principal replay dedups safely rather than erroring), clones a fresh checkout, and reapplies the checkpoint with `git stash apply --index`. Sensitive untracked paths (`.env`, `*.pem`, `id_rsa*`, SSH/AWS/GPG directories, 22 globs total — exported as `GITVAULT_HANDOFF_SENSITIVE_DENYLIST`) are excluded from capture unless named in `includeSensitive`. `r.gitvault.listHandoffs`/`revokeHandoff` manage outstanding keys.

```ts
const minted = await r.gitvault.handoff({
  project_id: projectId,
  note: { schema: "kygit.handoff-note.v1", created_at: new Date().toISOString(), from: { agent: "claude" }, summary: "refactored the parser" },
});
console.log(minted.handoff_key); // kgh1_… — print it ALONE, this is the only copy

// on another machine, with no shared keystore or allowance:
const resumed = await r.gitvault.resume({ key: "kgh1_…" });
console.log(resumed.restored.dir); // the fresh checkout, dirty state reapplied
```

**Invite / join — a second agent into the SAME work, dirty tree included (kygit-invite).** The second claim kind beside handoff/resume, sharing the same crypto/keystore/restore machinery, kind-parameterized: a Handoff passes the work on and the sender stops; an Invite grows the team while the sender keeps working. `r.gitvault.invite({ project_id, note, roomKey?, role?, ttlSeconds?, includeSensitive?, program?, model?, sessionKey? })` captures the checkpoint exactly like `handoff` — the inviter's own worktree, index, branch, refs, and access are all untouched — registers the inviter's OWN presence in the invite's room (the project's default room, or `roomKey` for a named org room) BEFORE minting, mints a single-use bearer `kgi1_<base64url(invite_id[16] ‖ master_secret[32])>` key (returned in `invite_key`, the ONLY copy), and posts ONE room message naming the checkpoint and invite id (never the key) — a presence or fact-post failure is reported in `inviter_presence`/`room_fact` and never voids the mint. `r.gitvault.join({ key, to?, program?, model?, sessionKey? })` claims it (a same-principal replay dedups safely), clones a fresh checkout, reapplies the checkpoint with `git stash apply --index`, pins the invite's OWN room locally (`r402.room`), registers this session's presence, posts ONE arrival message, and returns the inviter (name, labels, liveness), `live_presences`, the catch-up `cursor`, and the last few `recent_messages` — the joiner's first message needs no lookup. `r.gitvault.listInvites`/`revokeInvite` manage outstanding keys. A `kgh1_…` key handed to `join()`, or a `kgi1_…` key handed to `resume()`, is refused BY NAME (`INVITE_KEY_WRONG_KIND`/`HANDOFF_KEY_WRONG_KIND`) before any network call.

```ts
const minted = await r.gitvault.invite({
  project_id: projectId,
  note: { schema: "kygit.invite-note.v1", created_at: new Date().toISOString(), from: { agent: "claude" }, summary: "bringing in a second agent to pair on the parser" },
});
console.log(minted.invite_key); // kgi1_… — print it ALONE, this is the only copy

// on another machine, with no shared keystore or allowance:
const joined = await r.gitvault.join({ key: "kgi1_…" });
console.log(joined.restored.dir); // the fresh checkout, dirty state reapplied
console.log(joined.inviter?.name); // who invited you, and whether they're still live

// then, the room is the channel:
const heard = await r.rooms.waitForMessages(joined.membership.organization_id, joined.room.room_key, { presenceId: "prs_…" });
console.log(heard.settled, heard.messages, heard.live_presences);
```

**The `kygit::` scheme (design D8).** `gitvaultRemoteUrl`/`gitvaultRemoteUrlForRepo` render `kygit::<org>/<name>` instead of `run402::<org>/<name>` whenever `process.env.RUN402_REMOTE_SCHEME === "kygit"` (`gitvaultRemoteScheme()` reads it); `parseGitvaultRemoteUrl` accepts either prefix into the identical scheme-less canonical address. This is a pure client-side rendering choice — the gateway never sees which door a request came through, and every gateway-facing call, resolve, and pin is unaffected. The `@kychee/kygit` CLI package sets the env var before it execs the canonical CLI, so everything it prints or scaffolds comes out `kygit::`; both spellings resolve the same vault, and neither client ever rewrites a remote the other one wrote.

**Allocating a vault does not change deploy policy.** `gitvault_policy` stays whatever it already was — `null` for a project whose vault was just allocated. The apply engine (`applyWithGitvault`) attaches a typed `next_actions` entry (`gitvault_policy_required`) and a `warnings[]` entry on every deploy of a vaulted, ungated project, but never blocks or prompts; only `setPolicy(repoId, { gitvault_policy: "required" })` (or `"grandfathered"`) changes the gate.

**The progressive terminal-loss warning.** `status()` stays quiet at genesis and adds a `terminal_loss_risk` entry to `warnings[]` once the vault crosses any of ≥10 admitted generations, ≥10 MB `storage.source_bytes`, or ≥14 days since `genesis_admitted_at` — an OR-composite, computed by the pure, exported `gitvaultLossWarningTrip(record)` / `gitvaultLossWarningTripped(trip)` / `gitvaultLossWarningMessage(trip)` helpers (thresholds in `GITVAULT_LOSS_WARNING_THRESHOLDS`, tunable in this one place). There is deliberately no companion "resolved" check: V0 cannot detect a second principal able to open the vault, so nothing in the SDK ever clears a tripped warning.

**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 `run402 repos view` reports as `keystore.root` and prints under the terminal-loss statement — `~/.config/run402/gitvault` for the default wallet, `~/.config/run402/profiles/<wallet>/gitvault` for a named one. The recovery receipt `init` prints once is an integrity anchor, not a decryption key: it proves the vault you are served is the one you created, and it decrypts nothing.

### GitHub Actions OIDC — CI credentials drive deploy

The v1 CI path keeps the deploy primitive simple: link a GitHub repository once, then call the existing `r.project(spec.project).apply` with CI-marked credentials. There is no separate `r.ci.deployApply` method and no public `ci: true` deploy option.

The CLI is the easiest setup path (`run402 ci link github`), but the SDK exposes the building blocks:

```ts
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 });
const signed_delegation = signCiDelegation(values);
await r.ci.createBinding({
  ...values,
  provider: CI_GITHUB_ACTIONS_PROVIDER,
  signed_delegation,
});
```

Inside GitHub Actions, use `githubActionsCredentials`. It reads GitHub's OIDC environment, exchanges the subject token through `r.ci.exchangeToken`, caches the Run402 session until `expires_in - refreshBeforeSeconds`, and marks the credentials so deploy uses CI Bearer auth:

```ts
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 deploys intentionally allow 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. The SDK normalizes scopes, sends `route_scopes` only when non-empty, and still rejects `secrets`, `subdomains`, `checks`, unknown future top-level fields, non-current `base`, and specs large enough to require `manifest_ref` before upload/plan. Gateway planning enforces route diffs and can return `CI_ROUTE_SCOPE_DENIED`; re-link with covering exact scopes like `/admin` or final-wildcard scopes like `/api/*`, or deploy locally. Use the canonical builders (`buildCiDelegationStatement`, `buildCiDelegationResourceUri`) instead of hand-rolling SIWX text; gateway tests pin those strings as golden vectors.

### Timestamp Convention

Public API and SDK response timestamps are ISO-8601 strings, not `Date` objects
or numeric epochs: `created_at`, `updated_at`, `expires_at`,
`lease_expires_at`, `timestamp`, and similar absolute instants all stay JSON
native. Numeric time values are only for relative durations or elapsed/local
measurements, and their names carry units such as `expires_in`, `duration_ms`,
`elapsedMs`, or `ttl_seconds`.

### Errors

All failures throw subclasses of `Run402Error`. Every subclass carries a stable
`kind` discriminator string and an `isRun402Error` brand:

| Class | `kind` | When | Notable fields |
|---|---|---|---|
| `PaymentRequired` | `"payment_required"` | HTTP 402 | x402 payment requirements in `body` |
| `ProjectNotFound` | `"project_not_found"` | Server-authoritative project lookup/authorization reports not found or hidden | `projectId` |
| `ProjectCredentialNotFound` | `"local_error"` | A local project-key cache entry is required but missing for the selected profile | `projectId`, `details.source="local_cache"`, `nextActions` |
| `Unauthorized` | `"unauthorized"` | HTTP 401 / 403 | — |
| `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`, `safeToRetry`, `mutationState`, `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` |

Project credential codes are deliberately distinct from project existence/authz. Branch on `isProjectCredentialNotFound`, `isProjectCredentialInvalid`, `isProjectCredentialExpired`, `isProjectCredentialProjectMismatch`, or the broad `isProjectCredentialError`. Gateway-returned `PROJECT_CREDENTIAL_INVALID`, `PROJECT_CREDENTIAL_EXPIRED`, and `PROJECT_CREDENTIAL_PROJECT_MISMATCH` pass through unchanged; the SDK does not rewrite them to `PROJECT_CREDENTIAL_NOT_FOUND`.

**Branch with type guards, not `instanceof`.** `instanceof X` is an identity
check on the class object — it fails 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). The exported guards
(`isPaymentRequired`, `isDeployError`, …) check `isRun402Error` + `kind`,
which is identity-free and survives all of those scenarios. `instanceof`
continues to work for back-compat in the simple single-copy case.

```ts
import {
  run402,
  isPaymentRequired,
  isDeployError,
  type ReleaseSpec,
} from "@run402/sdk/node";

declare const spec: ReleaseSpec;
const r = run402();

try {
  await (await r.project(spec.project)).apply(spec);
} catch (e) {
  if (isPaymentRequired(e)) {
    // e is narrowed to PaymentRequired
    // present payment requirements to the user — read e.body, e.context, etc.
  } else if (isDeployError(e)) {
    // e is narrowed to Run402DeployError.
    // apply auto-retries safe BASE_RELEASE_CONFLICT races for current-base specs.
    // Log the structured envelope for policy errors, exhausted retries, or caller-owned recovery.
  } else throw e;
}
```

`Run402Error.toJSON()` returns a canonical envelope, so `JSON.stringify(e)`
produces a populated structured object instead of the empty `"{}"` plain
`Error` produces. Use this for telemetry, MCP tool results, CLI JSON output,
and any inter-process boundary where the error needs to survive serialization.

#### Retry idempotent operations with `withRetry`

`withRetry(fn, opts?)` wraps any async call with exponential backoff. It uses
`isRetryableRun402Error` (the canonical "should I retry this?" policy: 408 /
425 / 429 / 5xx / `NetworkError` / gateway-flagged `retryable`) by default.
`safeToRetry` by itself is not a retry signal; it means the repeated mutation
should not duplicate or corrupt state, not that lifecycle/payment/auth gates
will become allowed without an action. Pair retries with the SDK method's own
`idempotencyKey` so retried mutations dedup server-side:

For `r.project(spec.project).apply()`, safe `BASE_RELEASE_CONFLICT` release races are already
handled by the apply hero with a fresh plan and visible `deploy.retry`
events. Use `withRetry` for caller-owned retry policies around other operations,
or pass `maxRetries: 0` to `apply` when you want to handle deploy races
yourself.

```ts
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)) {
    // ... present payment
  } else if (isDeployError(e)) {
    // log structured envelope for triage
    process.stderr.write(JSON.stringify(e) + "\n");
  } else throw e;
}
```

Defaults: 3 attempts (1 initial + 2 retries), 250 ms base delay, 5 s cap. Pass
a custom `retryIf` to override the default policy (e.g., retry on
`PaymentRequired` if your sandbox auto-funds). After exhausting attempts
`withRetry` throws the LAST error — your catch handler sees the original
structured envelope, not a wrapper.

The SDK never calls `process.exit`. Each interface (MCP tools, CLI, your code) wraps with its own error behavior.

## Stability

This package is on the `3.x` line. The in-repo packages (`@run402/sdk`, `run402`, and `run402-mcp`) release in lockstep at the same version. Pin an exact version in production dependencies. `@run402/functions` and `@run402/astro` have independent release cadences.

## Other interfaces

`@run402/sdk` is the kernel that powers the CLI/MCP/OpenClaw edges and is used by adjacent integrations:

- [`run402`](https://www.npmjs.com/package/run402) — CLI (terminal / scripts / CI)
- [`run402-mcp`](https://www.npmjs.com/package/run402-mcp) — MCP server (Claude Desktop / Cursor / Cline / Claude Code)
- [`@run402/functions`](https://www.npmjs.com/package/@run402/functions) — in-function helper imported _inside_ deployed functions
- [`@run402/astro`](https://www.npmjs.com/package/@run402/astro) — Astro SSR, ISR cache, hosted auth, and image integration
- OpenClaw skill — script-based skill for OpenClaw agents

## Links

- HTTP API reference: <https://run402.com/llms.txt>
- CLI reference: <https://docs.run402.com/llms-cli.txt>
- Run402: <https://run402.com>

## License

MIT

Deployment diagnostics distinguish authenticated principal identity from detected client, and release identity from content-hash verification. Weak size/type evidence is inconclusive. Native REST SQLSTATE `42501` is surfaced as `REST_PERMISSION_DENIED` while retaining the upstream body/status and requested relation/method.
