# AGENTS.md

This file is the single source of truth for AI coding agents working in this repository (Claude Code, Codex, Cursor, Grok, Cline, OpenClaw, etc.). `CLAUDE.md` imports it via `@AGENTS.md`.

> **Updating docs?** See [`documentation.md`](documentation.md) — the map of every doc surface, with target audience, content summary, and update triggers. **Scan it before merging code changes.**

## Documentation policy

Use CLI by default in general operating examples. Use SDK examples in typed scripting/reference guides, MCP calls in MCP references, HTTP in protocol references, and native code in application-runtime guides. Root `SKILL.md` and `openclaw/SKILL.md` share the CLI-first body; MCP-native onboarding lives in the MCP reference. Describe the HTTP API foundation, the typed and opinionated SDK workflow layer, and thin CLI/MCP adapters. Edit authoring sources and regenerate flat docs/discovery artifacts. Never turn a missing CLI capability into an invented command.

## What This Is

run402 is a developer platform that ships Postgres databases, content-addressed CDN storage, static site hosting, Node 22 serverless functions, email, image generation, and KMS-backed Ethereum signing — provisioned by AI agents and paid for autonomously via x402 USDC on Base, MPP pathUSD on Tempo, or the organization's card-funded allowance. Prototype tier is free on testnet.

This monorepo ships **six interfaces**:

- **SDK** (`sdk/`) — typed TypeScript client for the run402 API. Used by external integrators and by MCP/CLI/OpenClaw. Published as `@run402/sdk` on npm. Two entry points: root (isomorphic — works in Node 22, Deno, Bun, V8 isolates) and `/node` (zero-config Node defaults — keystore + wallet + x402).
- **MCP server** (root `src/`) — published as `run402-mcp` on npm. Exactly eight tools: `up` and `deploy` (the first deploy), `status`, `whoami`, `doctor`, `docs`, `run`, and `expand_result`. Every other operation is a `run` snippet: TypeScript executed against the Node SDK in a QuickJS-in-WebAssembly sandbox. Read by Claude Desktop / Cursor / Cline / Claude Code.
- **CLI** (`cli/`) — standalone CLI published as `run402` on npm. Each subcommand is a thin shim over an SDK call; argv parsing and JSON output stay at the CLI edge.
- **Functions library** — in-function helper imported _inside_ deployed serverless functions. Exposes `auth.*` (the canonical auth namespace — `auth.user`, `requireUser`, `requireRole`, `requireMembership`, `requireFresh`, `fetch`, `csrfToken`/`csrfField`, `sessions.*`, `identities.link`), `db(req?)`, `adminDb()`, `email`, `ai`, and `assets`. Published as `@run402/functions` on npm. **Source lives in the public `run402-core` repo** under `packages/functions`; Run402 Cloud consumes the published npm package when it bundles deployed functions. Distinct from the SDK: this is the request-scoped, in-function shape; the SDK is the typed external client. The two are complementary, not redundant.
- **OpenClaw skill** (`openclaw/`) — script-based skill for OpenClaw agents, re-exports from CLI modules.
- **Astro integration** (`astro/`) — framework integration that wires the SDK into Astro's build pipeline. Ships an `<Image>` component (build-time scan + variant rewrite), an `assetsDir`/manifest pattern for data-driven sites, and runtime helpers (`resolveVariants`, `renderPicture`) for the recommended **AssetRef-persistence pattern** where consumers store the full `AssetRef` returned by `r.assets.put` in their data rows. Published as `@run402/astro` on npm.
- **Buzz integration** (`buzz/`) — self-contained `run402-buzz` skill with global CLI/profile setup, dual-proof fixtures/helper, four-state human/community/agent control-plane reporting, verified no-deploy readiness, bounded enrollment offers, and an approval-gated org-of-one fallback. It never accepts or derives from a Nostr private key, and public proof alone grants no authority.

Workspace layout: `package.json` declares `cli`, `sdk`, and `astro` as npm workspaces; `pnpm-workspace.yaml` lists only `cli` and `sdk` for pnpm-based hosts (astro is npm-only). `core/` is shared internal code, not an npm package. `@run402/functions` is NOT a workspace of this repo — its source lives in `kychee-com/run402-core/packages/functions`.

The three packages in this repo (`run402-mcp`, `run402`, `@run402/sdk`) release in lockstep via the `/publish` skill at the same version (the skill also supports per-package selection for off-cycle patches). MCP, CLI, OpenClaw, and the Node SDK all share the request kernel via `@run402/sdk`. `@run402/astro` is a sibling integration on its own release cadence via the `/publish-astro` skill — not part of the kernel lockstep. `@run402/functions` is published separately from `run402-core`, but ships at the same npm name. `core/` holds filesystem primitives (keystore, wallet, SIWE signing) that the Node SDK provider wraps.

## Where this code is edited

This repository is a published mirror: its `main` is generated from the `public/run402/` directory of Kychee's private platform monorepo and only ever fast-forwards. Maintainers and agents edit the code there, never in a clone of this repository.

- **Working inside the monorepo** (where this directory is `public/run402/`): follow the monorepo's root `AGENTS.md`. Make a worktree of the monorepo, never a worktree inside this directory; never `git stash`; never create local branches.
- **Contributing from outside:** open a pull request here as usual. A maintainer merges it on GitHub and pulls it back into the monorepo, and the next mirror run carries it forward.
- **Commit messages are public.** A change to this directory becomes this repository's history with its message unchanged, so keep messages free of private details.


## Build & Test Commands

```bash
npm run build:core         # tsc -p core/tsconfig.json → core/dist/
npm run build:sdk          # tsc -p sdk/tsconfig.json  → sdk/dist/
npm run build              # build:core + build:sdk + tsc → dist/ (also stages dist copies under cli/)
npm run start              # node dist/index.js (stdio MCP transport)
npm run lint               # type-aware ESLint bug rules on src, sdk/src, core/src, astro/src (run after build)

npm run test:skill         # validates SKILL.md and openclaw/SKILL.md (49 tests across both)
npm run test:sync          # checks MCP/CLI/OpenClaw/SDK stay in sync
npm test                   # runs build first, then SKILL + sync + unit (core/src + sdk/src + src) + CLI e2e
npm run test:e2e           # runs build first, then CLI end-to-end tests
npm run test:help          # runs build first, then CLI help-text snapshots only
npm run test:integration         # SIWX integration (core/src/siwx-integration.integ.ts)
npm run test:integration:full    # full CLI integration (cli-integration.test.ts)
npm run test:integration:mcp     # MCP integration (mcp-integration.test.ts)
npm run test:integration:fullstack # full-stack live platform integration (fullstack-integration.test.ts)
```

Unit tests use Node's built-in `node:test` runner with `tsx` for TypeScript:

```bash
# Run all unit tests
node --test --import tsx core/src/**/*.test.ts sdk/src/**/*.test.ts src/**/*.test.ts

# Run a single test file
node --test --import tsx src/tools/run.test.ts
node --test --import tsx core/src/keystore.test.ts
```

Tests are excluded from the build (`tsconfig.json`, `core/tsconfig.json`, and friends all exclude `**/*.test.ts`).

### Sync Test (`sync.test.ts`)

`sync.test.ts` defines the canonical API surface in a `SURFACE` array (columns `id`, `endpoint`, `cli`, `openclaw`; MCP has no column, because MCP reaches every SDK method through `run`) and checks:
- `src/index.ts` registers exactly `MCP_TOOLS`, the literal list of the eight tools, and each has a line in `docs-site/src/content/docs/mcp/`
- Every tool is registered with `server.registerTool` and an `outputSchema`
- No doc surface (`README.md`, `SKILL.md`, `openclaw/SKILL.md`, `docs-site/**`) and no `src/**` module names an MCP tool outside `MCP_TOOLS` (the doc-drift check)
- CLI commands in `cli/lib/*.mjs` match the expected set
- OpenClaw commands in `openclaw/scripts/*.mjs` match the expected set (follows re-exports to CLI)
- CLI and OpenClaw have identical command sets (parity)
- Every SURFACE capability maps to an SDK method (or explicit `null` in `SDK_BY_CAPABILITY`); every SDK method is referenced by SURFACE (orphan check)
- The CLI/MCP SDK-boundary guard: no production interface file (`cli/lib/**`, `src/**`) calls the gateway except through the SDK

When adding a new command, add it to the `SURFACE` array **and** `SDK_BY_CAPABILITY` in `sync.test.ts`. A new SDK method needs no MCP work: `run` reaches it.

## Architecture

```
@run402/sdk  (typed TypeScript kernel — focused namespaces including pay, snapshots, branches, and apply rehearsal)
   │
   │   /index.ts    (isomorphic: Node + sandbox)
   │   /node        (Node-only: keystore + wallet + x402-wrapped fetch + fileSetFromDir)
   │
   ├─── MCP        (src/)            — eight tools; `run` replays sandboxed snippets against the SDK
   ├─── CLI        (cli/lib/*.mjs)   — thin shim → SDK → JSON output + exit code
   └─── OpenClaw   (openclaw/scripts/*.mjs) — re-exports from CLI

core/      ← Node-only primitives (keystore, wallet, SIWE signing, config paths)
              Imported by sdk/src/node via ../../../core/dist/; not an npm package.

@run402/functions ← in-function helper (auth.*, db, adminDb, email, ai, assets, routedHttp).
              `auth.*` is the canonical namespace; bare exports
              (`getUser`, `getUserId`, `getRole`, `getSession`, `currentUser`,
              `getCurrentUser`, `getServerSession`) throw R402_AUTH_UNKNOWN_EXPORT at
              runtime AND fail `run402 doctor` source scan at deploy.
              SOURCE LIVES IN THE PUBLIC run402-core REPO — NOT in this repo.
              Treated as platform code, not a user dependency: the gateway bundles
              its installed @run402/functions package via esbuild alias at deploy
              time (users should not list @run402/functions in --deps). The npm
              package (@run402/functions) is both the editor-autocomplete artifact
              and the artifact Run402 Cloud consumes. To change the surface, edit
              run402-core/packages/functions, publish @run402/functions from
              run402-core, then bump the Cloud gateway dependency and redeploy.
```

The SDK is the canonical kernel — a single typed client with a `CredentialsProvider` interface for credential access and a pluggable `fetch` (for x402 wrapping in Node, session tokens in sandboxes). MCP handlers and CLI commands are thin shims: argv/schema parsing + SDK call + output formatting. They must not call Run402 gateway endpoints directly; add missing SDK methods first. The only direct `fetch()` calls allowed at those edges are non-Run402 external services (Tempo RPC, GitHub API) and presigned storage part URLs returned by the SDK. `sync.test.ts` enforces this boundary. MCP's `run` does not load the SDK into its sandbox: `r` inside the sandbox is a recorder whose chains replay on the host against the real Node SDK client, so the snippet sees the SDK's typed surface and nothing else.

### SDK (`sdk/src/`)

- **`index.ts`** — `Run402` class + `run402()` factory + `files()` helper. Isomorphic entry point.
- **`kernel.ts`** — Request function, `Client` interface. Only place that calls `globalThis.fetch`.
- **`errors.ts`** — core `Run402Error` hierarchy: `PaymentRequired`, `ProjectNotFound`, `Unauthorized`, `ApiError`, `NetworkError`, `Run402DeployError` (the v1.34+ structured envelope from the deploy state machine). The pay namespace adds `PaymentBuyerError` and post-settlement `PaymentPolicyError`. Never calls `process.exit`.
- **`credentials.ts`** — `CredentialsProvider` interface. Required: `getAuth`, `getProject`. Optional: `saveProject`, `updateProject`, `removeProject`, `setActiveProject`, `getActiveProject`, `readWallet`, `saveWallet`, `createWallet`, `getWalletPath`.
- **`namespaces/*.ts`** — One class per resource group (pay, projects, assets, functions, jobs, email, CI/OIDC, identityLinks, …). `identityLinks` represents protocol-discriminated public Nostr attribution for human and agent principals without accepting secret material or changing authority; agent creation remains the EOA-plus-kind-1 SDK ceremony, while human creation/revocation is browser-canonical. Lists preserve multiple active and revoked subjects. `r.pay.fetch` is the bounded arbitrary-URL x402 buyer; `requireReceipt` verifies wallet-rooted offer/receipt evidence and `payFetchResultToJson` is the canonical `x402-commerce-result.v1` serializer the CLI prints. Namespaces hold a `Client` and expose typed methods. The canonical apply primitive is implemented in **`namespaces/deploy.ts`**; the public-facing symbol is `r.project(id).apply`, with no public `r.deploy` surface. The internal engine is `_applyEngine`. Shared types live in `deploy.types.ts` — see "Unified Apply" below.
- **Timestamp contract.** Public API and SDK response timestamps are ISO-8601 strings (`created_at`, `updated_at`, `expires_at`, `lease_expires_at`, `timestamp`, etc.), nullable only when the gateway state is genuinely absent. Public SDK types must never expose JavaScript `Date` objects or numeric epochs for absolute instants. Public absolute-time inputs such as `since` should be ISO-8601 strings too; legacy numeric epochs are allowed only when the route/SDK docs explicitly name the compatibility path. Numeric time values are only for relative durations or local measurements and must carry units in the field name (`expires_in`, `duration_ms`, `elapsedMs`, `ttl_seconds`). Local credential/session caches may store epoch milliseconds internally, but CLI/user-visible output converts them back to ISO strings. `sdk/src/timestamp-conventions.test.ts` enforces this.
- **`node/*.ts`** — Node-only entry point (`@run402/sdk/node`). Wraps `core/` keystore + wallet into `NodeCredentialsProvider`. `createLazyPaidFetch()` resolves one deterministic payer source: explicit opaque `paymentSigner`, explicit `walletPath`, the supplied provider's `readWallet`, or the default Node provider. Auth and payer may differ intentionally; a selected payer never falls back to an ambient wallet, and `r.paymentPayer()` exposes only source/rail/public address/network provenance. Balance preflight uses bounded independent RPC failover without converting failure to zero. Automatic x402 calls commit a redacted mode-0600 record under the active profile's `payment-attempts/` directory before provider dispatch; `PaymentAttemptError` distinguishes safe pre-dispatch failures from ambiguous post-dispatch outcomes. `r.pay.fetch` may retain a proof in memory for an identical same-instance retry, but the reserved attempt-id header is sent only on the signed request with redirects disabled and the journal never stores keys, signed headers/proofs, bodies, query strings, or raw causes. Adds `fileSetFromDir(path)` and the deploy manifest adapter (`loadDeployManifest(path)`, `normalizeDeployManifest(input)`), plus the local file-reference check every `up` mode and `run402 deploy` run before any gateway call: `collectLocalFileReferences(spec)`, `findMissingLocalFileReferences(spec)`, `assertLocalFileReferencesExist(spec, { manifestPath? })` (throws `MANIFEST_FILE_MISSING` with `details.missing[]` of `{ field_path, path, kind }` and one `create_file` next action per file) and the `manifestFileMissingError` / `manifestNotFoundError` builders (`MANIFEST_NOT_FOUND` for a missing `manifest` path). `node/client-detect.ts` is the one client-detection table: `KNOWN_CLIENT_MARKERS` (`claude-code`, `codex`, `cursor`, `grok` with their env markers, first truthy marker wins) behind `RUN402_CLIENT=<name>` (`CLIENT_OVERRIDE_ENV`, checked first, declares a client with no marker of its own); `up` applies the detected client as the principal's display name only when it has none, and `result.identity` always reports `detected` plus `detection: { applied, reason }` (`applied` | `name_already_set` | `explicit_name_wins` | `nothing_detected`).
- **`scoped.ts`** — `ScopedRun402` sub-client. Returned by `r.project(id?)` and `r.useProject(id)`. Wraps every project-id-bearing namespace method with the id pre-bound, so `p.apply({ site })` (no `project_id`), `p.functions.list()`, `p.jobs.get(jobId)`, `p.assets.put(key, src)` all "just work" once the scope is set. Caller-supplied `project_id` / `project` still wins (override-friendly). The unwrapped namespaces (`r.assets`, `r.functions`, `r.jobs`, …) keep their required-id signatures unchanged — scoped is sugar, not a replacement. The apply primitive is only exposed via the scoped client (`r.project(id).apply(spec)`); there is no public `r.deploy`.
- **`namespaces/org.ts` + `namespaces/grants.ts` + `namespaces/wallets.ts`** — Org-owned control plane. `r.orgs` is the org collection + control-plane identity (`create`, `list`, `whoami`); `r.org(id)` is the scoped per-org sub-client (the org analog of `r.project(id)` — `get`, `rename`, `members.*`, `invites.*`, `audit`). `r.grants` is per-project capability grants and the grant keys minted against them (`create` with an optional first `key`, `list` with keys nested, `revoke`, `createKey`, `revokeKey`, `rotateKey`) for agent/CI principals, also reachable as `r.project(id).grants`; a grant key's bearer rides `RUN402_GRANT_KEY` (`sdk/src/grant-key-credentials.ts`, the `grantKey` option of `run402()` from `@run402/sdk/node`). `r.wallets` carries the signed server-side wallet label (`getLabel`, `setLabel`, gateway `/wallets/v1/:address/label`) surfaced in the console. A principal *authenticates* via SIWX but its org-membership role (or a per-project grant) decides authorization — never `wallet_address == signer`.

### Project-scoped sub-client (`r.project(id?)`)

- `r.project(id)` — bind to an explicit id, **synchronously** (returns `ScopedRun402`, so `r.project(id).apply(spec)` needs no `await`; awaiting it is a harmless no-op); no keystore lookup at construction (lazy: errors surface from the first method call that needs keys).
- `r.project()` — resolve from `credentials.getActiveProject()`; this form is async and returns `Promise<ScopedRun402>`. Throws `LocalError` (context: "scoping client to project") when the provider has no active-project state or returns null.
- `r.useProject(id)` — sugar: `r.projects.use(id)` + `r.project(id)` in one call. Mutates persistent keystore state (the active project is shared with concurrent CLI runs); use `r.project(id)` for transient in-script scoping.
- The drift-protection test in `sdk/src/scoped.test.ts` asserts every project-id-bearing namespace method has a corresponding wrapper — adding a new method without a wrapper fails CI.

### Unified Apply

- **`namespaces/deploy.ts`** — `Deploy` class (the internal `_applyEngine`; not exposed as `r.deploy`). The public surface is the scoped `r.project(id).apply` hero, callable with `.plan`/`.start`/`.resume` sub-methods. Three layers:
  - `r.project(id).apply(spec, opts?)` — one-shot, awaits to terminal (most agents use this).
  - `r.project(id).apply.start(spec, opts?)` — returns a `DeployOperation` with `events()` async iterator + `result()` promise.
  - `r.project(id).apply.plan` / `upload` / `commit` — low-level steps for CLI debugging.
  - Plus `r.project(id).apply.resume(operationId)`, `status`, `getRelease`, `getActiveRelease`, `diff`.
- **First-class `assets` slice.** `ReleaseSpec.assets` joins `site` / `functions` / `database` / `secrets` as a top-level slice with the same atomic guarantees. Two shapes: `{ put: AssetPutEntry[] }` (additive batch) and `{ put: [...], sync: { prefix, prune: true, confirm? } }` (declarative sync with prune-confirmation-token semantics; gateway re-checks via `ASSET_SYNC_DRIFT` on activation). All bytes ride through the same CAS substrate as the site slice — only new shas trigger S3 PUTs. The Node-only `NodeAssets` enrichment (`r.assets.uploadDir` / `syncDir` / `prepareDir` / `putMany`) walks directories and streams SHA-256s before submitting through this same slice. `LocalDirRef` (from `dir(path)`) is SDK-input-only and never leaks to the wire (HTTP 400 `INVALID_WIRE_SCHEMA` if it does).
- **Safe release-race retries are SDK-owned.** `apply` automatically re-plans and retries omitted/current-base specs on `BASE_RELEASE_CONFLICT` only when the gateway marks the error `safe_to_retry: true`. Default budget is 2 retries after the initial attempt; `maxRetries: 0` opts out. Each retry emits `deploy.retry`; exhausted retries preserve the last `Run402DeployError` and add `attempts`, `maxRetries`, and `lastRetryCode`. The transient codes in `APPLY_TRANSIENT_RETRY_CODES` (`OPERATION_INTERRUPTED`, `IMAGE_ENCODE_TIMEOUT`, `TOO_MANY_ENCODES_QUEUED`, `EXPORT_CONSISTENCY_UNAVAILABLE`) re-apply on `safe_to_retry: true` for any base (they also arrive as plain `ApiError`s from upload-phase requests), with a 2s-15s backoff; content plans and operation polls lost to 502/503/504 or a dropped connection repeat in place (`requestWithTransientRetry`). Static activation/spec failures reported from `activation_pending` throw immediately with gateway metadata preserved.
- **All bytes ride through CAS.** The plan request body never carries inline bytes — only `ContentRef` objects. When the normalized spec exceeds 5 MB JSON, the SDK uploads the manifest itself as a CAS object and references it (`manifest_ref` escape hatch — no body-size cliff).
- **Manifest adapters live in the Node SDK.** `loadDeployManifest(path)` and `normalizeDeployManifest(input)` accept the agent/CLI/MCP JSON shape (`project_id`, `{data,encoding,content_type?}`, `{path,content_type?}`, `config.timeout_seconds`, `require_auth` / `require_role`, migration `sql_path` / `sql_file`) and return an SDK-native camelCase `ReleaseSpec` plus optional `idempotencyKey`. CLI/MCP should call these helpers instead of reimplementing adapter logic. A `manifest` path that does not exist is a typed `MANIFEST_NOT_FOUND` (with a `create_manifest` next action); with no manifest in the directory, `up` answers `UP_MANIFEST_REQUIRED` naming `run402.json` and listing `details.nearby_manifests[]` one directory down with a `run_in_directory` next action.
- **Strict/no-op validation lives in the SDK.** `Deploy.validateSpec` rejects unknown raw `ReleaseSpec` fields before normalization can drop them, and rejects project/base-only or empty nested specs with `MANIFEST_EMPTY` before hashing, uploading, or planning. String sources paired with known binary paths/MIME types fail locally with `BINARY_CONTENT_REQUIRES_BYTES`; binary files must be read without a text encoding and supplied as bytes or filesystem sources. The Node manifest adapter is strict too, so agent JSON typos such as `subdomain` or `sqlPath` do not become partial deploys.
- **Replace vs patch semantics per resource.** `site.replace` = "this is the whole site" (files absent are removed in the new release); `site.patch.put` / `patch.delete` = surgical updates; `site.public_paths` is the direct browser reachability table for static assets. Explicit mode uses a complete map such as `{ "/events": { asset: "events.html", cache_class: "html" } }`, so `/events` serves the release asset `events.html` while `/events.html` is not public unless separately declared. `mode: "implicit"` restores filename-derived reachability and can widen access. Public-path-only site specs are deployable. Same replace/patch split for `functions`. Secrets are value-free declarations: `secrets.require` asserts keys already exist, and `secrets.delete` removes keys at activation. Set secret values out-of-band through the secrets API. `subdomains` use `set` / `add` / `remove`. `routes` is `undefined | null | { replace: RouteSpec[] }`: omitted/null carries forward base routes, `replace: []` clears dynamic routes, and route entries target materialized functions with `{ type: "function", name }` or exact method-aware static aliases with `{ type: "static", file: "events.html" }`, where `file` is a release asset path, not a public path, URL, CAS hash, rewrite, or redirect. Prefer `site.public_paths` for ordinary clean static URLs. Top-level absence = leave untouched.
- **Structured warnings.** Plan responses include `warnings: WarningEntry[]`. `apply` emits `plan.warnings` and aborts before upload/commit when a warning requires confirmation (including `MISSING_REQUIRED_SECRET`) unless the caller explicitly passes broad `allowWarnings` or covers every blocking warning through `allowWarningCodes`. Only warnings the gateway marks `requires_confirmation: true` block; `PUBLIC_ROUTED_FUNCTION` is informational (`requires_confirmation: false`), so no CLI/MCP guidance or doc surface teaches `--allow-warning PUBLIC_ROUTED_FUNCTION`. The SDK-side `WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS` lint (a GET/HEAD final-wildcard function route) is acknowledged only through `allowWarningCodes` / `--allow-warning` / `allow_warning_codes`; there is no route-level acknowledgement field.
- **Tier preflight.** After normalization and before manifest CAS upload or deploy planning, deploy checks literal function timeout, memory, cron interval, and scheduled-function count when limits are known from `tier.status()` or the static fallback. Local cap failures are structured `Run402DeployError.code === "BAD_FIELD"` with field/value/tier/limit details and `limit_source`; gateway validation remains authoritative.
- **Server-authoritative dry-runs.** `r.project(id).apply.plan(spec, { dryRun: true })` calls `POST /apply/v1/plans?dry_run=true`; the gateway returns the v2 flat plan envelope without creating plan or operation rows, so `plan_id` and `operation_id` are `null` and the response cannot be uploaded or committed.
- **Release observability.** `getRelease({ project, releaseId, siteLimit? })`, `getActiveRelease({ project, siteLimit? })`, `diff({ project, from, to, limit? })`, and `resolve({ project, url|host, method? })` are typed apikey reads over `/apply/v1/releases*` and `/apply/v1/resolve`. `active` means the current-live target; inventories expose materialized routes, `static_public_paths` when returned, and warnings when returned. `site.paths` is the release static asset inventory; `static_public_paths[]` is the browser reachability inventory with `public_path`, `asset_path`, `reachability_authority`, `direct`, cache class, and content type. Resolve diagnostics preserve stable-host fields such as `authorization_result`, `cas_object`, `response_variant`, `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`; known match literals include `active_release_missing`, `unsupported_manifest_version`, `route_function`, `route_static_alias`, and `route_method_miss`, and known fallback states include `negative_cache_hit`, but future strings remain valid. Release-to-release diffs expose `migrations.applied_between_releases`, not plan migration buckets. Secret diffs expose keys only; route diffs expose `added` / `removed` / `changed`.
- **Server-authoritative manifest digest.** The gateway returns the canonical digest in the plan response; `canonicalize.ts` is a UX helper only.
- **Rehearsal is automatic and the skip reason is typed.** `apply()` / `r.up()` rehearse a migration-bearing plan against a project with a live release on a contained branch and commit only on a passing report; otherwise the result carries `rehearsal: { status: "skipped", reason }` with `reason` one of `no_live_release`, `no_migrations`, `migrations_unchanged`, `disabled` (`noRehearse`), `reviewed_plan`, `unsupported`. The plan envelope's `rehearsal.reason` is `null | no_migrations | no_live_release | migrations_unchanged`; `migrations_unchanged` is gateway-supplied, and `deploy.ts` also derives it from the plan's `{ new, noop }` migration buckets (`isModernPlanMigrationDiff`) when `new` is empty against an older gateway, so a page-only redeploy that still carries its migrations never spends a snapshot and a branch.
- **Convenience shims.** `sites.deployDir` is a Node-only wrapper that uses `fileSetFromDir(dir)` and hands off to `apply`; its event callback emits only unified `DeployEvent` shapes.
- **MCP/CLI surface.** The MCP `deploy` tool exposes the apply primitive directly; resume, rehearse, release reads, and the URL resolver are `run` snippets over `r.project(id).apply.resume|rehearse|getRelease|getActiveRelease|diff|resolve`. The CLI verb is bare `run402 deploy` (the deploy itself); `run402 deploy rehearse <plan_id>`, `resume <operation_id>`, `status <operation_id>`, `releases <get <release_id>|active|diff>`, and `resolve <url>` (in `cli/lib/deploy-v2.mjs`) are the same SDK calls. `run402 up` does missing setup and then runs the same deploy; `run402 deploy` only deploys. Snapshot and branch primitives live in `cli/lib/snapshots.mjs` and `cli/lib/branches.mjs`. Use a v2 `ReleaseSpec` through `deploy` (MCP) / `run402 deploy` (CLI).
- **Function-level auth gates.** `FunctionSpec` carries two optional declarative fields: `requireAuth?: boolean` and `requireRole?: RequireRoleSpec | null`. When set, the gateway enforces them before invoking the function — unauthorized callers get `401`/`403` without the function body running, and the gateway injects `x-run402-user-id` (always when any gate ran) and `x-run402-user-role` (only when `requireRole` ran) into the request. In-function code reads them directly from `req.headers.get("x-run402-user-id")` / `req.headers.get("x-run402-user-role")` (the bare `getUserId(req)` / `getRole(req)` exports throw `R402_AUTH_UNKNOWN_EXPORT`; for the canonical cookie-session flow see the auth-aware SSR bullet below). `requireRole` describes a single-table lookup against the project schema (`table`, `idColumn`, `roleColumn`, `allowed[]`, optional `cacheTtl` in seconds — default 60, max 600, 0 disables). All `requireRole` blocks in a release must share the same `(table, idColumn, roleColumn)` triple — gateway rejects mixed tables at plan time. Schema-qualified identifiers, empty `allowed`, and out-of-range `cacheTtl` are rejected with the canonical `INVALID_SPEC` envelope; missing-table at activation fails with `DEPLOY_INVALID_ROLE_GATE` (422) before flipping the live release. SDK does not validate shape — gateway is authoritative.
- **Astro SSR runtime + ISR cache.** `FunctionSpec.class?: "ssr" | "standard"` opts a function into the SSR class — gateway provisions it with SnapStart enabled and reverse-validates the published version before activation. Activation surfaces `DEPLOY_FUNCTION_SSR_SNAPSTART_VALIDATION_FAILED` (warning, non-blocking; function still ships, just without SnapStart). Routed invocations to `class: "ssr"` functions go through an origin ISR cache (`internal.ssr_cache`, keyed by `(host, path, search, method, locale, release_id)` with WHATWG-conservative canonicalization). Cache hot path checks `tryServeFromCache` BEFORE invoking; misses go to `storeIfCacheable` AFTER rendering. Hot path is bypass-by-default — only stores when `cacheable: true` (Cache-Control parsing) AND no `Set-Cookie` AND no auth-taint flag (`taintCacheBypass()` from `@run402/functions/runtime-context` — automatically set by every `auth.*` helper and by `withPaymentTaint()` payment primitives). Generation-guarded writes (per-`(project,host)` counter incremented on every invalidate) prevent in-flight MISS renders from clobbering a freshly-cleared state. Single-flight dedup per cache key collapses concurrent misses to one render. Invalidation: `r.cache.invalidate(url)` / `invalidatePrefix({ host, prefix })` / `invalidateAll({ host })` / `invalidateMany(urls)` (SDK), `run402 cache invalidate <url>` / `--prefix` / `--all` (CLI). Inspect: `r.cache.inspect(url)` / `run402 cache inspect <url>` — returns `{ status: 'HIT' | 'MISS', cachedAt, expiresAt, contentSha256, writtenUnderGeneration }`. Host ownership is enforced server-side (`R402_CACHE_INVALIDATION_HOST_FORBIDDEN` 403 on cross-project hosts). Authored via `@run402/astro` 2.0+: `export default run402();` returns an `AstroUserConfig` composing the SSR adapter (Lambda + SnapStart + ISR cache + ALS context) + image integration + build-time detectors (dynamic `<Image>` / server islands / sessions / `<Run402Picture>`) + hosted-auth components (`<SignIn />`, `<SignUp />`, `<UserButton />`, `<AccountSecurity />`, `<SignedIn>`, `<SignedOut>`). 17 R402_* error codes catalog at `astro/README.md` (top section) and `cli/llms-cli-errors.txt` (the errors slice of the CLI reference, R402_* SSR Runtime Error Codes section).
- **auth-aware SSR.** Browser sessions are opaque server-side handles backed by `internal.sessions` — `__Host-Http-r402_session=v1.<session_id>.<secret>` cookie carries no client-readable identity; the gateway resolves it on every request (no positive validity cache, so revocation is instant). The SDK's `auth.*` namespace is the single canonical surface — `auth.user()` / `requireUser()` / `requireRole(role)` / `requireMembership(m)` / `requireFresh({maxAge, amr?})` / `fetch(input, init?)` (same-origin only) / `csrfToken()` / `csrfField()` / `sessions.createResponseFromIdentity({provider, subject, proof, amr, createUser?})` / `sessions.endResponse()` / `identities.link({...})`. Hallucinated names (`getUser`, `getSession`, `currentUser`, `getServerSession`, `auth.protect`, `auth.signIn`, `auth.logout`, …) throw `R402_AUTH_UNKNOWN_EXPORT` at runtime AND fail `run402 doctor` source scan at deploy with structured fix-it pointing at the canonical helper. The `Actor` type uses `id` (not `userId`) as the canonical public user-id field — matches Supabase / Clerk / Auth.js convention; `email_code` passes through its open-ended `amr`/`amrTimes` but is AAL1 and does not satisfy passkey freshness. Cookie + JWT precedence: `R402_AUTH_BEARER_COOKIE_MISMATCH` on actor disagreement, `R402_AUTH_INVALID_BEARER` on valid cookie + malformed Bearer. Origin enforcement is mandatory on cookie-authenticated unsafe-method requests; `Origin: null` always fails; Referer fallback uses full-origin equality. The `@run402/astro` `<SignIn emailDelivery="link|code|both" />` component capability-gates its managed both-mode default, keeps the challenge handle in the originating page, and preserves the emailed link fallback; `<UserButton />` renders the canonical sign-out POST form + double-submit CSRF token from `auth.csrfField()`. Hosted routes include `/auth/sign-in`, `/auth/sign-up`, `/auth/sign-out`, `/auth/re-auth`, `/auth/magic-link/send`, `/auth/email-code/confirm`, and OAuth start. **Never catch `auth.*` errors** — the platform decides 303-vs-envelope from the `Accept` header. **Never write `.eq("user_id", user.id)` against an RLS-bound table** — RLS already binds visitor's rows via `run402.current_user_id()`; the redundant filter is a deploy-fail (`R402_AUTH_REDUNDANT_USER_FILTER`). For per-user gating in functions outside the cookie-session flow, the existing `requireAuth` / `requireRole` deploy-spec gates inject `x-run402-user-id` / `x-run402-user-role` headers — read them from `req.headers.get(...)` directly.

### CI/OIDC Federation (GitHub Actions)

- **`namespaces/ci.ts`** — `/ci/v1/*` SDK surface: `createBinding`, `listBindings`, `getBinding`, `revokeBinding`, `exchangeToken`, plus canonical delegation builders (`buildCiDelegationStatement`, `buildCiDelegationResourceUri`) and validators.
- **`ci-credentials.ts`** — isomorphic CI-session credential providers. `githubActionsCredentials({ projectId })` requests the GitHub OIDC subject token, exchanges it through `ci.exchangeToken`, caches the Run402 session until `expires_in - refreshBeforeSeconds`, and marks credentials with `CI_SESSION_CREDENTIALS`.
- **`node/ci.ts`** — Node-only `signCiDelegation(values, opts?)` (async); reads the local wallet and signs the canonical SIWX delegation for `/ci/v1/bindings` through the wallet's signer. Default delegation chain id is `eip155:84532` unless overridden.
- **Deploy integration is credential-driven.** `Deploy` detects the CI credential marker internally. Do not add public `ci` options, `r.ci.deployApply`, or broad CI deploy wrapper tools without a new design. CI deploys allow only `project_id`, `database`, `functions`, the complete `site` resource including `site.public_paths`, absent/current `base`, and `routes` authorized by the binding's `route_scopes`; every `spec.secrets` shape (including value-free `require`/`delete`), subdomains, checks, unknown top-level fields, non-current base, and `manifest_ref` are rejected before upload/plan. Gateway planning enforces route diffs and nested public-path validation/authorization, returning canonical errors such as `CI_ROUTE_SCOPE_DENIED` for out-of-scope route declarations.
- **CLI DX.** `run402 ci link github` creates a deploy-scoped binding and generated workflow that calls `run402 deploy --manifest <manifest> --project <project>`. Repeatable `--route-scope <pattern>` delegates exact public paths such as `/admin` or final-wildcard prefixes such as `/api/*`; no scopes means no CI route authority. `run402 ci list` and `run402 ci revoke` manage bindings. V1 intentionally omits raw subject/wildcard/event/PR-deploy flags and requires GitHub repository-id binding.

### KyGit: vaults and repos (host-blind encrypted Git remote, `r402s/v0`)

`r.repos` (`sdk/src/namespaces/repos.ts` + `vault.crypto.ts` + `vault.types.ts`, Node-only writes in `sdk/src/node/vault-*.ts`) owns every piece of protocol behavior — crypto core, keystore, creation journal, snapshot + capture, publication state machines, ref transactions, verification budget, repair, `fsck` (verify + materialize + explicit `pin_before`/`pin_after`/`local_state_changed`, `write:false` for `--no-write`), `access` (READ-ONLY recipients/coverage/local-TOFU-pin report), and `listByOrg` (the bulk vaults-by-org read `repos list` uses) — exactly once. `run402 repos …` and `git-remote-run402` are thin adapters over it, and MCP reaches it through `run` snippets. Naming: KyGit is the brand, a **vault** is the resource (`/vaults/v1/*`, `VAULT_*` codes, `vault_policy`, the `vault` apply slice), and a **repo** is what you have (`run402 repos`, `r.repos`); the SDK namespace class is `Repos` and protocol-level types and crypto are `Vault*`. The local keystore lives at `<config dir>/vault/`. `src/tools/vault-copy.test.ts` (the copy gate) enforces that the vault's three approved claim sentences and the terminal-loss statement are reproduced verbatim, never paraphrased, across every doc surface — see `documentation.md`'s KyGit row for the full file list and claim vocabulary.

- **The remote scaffold claims `origin` additively.** `Repos.scaffoldRemote` names the run402 remote `origin` when the repository has none yet — `git push origin main` works with zero run402-specific ceremony — and falls back to `run402` only when `origin` is already taken by something else, never touching an existing remote under either name. `cli/lib/vault-scaffold.mjs` is the shared fold-in `projects provision`, `run402 up`, and `run402 repos create` all call, so they report through identical `vault` / `vault_skipped` / `vault_error` summary keys. `run402 init`'s own git-remote step is separate and does not route through `scaffoldRemote`. When the scaffold has to `git init` because the directory is not a repository yet, it always requests branch `main` (`-b main`, with an `init` + `symbolic-ref HEAD refs/heads/main` fallback for pre-2.28 git) — never whatever `init.defaultBranch` happens to be locally — matching the `git push origin main` every doc surface teaches; an EXISTING repository's branch is never touched. `up.mjs`'s own separate `gitInitIfNeeded` mirrors the identical fallback (it does not route through `scaffoldRemote`). A `repo_dir` that lies INSIDE another repository is reported `skipped` (`inside_other_repository`, the enclosing toplevel named) with a `create_nested_repo` next action; `scaffoldRemote({ nested: true })` (reached as `run402 up --nested` and `run402 repos create --nested --project <project_id>`, both through `cli/lib/vault-scaffold.mjs`'s `nested` / `nestedCommand` options, the latter respelling the next action to the caller's own verb) makes the app root its OWN nested repository instead (`git init -b main` there, the remote added there, the first push from there) and appends exactly one line (`/<relative path>/`) to the ENCLOSING repository's local `.git/info/exclude`, touching nothing else there (no `.gitignore`, index, or submodule); the result reports `nested`, `enclosing_toplevel`, `excluded_in_enclosing`. A `push_repo` next action names a remote that EXISTS, so `repos create` prints none for a remote it skipped.
- **Lazy allocation on first push.** `Repos.openOrCreate({ project_id, org_id?, ... })` resolves `repo_id` like `open()`, and — only when `org_id` is supplied and resolution fails — runs the six-stage creation journal inline before opening. `capture({ org_id, onVaultCreated })` and `git-remote-run402`'s push path both compose it, so `git push origin main` and `repos capture` allocate a vault that does not exist yet, printing the one-shot recovery receipt to stderr the instant it lands. `findResumableVaultJournal` (`sdk/src/node/vault-creation-journal.ts`) makes this resumable: a second call after a crash resumes the SAME local `client_creation_id` rather than racing a fresh one. `list`/`fetch` in the remote helper stay pure reads and never allocate — an unresolved vault reports as an empty ref set, exactly what a fresh repository looks like to git.
- **Allocation never changes deploy policy.** Neither the client nor the gateway writes `vault_policy` at allocation (`setVaultPolicy` behind `PATCH /vaults/v1/:vault_id/policy` is the only writer). A vault with unset policy gets its own `{ kind: "ungated" }` mode in `applyWithVault` (`sdk/src/node/vault-apply.ts`): plain deploy path untouched, result gains a `vault_policy_required` `next_actions` entry and a `VAULT_POLICY_UNSET` `warnings[]` entry, never a block, never an interactive prompt (vision principle 11).
- **`repos capture` is the capture verb; `push` means exactly `git push`.** `r.repos.capture(...)` captures the working tree and publishes it outside a deploy; the vault handle's own `push` is the `git push` publication path `git-remote-run402` drives. "Snapshot" means a project snapshot only. `SKILL.test.ts` bans `repos snapshot` and the `kygit::` scheme from both skill files.
- **The progressive terminal-loss warning.** `vaultLossWarningTrip(record, now?)` / `vaultLossWarningTripped(trip)` / `vaultLossWarningMessage(trip)` (pure, exported from `repos.ts`) compute an OR-composite over `VAULT_LOSS_WARNING_THRESHOLDS` (`generations: 10, source_bytes: 10 MB, days_since_genesis: 14`, tunable in this one place). `status()` folds a tripped result into `warnings[]` as a `terminal_loss_risk` entry; `run402 repos view` and `run402 doctor` echo `warnings[]` verbatim, so neither has its own threshold logic to drift. There is deliberately no "resolved" check: V0-A cannot detect a second principal (another keystore, or later a human envelope) able to open the vault, so nothing here ever clears a tripped warning — the §0 terminal-loss statement itself never changes, only this warning's prominence and cadence do.
- **Named addressing, id-pinning, and push-to-create.** `run402::<org>/<name>` admits two forms in the same wire slot, discriminated by `vaultRemoteAddressForm(address)` (exported from `repos.ts`): id-form (org UUID + `prj_`-prefixed project id — real orgs/projects always satisfy BOTH at once) and slug-form (`run402::<org-slug>/<name>`, e.g. `run402::acme/my-notes`). `Repos.forRepo({org_slug, repo_name})` resolves slug-form via `GET /vaults/v1?repo=<org-slug>/<name>`; `Repos.resolveAddress(address)` dispatches on form (pure read, no pin, no create). **Id-pinning:** `Repos.resolveOrCreateAddress` (the orchestrator the remote helper and `repos capture`'s slug-form capture drive) pins the resolved `repo_id` into this checkout's LOCAL git config (`r402.repoId` + `r402.repoAddress`, via Node-only `sdk/src/node/vault-address.ts`'s `pinVaultRepo`/`readPinnedVaultRepo`) the first time a slug-form address resolves — every later open on that checkout follows the pin directly, skipping the network resolution and surviving a later rename of either half. Id-form pins nothing (a project id already never changes, so pinning buys it nothing). `run402 repos view` reports `pinned: {repo_id, resolved_from}`. **Push-to-create:** `sdk/src/node/vault-push-to-create.ts`'s `pushToCreateVault` drives the same six-stage creation journal (`vault-creation-journal.ts`), addressed by an optional `push_to_create: {org_slug, repo_name}` in place of `{org_id, project_id}` — the journal's `org_id`/`project_id` are `string | null` and trust-on-first-use pin from the allocation response. `POST /vaults/v1 {org_slug, repo_name}` creates the project and allocates the vault atomically server-side; on `REPO_CREATION_CONFLICT` (a concurrent pusher raced the atomic name-claim) the loser polls briefly (`pollFindVaultByProject`) and resolves to the winner's `repo_id`, proceeding as an ordinary push — the loser's work is not lost, it just wasn't the creator, never a transparent retarget. `SLUG_RELEASED` (`vaultSlugReleasedInfo(err)`) is never auto-followed. `Repos.capture()` accepts an optional `address` to route through `resolveOrCreateAddress` instead of the ordinary project_id path. `run402 orgs slug <slug>` (`ScopedOrg.setSlug`, `POST /orgs/v1/:org_id/slug`, owner-only, client-generated `Idempotency-Key`) claims/renames the org's slug; `run402 repos rename <name>` (`Projects.setRepoName`, `POST /projects/v1/:id/repo-name`, no fee) claims the per-org-unique address-form project name; `repos create` best-effort-claims one too when the owning org already has a slug. Both are CLI/SDK-only (`mcp: null`), the same "mutating verbs are CLI-only" law as `repos create/delete` below.
- **`run402 repos create|list|view|rename|delete|capture|policy|mirror|fsck|gc|access|recover|handoff|resume|invite|join`** — CLI + OpenClaw; from MCP, `run` snippets over `r.repos`. `cli/lib/repos.mjs` (+ `openclaw/scripts/repos.mjs` re-export) is the family's home. `create` composes `projects.provision` + `repos.init` (allocation happens immediately, NOT lazily) + `repos.scaffoldRemote`, with zero deploy ceremony; `--project <project_id>` adopts an EXISTING project instead of provisioning, and `[name]` is inferred from an existing git remote's basename or the directory name when unambiguous (never a prompt; disagreement or a dead end is a structured `REPOS_NAME_AMBIGUOUS`/`REPOS_NAME_REQUIRED` error). `list` prefers `Repos.listByOrg` (the bulk vaults-by-org read, FROZEN shape) and falls back to a client-side per-project N+1 read on a 404. `view` never passes `refs: true` to `Repos.status` — it is side-effect-free by construction. `fsck` is where materialization and pin advance happen, via `Repos.fsck({write, mirror})` (`write: false` is `--no-write`'s genuine audit mode — the SDK's `verifyToNewest`/`materialize` both accept a `persist` option that skips every `keystore.updateRepo` write while still computing the real answer). `gc` composes `compact` + `prune` (plan by default; `--submit` skips compact and submits only). `access` composes `listOrgEncryptionKeys` + `listEnvelopeRecipients` (both isomorphic HTTP reads) plus a best-effort, Node-only local TOFU-pin read via `Repos.access` — never wraps a key; the read reports per-recipient `envelope_state` and `stale_access` from the gateway's `desired[]` state. `access repair`, `access revoke-key`, and `access declare-exposure` are the epoch-rotation entry points (rev 42, D193–D203) — repair drives `Repos.rotateEpoch` against the D194 frozen watermark pair and is JSON-only. `delete` refuses `PROJECT_HAS_NON_REPO_RESOURCES` when `projects.get`/`getSchema`/`functions.list`/`secrets.list`/`subdomains.list` show a materialized mailbox/custom-domain/schema/functions/secrets/subdomains — using the SAME service-key credential `projects.delete` itself requires, so a 404 reads as genuinely absent and any other failure refuses as `unknown` (never a silent yes); `--force` overrides ONLY the separate vault-history confirmation. The verbs that mint or redeem a one-time secret (`handoff`, `invite`, `resume`, `join`) refuse on the sandbox surface with `SECRET_REQUIRES_CLI`, so an MCP `run` snippet gets the `run402 repos` command to hand the person; `create` (one-shot recovery receipt) and `delete` (irreversible) are run from the CLI. When the best-effort address-form name claim doesn't land, `create` names WHY through a typed `next_actions` entry rather than a bare `address: null`: `set_org_slug` (pointing at `run402 orgs slug <slug>`) when the owning org has no slug yet, `set_repo_name` (pointing at `run402 repos rename <name> --project <project_id>`) when it does but this project's own claim missed; `create`'s response also carries a `push_repo` next_action naming the exact `git push` to run, only when the remote was actually added (a remote skipped as `inside_other_repository` gets the `create_nested_repo` next action instead, and `create --nested --project <project_id>` is the way out). `create` never names a monorepo app after the enclosing repository's `origin`.
- **Handoff / resume — a bearer-key session pass, no shared credential (kygit-handoff design D1-D3/D10).** `run402 repos handoff` captures the WORKING TREE (not the last capture) into a synthetic 3-parent stash-shaped commit — outer tree = tracked worktree state, parent1 = base HEAD, parent2 = an index-tree commit, parent3 = a parentless untracked-tree commit, mirroring real `git stash push -u` plumbing exactly — pushes it into the vault as a retention root (no ref, so it never pollutes `git log`/branch listings), and mints a single-use bearer `kgh1_<base64url(handoff_id[16] ‖ master_secret[32])>` (`sdk/src/node/vault-handoff.ts`). HKDF-SHA256(ikm=master_secret, salt=handoff_id) derives `auth_secret` (the gateway only ever sees its SHA-256 hash) and `wrap_key` (seals a small AEAD envelope carrying the vault's live epoch key `K_e` directly to the recipient — a DIFFERENT, deliberately simpler format than an `r402s/v0` vault object: no JCS, no 7-field frameAad, its own `KGH1` frame). The key is printed to stdout EXACTLY ONCE and never appears in any log or error path — `--json` still omits it from stderr; only `console.log(result.handoff_key)` ever emits it. `run402 repos resume <kgh1_…>` redeems it (`POST /vaults/v1/handoffs/:handoff_id/redeem` — one-shot; a same-principal replay is a safe dedup, not an error), opens the envelope, clones fresh (`cloneVaultRemote` in `sdk/src/node/vault-restore.ts` deliberately bypasses `hardenedGit`'s `-c protocol.allow=never`, since the clone target here IS the trusted vault remote, not an arbitrary URL), and reapplies the checkpoint with `git stash apply --index` — so resume is bit-for-bit what the sender was looking at, staged/unstaged/untracked distinction included. A Handoff Note (`kygit.handoff-note.v1` — summary, completed/in-progress/failing, decisions, next steps) rides alongside; `--note-file` or piped stdin on `handoff`, rendered as Markdown by default on `resume` (`--json` for the raw envelope). Sensitive untracked paths (`.env`, `.env.*`, `*.pem`, `*.key`, `id_rsa*`, `id_ed25519*`, `.aws/**`, `.ssh/**`, `.gnupg/**`, `*.tfstate*`, and 13 more — `VAULT_HANDOFF_SENSITIVE_DENYLIST` in `sdk/src/node/vault-snapshot.ts`, 22 globs total) are excluded from capture by default; `--include-sensitive <glob>` (repeatable) re-admits one explicitly. `handoff` mints at the SENDER's own role (never escalates); a minted `owner` key is real org authority until redeemed or expired — the mint response's `warnings[]` says so verbatim, and the CLI echoes it to stderr before printing the key. Both refuse on the sandbox surface with `SECRET_REQUIRES_CLI` (they mint or spend a bearer secret and mutate org membership), so an MCP `run` snippet gets the CLI command instead.
- **Invite / join — a second redeem kind beside handoff, the same machinery, collaboration instead of continuity (kygit-invite design D1-D9).** `sdk/src/node/vault-handoff.ts` is kind-parameterized (`RedeemKind = "handoff" | "invite"`) without renaming a single shipped export: `HANDOFF_KEY_PREFIXES` carries `kgi1_`/`invite`/`join` as its second row; `parseRedeemKey(raw, expectedKind)` is the one parser, with `parseHandoffKey`/`parseInviteKey` as kind-bound aliases that refuse a sibling prefix BY NAME pointing at its OWN verb (`HANDOFF_KEY_WRONG_KIND` from `join`'s key handed to `resume`; `INVITE_KEY_WRONG_KIND` from `resume`'s key handed to `join`) — never contacting the gateway. HKDF info strings and the auth-hash label are kind-embedded (`kygit/<kind>/auth/v1`, `kygit/<kind>/wrap/v1`, `kygit/<kind>/auth-hash/v1`), so an invite secret never verifies as a handoff hash or the reverse. The envelope frame is `KGI1` (payload `kygit-invite-envelope-v1`, `note_schema: "kygit.invite-note.v1"`) — a THIRD frame format, distinct from both `r402s/v0`'s `R402S0` object frame and the handoff bridge's own `KGH1`. `run402 repos invite [--room <key>]` captures the checkpoint exactly like `handoff` (inviter's worktree/index/branch/refs/access all untouched), registers the INVITER's own presence in the invite's room (`Rooms.registerPresence`, constructed inline in `sdk/src/namespaces/repos.ts`'s `Repos.invite`, since the SDK's `rooms` namespace is not otherwise exposed on `Repos`) BEFORE minting so the row can carry `inviter_presence_id`, mints (`POST …/invites`, default role `developer`, never wider than the minter's own), and posts ONE room message from that presence naming the checkpoint and invite id (never the key) — a presence or message-post failure is reported (`inviter_presence`/`room_message` on the result) and NEVER voids the mint. `run402 repos join <kgi1_…>` folds the SAME cold-start chain `resume` does (the CLI composes `foldColdStartForResume` before calling `Repos.join`; the SDK method itself only backstops a bare wallet via `#ensureLocalWallet`, identical to `resume`), redeems (`POST /vaults/v1/invites/:invite_id/redeem`), opens the `KGI1` envelope, clones + `git stash apply --index` exactly like `resume`, pins `r402.room` to the INVITE's own room key (`pinVaultRepo`'s `ids.room_key`, defaulting to the project id when omitted), appends `.run402/` to `.git/info/exclude` (`excludeMessagingCacheFromGit` in `sdk/src/node/vault-restore.ts` — NEVER `.gitignore`, which is part of the captured tree; `resume` gained the identical call, since restore is kind-agnostic), registers this session's OWN presence, posts ONE arrival message, and returns the inviter (name/labels/liveness), `live_presences[]`, the catch-up `cursor`, and the last few `recent_messages[]` so the joiner's first message needs no lookup. `run402 repos view` reports `messaging_cache_excluded` (read-only counterpart `isMessagingCacheExcludedFromGit`) and the pinned `room` alongside `repo_id`/`resolved_from`. Both refuse on the sandbox surface with `SECRET_REQUIRES_CLI`, like `handoff`/`resume`; `errors.ts`'s `NextActionType` gains `join_invite` (`run402 repos join …`), `revoke_invite`, `wait_room`, `send_room_message`.
- **`run402 messages wait` — the blocking read, built on the SAME held-read query parameter as `messages list` (kygit-invite design D6/D7).** `rooms.ts`'s `Rooms.waitForMessages(orgId, roomKey, opts)` calls `listMessages` with `wait=<1..25>` (gateway-clamped) and decides its OWN next poll interval BY EVIDENCE on every read, never by a version check: a page carrying `waited_ms` proves the gateway held the request, so the next call is a zero-sleep held re-read; a page with NO `waited_ms` proves an older gateway that ignored the parameter, so the client falls back to bounded polling (`pollMs`, default 5000, floored at 1000 — the SAME anti-hammering floor the shared `waitFor` helper applies) until the timeout budget (default 120s via the CLI, matching a coding harness's own tool-call ceiling) is exhausted. Silence is an answer, never a throw: on timeout the LAST OBSERVED (empty) page returns with `settled: false` and the UNCHANGED cursor — the same contract `waitFor` documents. `live_presences[]` comes from the held page's own rider when present, else one best-effort `listPresences` fallback read. `ScopedRoom.waitForMessages` delegates with `(orgId, roomKey)` pre-bound. The CLI's `run402 messages wait` is a thin single call into this — no client-side loop of its own — persisting the returned cursor through the SAME per-checkout `.run402/messaging.json` state `messages list` uses. From MCP it is a `run` snippet: `r.rooms.waitForMessages(...)` inside one `run`, whose deadline bounds the wait.
- **One remote scheme, `run402::`.** `vaultRemoteUrl`/`vaultRemoteUrlForRepo` (`sdk/src/namespaces/repos.ts`) render `run402::<org>/<name>`; `parseVaultRemoteUrl` accepts `run402::` only and refuses any other `<scheme>::` address by name (`REMOTE_SCHEME_UNSUPPORTED`), which the remote scans in `Repos.status()` and `cli/lib/vault-target.mjs` skip. `git-remote-run402` is the one remote helper; `repos resume` and `repos join` add an `install_remote_helper` next action naming `npm i -g run402` when it is not on PATH (`cli/lib/path-lookup.mjs`). kygit.com is the human door and teaches `run402 repos`.
- **Vault verb targeting, when standing inside a repository.** `resolveVaultTarget`/`repoOwnVaultTarget` (`sdk/src/node/vault-target.ts`, exported from `@run402/sdk/node`) are the ONE shared resolver `run402 repos view|capture|policy|mirror|fsck|gc|access` (via `repos.mjs`'s `vaultTarget`, through the thin `cli/lib/vault-target.mjs`) and `r.doctor()`'s vault check both compose — targeting policy, not vault protocol. Order, highest first: an explicit `--repo`/`--project` flag (doctor has `--project` only — no `--repo`) > the id-pin (`readPinnedVaultRepo` — addresses by `repo_id` directly, no network) > the repo's own `run402`/`origin` remote (id-form parsed out of the address string for free; slug-form resolved via one read-only `repos.resolveAddress` call, never a pin, never push-to-create) > `RUN402_PROJECT_ID` env > the active project. An explicit flag that disagrees with the repo's own remote still wins, but prints a one-line stderr warning naming both first. `repos create` is deliberately NOT wired through this resolver — its `--project` addressing takes no `--repo`; it resolves via env/active project only. `Repos.status()`'s `remote` field checks both the `run402` and `origin` remote names.

- **`git-remote-run402` shares wallet resolution with the CLI.** The resolution is the Node SDK's (`resolveWalletSelection`/`assertWalletExists` in `sdk/src/node/wallets.ts`, throwing `WalletSelectionError`, never `process.exit()`, which can truncate a pending stdout write mid-protocol-stream); `cli/lib/wallet-context.mjs`'s `resolveWalletCore`/`enforceWalletExistsCore` only add which commands are exempt, and BOTH `cli.mjs`'s `resolveWallet`/`enforceWalletExists` wrappers (translate to `fail()`) and `git-remote-run402.mjs` (translate to `note()` + a non-throwing return) compose them; one implementation, two error-reporting strategies. The binding walk starts from the RESOLVED REPOSITORY directory for `fetch`/`push` (not `process.cwd()` — the same fail-closed reasoning `resolveGitInvocationRepo` already applies to repository resolution: during `git clone` cwd is wherever clone was run FROM), falling back to cwd only for `list` (repository-free, same tier as `capabilities`/`option`). The missing-wallet remedy (`core/src/wallet.ts`'s "Back up the file and run `run402 init`") names the resolved wallet and how it was selected, and only keeps pointing at `init` when the bare default really was never overridden anywhere.
- **Active-project scope.** `NodeCredentialsProvider.setActiveProject`/`getActiveProject` (what `projects.provision`/`projects.use` write through) scope the active-project pointer by `{api_base, profile, principal}` — the wallet's real address. `core/src/keystore.ts`'s `getActiveProjectId`/`setActiveProjectId` — what EVERY CLI-facing reader goes through instead (`resolveProjectId`, `projects current`, `vault-target.mjs`'s active-project fallback) — derives the SAME principal from the current wallet before every read and write, so the two sides agree.

### Shared Core (`core/src/`)

The `core/` module contains shared logic imported by all interfaces:

- **`config.ts`** — Path resolution and env vars: `getApiBase()`, `getConfigDir()`, `getKeystorePath()`, `getWalletPath()`. `getWalletPath()` (and profile enumeration) first runs `moveWalletFiles()`, the one-time local move that renames each profile's previous `allowance.json` to `wallet.json` (atomic rename, only when `wallet.json` is absent, then 0600); nothing reads the previous name afterwards.
- **`wallet.ts`** — `readWallet()`, `saveWallet()` with atomic writes (temp-file + rename, mode 0600).
- **`wallet-auth.ts`** — EIP-191 signing with `@noble/curves`. `getWalletAuthHeaders()` returns headers or null.
- **`keystore.ts`** — Unified project credential store. Object schema: `{projects: {id: {anon_key, service_key, tier, lease_expires_at}}}`. Auto-migrates legacy array format and `expires_at` → `lease_expires_at`. Functions: `loadKeyStore()`, `saveKeyStore()`, `getProject()`, `saveProject()`, `removeProject()`.
- **`control-plane-session.ts`** — the **sign-in session** cache: a person's one session (wire token class `control_plane_session`), bound to the principal and graded by provenance (`grade`: `loopback` from `run402 login`, full and step-up-able; `device` from `run402 login --device`, read-only — the gateway answers every mutation `SESSION_READ_ONLY`). `readControlPlaneSession()`, `saveControlPlaneSession()`, `clearControlPlaneSession()`, `loadLiveControlPlaneSession()`, `controlPlaneSessionFromTokenResponse()` over `{base}/control-plane-session.json` (mode 0600, **base** config dir — principal-scoped, shared across named wallets); `clearRetiredSessionCaches()` deletes stale `{base}/operator-session.json` / `{base}/write-auth-session.json` files on `login`/`logout`. Carried SDK-wide via `controlPlaneSessionCredentials({ token })`. The SDK side is `r.session.*` (`sdk/src/namespaces/session.ts`): the CLI seams (`buildCliAuthorizeUrl`/`exchangeCliToken`, `deviceStart`/`devicePoll`) and the browser surface (email magic link / passkey / OAuth / step-up / authenticators / recovery; console-side, no MCP/CLI verb). The CLI verbs are `run402 login [--device]`, `logout`, `whoami` (`cli/lib/sign-in.mjs` holds the shared loopback machinery).
- **`write-approvals.ts`** — the **write approval** cache: passkey-signed, `(action, target)`-scoped tokens a person signed in without a wallet mints with `run402 approve` (`r.writeApproval.requestChallenge`/`exchangeClaimCode`) to provision, deploy, or write secrets. Multi-entry, keyed by `(api_origin, sign-in session hash, action, target)`, over `{base}/write-approvals.json` (mode 0600). The Node provider attaches a matching one as `X-Run402-Write-Approval` only in `auto`/`session` auth mode; a gated write without one throws `WriteApprovalRequiredError` naming the `run402 approve …` command. A write approval never satisfies step-up.

Core functions return `null` or throw — they never call `process.exit()`. Each interface wraps with its own error behavior.

### Functions library (`@run402/functions` v3.0+)

**Source location: public `run402-core`.** Not in this repo. The package source lives in `kychee-com/run402-core/packages/functions`; the npm package (`@run402/functions`) on the registry is what users install for TypeScript autocomplete and what Run402 Cloud consumes when it bundles deployed functions.

Current v3.0+ contract:

- `getUser` / `getUserId` / `getRole` are not valid bare exports — they throw `R402_AUTH_UNKNOWN_EXPORT` at runtime with a structured fix-it pointing at `auth.*`.
- `auth.sessions.createResponseFromIdentity({ provider, subject, proof, amr, createUser? })` is the only session-creation entry point — the platform verifies the proof end-to-end; agent code never holds a raw `userId`.
- `Actor.id` is canonical (not `userId`).
- Per-AMR freshness: a recent password proof does NOT satisfy `auth.requireFresh({amr: ["passkey"]})`.
- `auth.fetch` is same-origin-only with synchronous URL validation.
- Origin enforcement is mandatory on cookie-authenticated unsafe-method requests.

See `cli/llms-cli-functions.txt` for the full v3.0 in-function helper reference and `cli/llms-cli-errors.txt` for the R402_AUTH_* code catalog (both slices of the CLI reference; `cli/llms-cli-full.txt` has everything in one document).

Quick reference of the public surface (full package docs live in `run402-core`):

- **`auth.user()`** — `Actor | null`. Reads the verified actor from the SSR runtime context. Triggers cache-bypass taint.
- **`auth.requireUser()`** — `Actor`. 303 → `/auth/sign-in?returnTo=` (HTML) or 401 envelope (JSON) on anonymous. Don't catch.
- **`auth.requireRole<const R>(role: R)`** / **`auth.requireMembership<const M>(m: M)`** — gate helpers; imply requireUser; return `{ user, role }` / `{ user, membership }`.
- **`auth.requireFresh({ maxAge, amr? })`** — per-AMR step-up. Recent password proof does NOT satisfy `{amr: ["passkey"]}`.
- **`auth.fetch(input, init?)`** — same-origin-only fetch with synchronous URL validation; `redirect: "manual"` default.
- **`auth.csrfToken()`** / **`auth.csrfField()`** — double-submit token for hosted forms.
- **`auth.sessions.createResponseFromIdentity({ provider, subject, proof, amr, createUser? })`** — custom identity proof bridge.
- **`auth.sessions.endResponse()`** — sign-out.
- **`auth.identities.link({ provider, subject, proof })`** — atomic nonce consumption + identity insert.
- **`db(req?)`** — caller-context PostgREST client. Inside an SSR request with a verified actor, mints a 60s actor JWT (`sub`, `project_id`, `session_id`, `authz_version`) so `run402.current_user_id()` resolves in RLS without any client-side header plumbing.
- **`adminDb()`** — service-key client. Routes to `/projects/v1/:project_id/rest/*` (and `.sql()` to `/projects/v1/:project_id/sql`). Use only when the function acts on behalf of the platform, not the caller.
- **`adminDb().sql(query, params?)`** — raw parameterized SQL, always BYPASSRLS.
- For per-user gating in functions OUTSIDE the cookie-session flow: read `req.headers.get("x-run402-user-id")` / `req.headers.get("x-run402-user-role")` directly (the gateway injects these when a `requireAuth` / `requireRole` deploy-spec gate ran). The bare `getUser(req)` / `getUserId(req)` / `getRole(req)` exports throw `R402_AUTH_UNKNOWN_EXPORT`. For the canonical cookie-session flow, use `auth.*` above.
- **`email.send(opts)`** — send email from the project's mailbox (raw HTML or template).
- **`ai.translate(text, to, opts?)`**, **`ai.moderate(text)`**, **`ai.generateImage({ prompt, aspect? })`** — project-billed AI helpers using the function's service-key auth.
- **`assets.put(key, source, opts?)`** — in-function asset upload through the service-key `/apply/v1/service-asset-put` path. Uses the same CAS/activation substrate as deploy-time assets and returns SDK-compatible `AssetRef` snake_case + camelCase fields.
- **`getRoutedPaymentContext(req)`** (`@run402/functions` 3.7+) — confirmed x402 payment context for priced routed function requests. Returns `{ scheme, paymentId, amountUsdMicros, payer, network, asset, payTo, transaction, settledAt, idempotencyKey, deduplicated, delivery }` or `null`; `paymentId` remains the canonical app-side idempotency identity, while `delivery` distinguishes `first` from same-generation `replay` invocation.
- **`routedHttp`** — non-framework helpers for the `run402.routed_http.v1` same-origin browser ingress contract. Direct `/functions/v1/:name` remains API-key protected; routed function code owns app auth, CSRF, CORS/`OPTIONS`, cookies, redirects, cache headers, and spoofed forwarding-header hygiene.

**Bundling model:** the gateway bundles its installed copy of this library into every function zip via esbuild `alias` at deploy time — it is **platform code, not a user dependency**. User-declared `--deps` are npm-installed and esbuild-bundled separately. Native binaries are rejected. Publish a new `@run402/functions` to npm from `run402-core` for API/type/runtime changes, then bump the Cloud gateway dependency and redeploy. Also installable locally for full TypeScript autocomplete.

### MCP Server (`src/`)

- **`index.ts`** — Entry point. Resolves the wallet with the SDK's `selectWallet` (environment, nearest binding, global default) and registers exactly the eight tools with `server.registerTool`, each with its `outputSchema` from `OUTPUT_SCHEMAS`; `MCP_TOOLS` in `sync.test.ts` pins the set.
- **`sdk.ts`** — Lazy SDK clients: `getSdk()` (surface `mcp`, for the fixed tools) and `getSandboxSdk()` (surface `sandbox`, for `run`: wallet-only credentials, client metadata `sandbox`, `capabilities.returnSecrets: false`). Rebuilt when the selecting environment changes; tests call `_resetSdk()`.
- **`sandbox.ts`** — The `run` sandbox: QuickJS compiled to WebAssembly (the asyncify build of `quickjs-emscripten`), a fresh module per run, a 64 MB memory limit, a stack limit, a deadline enforced by the interrupt handler and the event loop, captured `console`, and the pinned global inventory (`SANDBOX_GLOBALS`: the ECMAScript builtins plus `URL`, `TextEncoder`, `TextDecoder`, `structuredClone`, `crypto.randomUUID`, `console`, `r`). Types are stripped with `node:module`'s `stripTypeScriptTypes`; the source is wrapped as an async function body and a final expression statement becomes a `return` from an `acorn` parse tree.
- **`sandbox-proxy.ts`** — The chain proxy: `r` in the sandbox records property accesses and calls; an awaited chain replays on the host (`ChainHost`) against the sandbox client. JSON data crosses back as JSON, anything else as a per-run handle; arguments must be structured-cloneable data (`RUN_ARGUMENT_NOT_CLONEABLE` otherwise). The replay walks only the public SDK surface (members the SDK's declaration files mark private or protected, `_`-prefixed names, `constructor`/`prototype`/`__proto__`, and JavaScript intrinsics are refused) and records every chain in `calls[]`. An unknown member fails `RUN_UNKNOWN_MEMBER` with the full display path (`r.project(…).sql`) and `did_you_mean`: the parent's closest members, else where the member lives (the parent's namespaces, the root, the root's namespaces).
- **`input-guard.ts`** — Strict tool arguments: every tool registers `strictInput(schema)` (`additionalProperties: false`), and `guardToolArguments(transport, INPUT_SCHEMAS)`, installed after `server.connect`, validates each `tools/call` first and answers a failure itself as a structured `UNKNOWN_ARGUMENT` (with `did_you_mean` per unknown name) or `INVALID_ARGUMENTS` result, since the MCP SDK's own refusal is text-only.
- **`result-store.ts`** — The bounded result store behind `expand_result`: the full result under a `ref`, a window with `shown`/`total`, 32 entries, 30-minute TTL; a `secret: true` result is never stored.
- **`structured.ts`** — the structured channel: the shared error object (`ToolError`, `toolErrorFrom`, local codes), the per-tool output schemas (`OUTPUT_SCHEMAS`, every object open via `.passthrough()`), and the envelope builders. Every tool returns `structuredContent` with `status: "ok" | "error"` beside its text block, and the text's fenced JSON is that object.
- **`errors.ts`** — `formatApiError`, `projectNotFound`, and `mapSdkError`, which translate a thrown error into the prose a model reads plus the `structuredContent.error` a host reads (the fixed tools).
- **`config.ts`**, **`keystore.ts`**, **`wallet.ts`** — re-export from `core/dist/`.
- **`wallet-auth.ts`** — re-exports core's `getWalletAuthHeaders()` + adds `requireWalletAuth()` (early-exit with MCP error when no local wallet), used by `deploy`.
- **`identity-format.ts`** — renders linked identities for `whoami`.
- **`tools/*.ts`** — the eight tools: `up.ts`, `deploy.ts`, `status.ts`, `whoami.ts`, `doctor.ts`, `docs.ts` (answers from `sdk/llms-sdk.txt` and `docs-site/src/content/docs/mcp/run.md`, which `npm run build` copies into `dist/docs/`), `run.ts` (the snippet contract, `RUN_*` codes, four concurrent runs, one stderr line per run), and `expand-result.ts`.

### CLI (`cli/`)

- **`cli/lib/sdk.mjs`** — Fresh SDK per call via `getSdk()`. Sidesteps stale-env issues across test-file boundaries.
- **`cli/lib/sdk-errors.mjs`** — `reportSdkError(err)` writes JSON envelope `{status: "error", http?, message?, body_preview?, ...body}` to stderr and calls `process.exit(1)`. Preserves `ProjectNotFound` plain-text format with the "Hint: project IDs start with prj_" guidance.
- **`cli/lib/config.mjs`** — Imports from `../core-dist/`, adds CLI wrappers (`walletAuthHeaders()` with process.exit, `findProject()` with process.exit). Re-exports core keystore functions.
- **`cli/lib/*.mjs`** — Each module exports `async run(sub, args)`. Subcommand bodies: argv parse + SDK call + JSON output + `reportSdkError` on failure.
- **`cli/lib/assets.mjs`** hands `put` to `sdk.assets.put`, which routes through the unified-apply hero (`/apply/v1/plans` → `/content/v1/plans` → S3 PUT → commit). Resume semantics live at the apply-plan level (24h plan TTL). `--concurrency` and `--no-resume` flags are accepted but ignored.
- **`cli/lib/deploy.mjs`** decides whether an argv is the deploy itself (bare `run402 deploy [--manifest <path>] [--project <project_id>]`, any flags, `--help`, or nothing) or a family word; `deploy` is the one word that is both a verb and a family, so a bare non-family word is `UNKNOWN_SUBCOMMAND`, never a manifest. `cli/lib/deploy-v2.mjs` owns all of it.
- **`cli/lib/deploy-v2.mjs`** — `run402 deploy` plus `rehearse <plan_id>`, `promote <release_id>`, `resume <operation_id>`, `status <operation_id>`, `list`, `events <operation_id>`, `verify <operation_id>`, `resolve <url>`, and `releases <get <release_id>|active|diff>`. Thin wrappers over the apply engine and scoped `r.project(id).apply.*` shape; the deploy supports `--final-only` as a `--quiet` alias, repeatable `--allow-warning <code>`, `--plan` / `--require-plan <plan_id>` for the reviewed-plan flow, and `--no-rehearse` to skip the automatic rehearsal. With no `--manifest` / `--spec` / `--dir` / stdin it discovers the manifest in the current directory the way `up` does and fails `MANIFEST_NOT_FOUND` when none is there. `list` (operations) and `events` (the phase stream) stay beside `status` (one operation's snapshot) because the gateway has no releases-list route and a snapshot does not carry the event stream.
- **`cli/lib/snapshots.mjs` / `cli/lib/branches.mjs`** — `run402 snapshots create|list|get|restore|delete` and `run402 branches create|list|renew|delete`; OpenClaw re-exports both.
- **`cli/lib/ci.mjs`** — `run402 ci link github`, `run402 ci list`, and `run402 ci revoke`. Link signs the canonical delegation locally, verifies/inserts the GitHub repository id, optionally includes normalized `route_scopes`, and writes a workflow using GitHub OIDC (`permissions: id-token: write`) plus the `run402 deploy` command.
- **`cli/lib/up.mjs`** — `run402 up`, the one compound command (a thin shim over `r.actions.run` / `r.up`). Every mode, `--check` and `--print-spec` included, runs the SDK's local file-reference check before any gateway call (`MANIFEST_FILE_MISSING`); `--nested` composes the nested-repo scaffold through `cli/lib/vault-scaffold.mjs` with `nestedCommand: "run402 up --nested"` so the `create_nested_repo` next action names the verb the agent just ran; `--human` prints that remedy. `cli/lib/deploy-v2.mjs`'s `apply` runs the same file check.
- **`cli/lib/logs.mjs`** — `run402 logs --request-id <request_id> [<function> | --function <name>]`: with no function name it is a thin call into `functions.logsByRequestId` (project-wide fan-out, each entry tagged `function`); `req_` / `fnrun_` / `fnatt_` ids are accepted, `--since` takes ISO or epoch ms, unknown flags are rejected. `--app` (default) / `--platform` / `--all` map to the SDK `origin` filter (`resolveLogOriginFlag` in `cli/lib/functions.mjs`, shared with `functions logs`, whose `<name>` is optional with `--request-id`); every entry carries `origin: "app" | "platform"` (the SDK's exported `classifyFunctionLogLine`), `hidden` counts the dropped Lambda `INIT_START` / `START` / `END` / `REPORT` lines and `platformHiddenHint` explains an empty result.
- **`cli/lib/doctor.mjs`** — `run402 doctor` prints what `r.doctor()` (`sdk/src/node/doctor.ts`) returns, supplying the CLI's own update check and resident-helper probe as hooks: `{ ok, blocking[], warnings[], checks[] }`. `ok` is structural (`blocking[]` empty); every check carries `severity: "blocking" | "advisory" | "info"` (`severityOf` fails closed: an unknown status is blocking), advisory findings land in `warnings[]` and never change `ok` or the exit code. The `tier` check's `status` is a fixed vocabulary (`ok | inactive | frozen | past_due | dormant | purged | missing | unknown | error`, never a tier name; `value.tier` / `value.lifecycle` carry the rest), and a wallet whose own org holds no tier but can reach another org's projects is advisory `TIER_MISSING_ON_OWN_ORG`. Tests: `cli-doctor.test.mjs`, `sdk/src/node/doctor.test.ts`. `cli/lib/init.mjs`, `cli/lib/status.mjs`, and `cli/lib/buzz-doctor.mjs` are the same shape over `r.init()`, `r.status()`, and `r.buzz.doctor()`.
- **`cli/lib/harness-context.mjs`** — `resolveSessionKey` (the stable per-session identity room presence resumes on) and `resolveHarnessLabels`, which infers presence `program` (`claude-code`, `codex`, `cursor`, `grok`; `RUN402_PROGRAM` overrides) from the same env markers `sdk/src/node/client-detect.ts` reads for `up`'s client detection; keep the two marker lists in step.

### OpenClaw (`openclaw/`)

- **`openclaw/scripts/config.mjs`** — Re-exports from `cli/lib/config.mjs`
- **`openclaw/scripts/*.mjs`** — Thin shims: `export { run } from "../../cli/lib/<name>.mjs"`
- **`openclaw/scripts/init.mjs`** — Calls CLI's `run()` at top level (executable script)

### Astro integration (`astro/`)

Sibling framework package — wires `@run402/sdk` into Astro's build pipeline. Independent release cadence from the SDK/CLI/MCP lockstep.

- **`src/index.ts`** — `run402(options?)` integration factory. Pure JS (must evaluate cleanly in vanilla Node so `astro.config.mjs` can load it before Vite is alive). Wires the Vite plugin into Astro's lifecycle and validates configuration up front.
- **`src/vite-plugin.ts`** — Scans `.astro` templates for `<Image>` references, dedupes by absolute path, uploads sources via the SDK's `assets.put`, and rewrites the component HTML with v1.49 variant URLs + blurhash.
- **`src/Image.astro`** — Component that emits the `<picture>` markup. Shipped as a separate subpath export (`@run402/astro/Image.astro`) because root must stay pure JS.
- **`src/manifest.ts`** — Runtime helpers for the `assetsDir` data-driven path: `resolveVariants(manifest, key)` (build-time manifest lookup) and `renderPicture(ref, opts)` (HTML emitter). The renderer also serves the recommended **AssetRef-persistence pattern**: consumers persist the full `AssetRef` returned by `r.assets.put` in their data rows and call `renderPicture(row.image, opts)` directly with no manifest lookup, no cache, and no synchronization layer. See [`astro/README.md`](astro/README.md) "Data-driven consumers" for the schema-shift example and trade-offs.
- **`src/picture-builder.ts`** — Pure HTML builder shared by the `.astro` component and the runtime `renderPicture` helper, so both paths emit identical CLS-safe markup.
- **Three entry points:** root (integration factory + types), `/Image.astro` (the component), `/manifest` (runtime helpers).

Published via OIDC Trusted Publisher (`.github/workflows/publish-astro.yml`) — triggered by the `/publish-astro` skill. Bumps independently of `run402-mcp` / `run402` / `@run402/sdk`.

### Tool Pattern

Every tool in `src/tools/` exports two things:
1. A Zod schema object (e.g., `runSchema`) defining input parameters
2. An async handler function (e.g., `handleRun`) returning `{ content: [{type: "text", text: string}], structuredContent, isError? }`, where `structuredContent` satisfies the tool's entry in `OUTPUT_SCHEMAS` (`src/structured.ts`), which `src/index.ts` passes to `server.registerTool` as `outputSchema`

The set is closed: a new capability is an SDK method, which `run` reaches without a tool of its own. Paid calls pay automatically from the wallet; a 402 the wallet cannot cover is the SDK's `PaymentRequired` error with its `next_actions`, which `run` reports unchanged.

### Error Handling Pattern

The fixed tools use shared error helpers from `src/errors.ts`:

- **`formatApiError(res, context)`** — Formats a non-OK API response into the standard `{ content: [...], structuredContent: { status: "error", error }, isError: true }` shape. Includes HTTP status, extracts `hint`/`retry_after`/`renew_url`/`usage`/`expires_at` from the response body, and adds actionable guidance based on status code. The `context` parameter is a short verb phrase describing what failed (e.g. "running SQL").
- **`projectNotFound(projectId)`** — Returns a consistent "project not found in key store" error with guidance to provision first.
- **`mapSdkError(err)`** — Translates a thrown error into the same shape, with `structuredContent.error` built by `toolErrorFrom` from the same error value. A payment the wallet cannot cover is an error result, as in `run`.

When adding a new tool, use these helpers instead of inline error formatting:
```ts
if (!project) return projectNotFound(args.project_id);
// ...
if (!res.ok) return formatApiError(res, "doing the thing");
```

### Test Pattern

Tests mock `globalThis.fetch` and use temp directories for keystore isolation. Each test file follows:
- `beforeEach`: set `RUN402_API_BASE` env, create temp keystore, mock fetch
- `afterEach`: restore original fetch and env, clean up temp dir

### Skill files

The root `SKILL.md` is the generic CLI-first installation artifact. `openclaw/SKILL.md` carries the same operating body and CLI install metadata. Both teach `run402 up`, route typed scripting to the SDK, and send MCP-only hosts to the dedicated MCP reference. Neither assumes tools are already loaded. OpenClaw scripts continue re-exporting CLI modules.

`SKILL.test.ts` checks CLI install metadata, shared-body parity, native-alternative routing and retired-surface bans. Discovery and immutable artifact digests are generated by `build-agent-skills-index.mjs`; managed Buzz selection and consent remain separate contracts. Run `npm run test:skill`.

### Agent reference docs (`llms-*.txt`)

`scripts/build-agent-flat-docs.mjs` generates the flat agent references from the docs-site sources: `sdk/llms-sdk.txt` from `docs-site/src/content/docs/sdk/**`, `llms-mcp.txt` from `docs-site/src/content/docs/mcp/**`, and the CLI set from `docs-site/src/content/docs/cli/**`, where every page carries frontmatter `slice:` + `summary:`. The page with `slice: index` (`cli/reference.md`) becomes `cli/llms-cli.txt`, the INDEX an agent fetches first: the whole first-deploy contract plus a table of slices. Every other page becomes its own fetchable `cli/llms-cli-<slice>.txt` (`deploy`, `commands`, `repos`, `orgs`, `functions`, `assets`, `ops`, `errors`, `frontend`, `platform`), self-contained and linking back, and `cli/llms-cli-full.txt` is the whole reference in one document. The generator enforces a 48 KB index / 56 KB slice budget (`indexByteBudget` / `sliceByteBudget`) and refuses to emit past it, because agent runtimes truncate a fetched document near 60 KB; it exports `listAgentFlatFiles()`, which `scripts/build-docs-deploy-manifest.mjs` consumes so a new slice is served the moment it exists. Content-coverage tests (`sync.test.ts`, `SKILL.test.ts`, `src/tools/vault-copy.test.ts`, `scripts/check-typed-config-docs.mjs`) read `cli/llms-cli-full.txt`. Edit the docs-site page, then run `node scripts/build-agent-flat-docs.mjs`; never edit a generated `llms-*.txt` by hand.

## Environment Variables

| Variable | Default | Purpose |
|----------|---------|---------|
| `RUN402_API_BASE` | `https://api.run402.com` | API base URL (override for testing/staging) |
| `RUN402_CONFIG_DIR` | `~/.config/run402` | Local credential storage **base** directory. The `default` wallet lives here directly; named wallets live under `{base}/profiles/<name>/`. |
| `RUN402_WALLET` | `default` | Active named wallet (profile). Selects which wallet a command uses; the `--wallet <name>` flag overrides it, and a per-directory `.run402.json` binding sets it. `RUN402_PROFILE` is an accepted alias. |
| `RUN402_WALLET_PATH` | `{config_dir}/wallet.json` | Custom wallet file path |
| `RUN402_GRANT_KEY` | (unset) | A grant-key bearer (`run402 grants create --key`). When set it is the only credential sent; see `sdk/src/grant-key-credentials.ts` |

**Named wallets (profiles).** Hold multiple wallets on one machine via `run402 wallets` (`list`, `current`, `new <name>`, `use <name>`, `rename`, `bind`/`unbind`, `import` (a key, or `--kms <arn>`), `send`, `rm`, `fund`, `balance`, `lightning`; `wallets new default` creates the root wallet, `fund`/`balance` act on the active wallet). The SDK's `r.wallets` carries the same local wallet (`status`, `create`, `export`, `faucet`). Selection precedence: `--wallet <name>` flag > `RUN402_WALLET` env > nearest `.run402.json`/`.run402.local.json` directory binding > global `wallets use` default > `default`. A `--wallet`/binding that conflicts with `RUN402_WALLET` is a hard error (pass `--wallet`, unset the env, or `wallets unbind`). The binding file holds only a wallet *name* (no key) and is safe to commit. Each wallet has its own `wallet.json`, `projects.json`, and non-secret `meta.json` under `profiles/<name>/`; a `wallet.json` holds either a `privateKey` or a `signer` reference (an AWS KMS key ARN, no secret), and every signing site resolves its signer through `sdk/src/node/signers/` (`resolveWalletAccount`; a source-scan test keeps it the only door); the human-readable name surfaces in `run402 status`, `r.whoami()` (SDK), the MCP `status` tool, and — via a signed server-side label (`r.wallets.setLabel`/`getLabel`, gateway endpoint `/wallets/v1/:address/label`) — the console (WEB). The label push is on by default (`RUN402_WALLET_LABEL_SYNC=0` opts out). Core path resolution lives in `core/src/config.ts` (`getConfigBaseDir`/`getActiveProfile`/`getConfigDir`) + `core/src/profiles.ts`; the selection chain and the `wallets` verbs are `r.wallets` on `@run402/sdk/node` (`sdk/src/node/wallets.ts`), and `cli/lib/wallet-context.mjs` only reads `--wallet` off argv and reports a refusal.

## Fleet coordination room — join at session start

Agent sessions working on the run402 codebases (this repo, run402-private, run402-core) share the coordination room `run402-dev` on org `57035b1e-ec41-4ce6-a7a5-a5b2560efdd7`. Arrive:

```
export RUN402_ROOM=57035b1e-ec41-4ce6-a7a5-a5b2560efdd7/run402-dev
run402 --wallet platform-deploy rooms join --name <pick-your-own> --task "<what you're doing>"
run402 --wallet platform-deploy messages list --unread
```

`run402 messages wait` is the room's EAR, not just its catch-up read: block on it (default timeout 120s, max 600s — a coding harness's own shell-call ceiling) instead of polling `messages list` in a loop, and it holds on the gateway's own held read when available, degrading to bounded polling against an older one with the SAME output shape either way. Silence is a normal, exit-0 answer — `settled: false` plus `live_presences[]` naming who is still in the room, never an error.

Use `--wallet platform-deploy` only on coordination commands; never export it as the session-wide wallet. Pick your own presence name — collisions get an honest suffix (Opus → Opus-2), unless you also pass `--task`, in which case a collision qualifies your name from it first (`Opus` taken + `--task "mpp triage"` → `Opus-mpp-triage`, not `Opus-2`), and the output says why. A quiet session's presence resumes automatically across an idle gap or a lost `.run402/` cache: every room call derives a stable session identity (Claude Code's own session id, Codex's own thread id, or a locally generated key persisted for this checkout — see `cli/lib/harness-context.mjs`) and the gateway revives the same presence under the same name no matter how long the ~1h TTL decayed. Two genuinely concurrent sessions never resume each other's presence. Override with `RUN402_SESSION_KEY`; `RUN402_PRESENCE_ID=<prs_…>` pins an exact known presence when a session spans repos. Omit `--task` and the CLI best-effort fills it from your harness's own thread title — set `RUN402_NO_TASK_FROM_TITLE=1` to opt out. Claim shared areas before editing (`run402 claims create repo:<area>/** --note "why"` — advisory, never blocks; release when done). End with a done / hot / next message. Use `--ack --to <observed participant>` only when a known recipient is available; otherwise broadcast without `--ack`. Tool friction goes to `--thread dx`. Need a human? Send with `--importance high` — asking is always in-policy. The `.run402/` cache dir this creates is per-checkout and never committed.

**Skills announce shared-state work.** A workflow that mutates something other sessions share — production, npm, the specs — claims it before and reports it after, both best-effort (`|| true`, so a wedged room can never fail a release):

```
run402 messages send "published run402 / @run402/sdk / run402-mcp <version>: <what changed>" || true
```

The release commands themselves live in `run402-private/.claude/commands/` and are already wired. Anything you add follows the same bar: wire it only if a *second agent could be hurt by not knowing*. Reading and thinking skills (explore, recall, consult, are-we-there-yet) are deliberately not wired — announcing them is the noise that makes agents stop reading the room.

### Coordination identity is not deploy authority

The shared `platform-deploy` profile in the room instructions is for fleet coordination only. It is not authorization to build unrelated apps as that human principal. For a new independent demo, use the agent's own named wallet/profile and its free prototype setup, selected per command; preserve the global default. For existing authorized project work, use that project's intended credentials. Never copy a room wallet into deploy commands merely because it joined the room. Scope `--wallet platform-deploy` to room, message and claim commands, including skill announcements.

### End every fresh-wallet build with keep or delete

Starting a new site or app from a fresh wallet (`run402 init`, then `run402 up`) is the intended way to build demos: it exercises the cold-start path. That wallet lives only in this session, so a project left behind can never be updated or removed. Before closing the session, give the human the live URL and ask one question: **keep or delete?** Never end without asking.

- **keep:** offer the project to Tal with `run402 transfer init --to tal@kychee.com --project <project_id>`, then report the `transfer_id` and its expiry (72 hours). Tal accepts from the emailed invitation into the `run402` org and takes it from there; nothing more is needed from you. (A fresh wallet cannot move the project into that org itself: `--to-org` returns 403.) Finish every deploy first, because a pending transfer blocks owner-side changes (409 `PROJECT_HAS_PENDING_TRANSFER`).
- **delete:** `run402 projects delete <project_id> --confirm`.
