# Cloudflare OS Documentation

> Reference and operations documentation for Cloudflare OS, a Cloudflare Workers monorepo that runs sandboxed AI-built "gadgets", a Cap'n Web RPC kernel, and capability-scoped "gatekeeper" connectors. Written for operators deploying an instance, developers extending the kernel or connectors, and agents reading the RPC and configuration surface.

## Context Links

- [Agent index](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms.txt)
- [Human interactive docs](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd)
- [GitHub repository](https://github.com/cloudflare/cloudflare-os)

## Repository Metadata

- Repository: cloudflare/cloudflare-os

- Generated: 2026-08-05T16:44:47.795Z
- Updated: 2026-08-05T16:45:21.765Z
- Runtime: Claude Code · claude-opus-5
- Format: Documentation
- Pages: 23

## Page Index

- 01. [Overview](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/01-overview.md) - What Cloudflare OS exposes: a router origin, a Workers kernel, gadget sandboxes, and gatekeeper connectors. Covers the package layout, the OS-analogy mapping to real directories, runtime assumptions (Workers, Durable Objects, Worker Loader, Cap'n Web), and the first routes to read.
- 02. [Installation](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/02-installation.md) - Prerequisites (pnpm, pinned Node 22.14.0) and the three install paths: `pnpm run-local` for a single-command local stack, the hosted deploy wizard, and the starter repository. Documents what run-local builds, its source-hash stamp caching, and where local data is written.
- 03. [Quickstart](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/03-quickstart.md) - Run the full stack on workerd with `pnpm run-local`, reach http://localhost:8787, and confirm success. Includes the first prompts that exercise bundled format blueprints versus from-scratch gadget creation, and which prompts require a configured gatekeeper.
- 04. [Local development](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/04-local-development.md) - The two-terminal workflow: `pnpm dev-server` (router plus workers on wrangler) and `pnpm dev-client` (Vite on port 3000). Covers `.dev.vars` loading, generated dev wrangler files, dynamic gatekeeper service-binding discovery, the `--use-workers-ai-binding` and `--serve-frontend-assets` flags, and `VITE_BACKEND_HOST` port selection.
- 05. [Gadgets and sandboxing](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/05-gadgets-and-sandboxing.md) - How a gadget is isolated: a Dynamic Worker loaded through the `LOADER` binding with internet access disabled, a sandboxed client iframe restricted by Content-Security-Policy, and a Cap'n Web session bridged over postMessage. Covers per-gadget Durable Object storage, code versions, and the `global_fetch_strictly_public` SSRF posture.
- 06. [Gatekeeper protocol](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/06-gatekeeper-protocol.md) - The interfaces every connector implements: `GatekeeperVendor`, `GatekeeperConnectCallback`, `GatekeeperUser`, `GatekeeperUserVerifier`, and `Gatekeeper<Session>`. Documents `VendorDescription`, `AccountDescription` (`singleton`, `providesUi`, `providesAuth`), `ResourceDescription` and URL-pattern matching, agent catalog limits, and the `autoProvisionsAccount` disabled/optional/enabled mode resolution.
- 07. [Observations, actions, and approval queues](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/07-observations-actions-and-approval-queues.md) - The read/write split that makes asynchronous human-in-the-loop work: `ObservationDescription` (including `prohibitAllSharing`), `ActionDescription` and `ActionKind`, the `ObservationAuthorizer` and `ApprovalQueue` interfaces, simulated results while an action is pending, and `ActionState` transitions. Includes the MCP trust boundary where `readOnlyHint` decides observation versus queued action.
- 08. [Blueprints](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/08-blueprints.md) - What a blueprint captures and omits, the three binding types (`gatekeeper`, `aiModel`, `agentSpawner`), blueprint annotations stored on `GatekeeperRecord`, 128-bit hex IDs versus stable bundled IDs, and the one-way Gadget DO to User DO to Workers KV propagation with its `dirty` flag. Includes `.gadget` export/import and share-link semantics.
- 09. [Sharing, roles, and observer re-verification](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/09-sharing-roles-and-observer-re-verification.md) - Collaborator roles (`build` > `use`), the `use` allowlist enforced by `UseOverseerInterface` with its default-deny compile-time check, the two inert telemetry subscriptions, and share-link keys stored only as HMAC-SHA-256 hashes. Documents observer registration through `Gatekeeper.addObserver()`, verifier minting, and how a failing re-check blocks new observations.
- 10. [Agent runtime and tools](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/10-agent-runtime-and-tools.md) - The Code Mode agent loop: the tool set (`readFile`, `writeFile`, `editFile`, `executeCode`, `describeBinding`, `setGadgetBinding`, `createGadget`, `listBlueprints`, `listConnectableResources`, `requestConnection`, `webFetch`, `observeUserChanges`, `giveUp`), how `prepareChatBindings` folds ambient gatekeepers into `env` under `suggestedBindingName`, slash-command collection, and chat compaction checkpoints.
- 11. [Configure gatekeeper credentials](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/11-configure-gatekeeper-credentials.md) - Register a third-party OAuth app and wire its credentials into a connector. Covers the `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect-URI contract, the per-connector `CLIENT_ID`/`CLIENT_SECRET` secrets, `deploy-inputs.json` overrides with `setupSteps` and `consoleUrl`, dev seeding from shell variables, and connectors that need no OAuth app.
- 12. [Build a gatekeeper](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/12-build-a-gatekeeper.md) - Add a connector package: implement `GatekeeperVendor`, declare vendor/account/resource descriptions, own your Durable Object classes and migrations, and expose a session. Covers the configurator UI build into `src/generated`, the type-only `@gadgets/configurator-ui` helpers, `storage-schema.md`, structured logging with a `component`/`vendorId` logger, and installing by adding a `GATEKEEPER_*` binding.
- 13. [Configure sign-in and AI Gateway billing](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/13-configure-sign-in-and-ai-gateway-billing.md) - Turn on the optional public-service posture: `AUTH_GATEKEEPERS` allowlisting, `DISABLE_PASSWORD_AUTH`, email-keyed identity via `idFromName(email)`, incremental auth-versus-full scopes with transient login grants, and `ENABLE_CLOUDFLARE_LIMITS` free-tier plus credit top-up with the `$2` balance threshold and 5-minute balance cache.
- 14. [Manage bundled format blueprints](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/14-manage-bundled-format-blueprints.md) - Ship output formats as committed data. Covers the `.gadget` archive plus `.json` sidecar split, `FORMAT_BLUEPRINTS_DIR` for forks, generation into the gitignored `src/generated/format-blueprints.ts`, first-request installation into KV and R2, `pnpm import:format-blueprint` with `--new`, and why a deployed `blueprintId` must never be renamed.
- 15. [RPC API reference](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/15-rpc-api-reference.md) - The Cap'n Web interfaces shared between client and backend: `PublicApi`, `LoginAttempt`, `AuthenticatedApi`, `AdminApi`, and `Overseer`, plus supporting types (`GadgetMetadata`, `UiBundle`, `CodeUpdate`, `ActionLogEntry`, `AgentSpawnerConfig`, `AiModelConfig`, `ServerConfig`). Documents `OPEN_GADGET_ERROR_CODES`, `validateBindingName`, observer-config callbacks, and stub-disposal and promise-pipelining constraints.
- 16. [Environment variables](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/16-environment-variables.md) - Every backend environment variable and its default: `ADMINS`, `PUBLIC_BASE_URL`, the `CF_AI_GATEWAY*` family and its required account/token pair, `CF_ACCESS_AUD`/`CF_ACCESS_ISS`, `AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, `ENABLE_CLOUDFLARE_LIMITS`, `DAILY_LLM_CALL_LIMIT`, `MINIMUM_CLOUDFLARE_BALANCE`, plus `VITE_FRONTEND_ERROR_REPORTING`, `MCP_PORTAL_URL`, and `MCP_PORTAL_TRUST_ANNOTATIONS`.
- 17. [Admin configuration reference](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/17-admin-configuration-reference.md) - The `AdminConfig` schema and its defaults: `signupsEnabled`, `siteName`, `instanceInstructions`, `announcement`, `banner`, `accentColor`, `disabledResources`, `disabledGatekeepers`, `ambientGatekeeperModes`, and `formats`. Covers length limits, the `AdminSettings` DO as sole writer mirroring to the reserved `.adminConfig` KV key, `readAdminConfig` hot-path reads, and why auth config is deliberately excluded.
- 18. [Routing and worker bindings](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/18-routing-and-worker-bindings.md) - How the router resolves requests: `/api/*` and `/blueprint-screenshot/*` to the backend, `/gatekeeper/<name>/*` derived by lowercasing `GATEKEEPER_*` env keys, everything else to `ASSETS` or the dev fallback, and inbound email dispatch to `GATEKEEPER_EMAIL`. Lists backend bindings (`BLUEPRINTS`, `BLUEPRINT_CONTENT`, `AVATARS`, `LOADER`, `BROWSER`, optional reporter and rate limiter) and DO migration tags.
- 19. [Build, lint, and test](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/19-build-lint-and-test.md) - The commands CI enforces and their ordering: `pnpm build` (recursive), `pnpm lint` composed of `lint:check` (oxlint) and `types:check` (recursive `tsc --noEmit`), and `pnpm test` (`node --test scripts/*.test.js` plus per-package suites). Documents generator prerequisites, the oxlint rule posture, and why type-aware rules are disabled in this monorepo.
- 20. [Release pipeline](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/20-release-pipeline.md) - Building and publishing a release: `build-release.mjs` byte-identical worker bundles and manifest generation, the placeholder contract (`$ACCOUNT_ID`, `$WORKER_NAME(...)`, `$SECRET(...)`, `$PUBLIC_BASE_URL`), `upload-release.mjs --candidate` staging, and the all-or-nothing manifest copy in `promote-release.mjs`. Covers required R2 variables, `NO_DEFAULT_CRED_INPUTS`, promote serialization, and golden-file regeneration with `UPDATE_GOLDEN=1`.
- 21. [Integration testing](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/21-integration-testing.md) - How `packages/integration-tests` boots the backend and gatekeepers as real workers in workerd via `createTestHarness()` and speaks Cap'n Web over the same `/api` WebSocket the browser uses. Documents why fake timers cannot work out-of-process, why a fixture gatekeeper covers overseer logic, the ~3s `server.reset()` cost and convention-based isolation, and the pluggable network interceptor.
- 22. [Troubleshooting](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/22-troubleshooting.md) - Known failure modes and their fixes: `OPEN_GADGET_ERROR_CODES` and observer binding failures, GitHub App versus OAuth App scope errors, missing generated modules on a clean checkout, gadget lockdown from `prohibitAllSharing`, free-tier and balance blocks, missing `WORKERS_AI` binding in gateway mode, no-op error reporting without its bindings, and RPC stub leaks or `useState` misuse.
- 23. [Developer conventions and contributing](https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/23-developer-conventions-and-contributing.md) - The rules a change must satisfy: pnpm only, kernel review standards for `workshop-backend` and `workshop-shared`, doc-comment every exported member, no hand-written RPC mirror interfaces with `as unknown as`, promise pipelining and stub disposal, structured logging field vocabularies and the never-log-secrets rule, opt-in frontend error reporting boundaries, and the narrow external-PR policy.

## Source File Index

- `.gitlab-ci.yml`
- `.oxlintrc.json`
- `AGENTS.md`
- `CONTRIBUTING.md`
- `docs/ai-gateway-billing.md`
- `docs/blueprints.md`
- `docs/integration-testing.md`
- `docs/oauth-signin.md`
- `docs/observers.md`
- `docs/public-server.md`
- `docs/sharing.md`
- `package.json`
- `packages/backend-utils/src/error-reporting.ts`
- `packages/backend-utils/src/logger-core.ts`
- `packages/backend-utils/src/logger.ts`
- `packages/backend-utils/src/observability-context.ts`
- `packages/configurator-ui/src/index.ts`
- `packages/error-reporting/src/index.ts`
- `packages/gatekeeper-context/src/context-api.ts`
- `packages/gatekeeper-email/README.md`
- `packages/gatekeeper-github/deploy-inputs.json`
- `packages/gatekeeper-github/README.md`
- `packages/gatekeeper-github/src/github-configurators.ts`
- `packages/gatekeeper-github/src/github.ts`
- `packages/gatekeeper-google/README.md`
- `packages/gatekeeper-mcp-portal/README.md`
- `packages/gatekeeper-mcp/README.md`
- `packages/gatekeeper-scheduler/README.md`
- `packages/integration-tests/__tests__/observer-reverification.test.ts`
- `packages/integration-tests/fixtures/gatekeeper-test/src/test-gatekeeper.ts`
- `packages/integration-tests/src/harness.ts`
- `packages/integration-tests/src/network-interceptor.ts`
- `packages/integration-tests/src/rpc-client.ts`
- `packages/mcp-shared/README.md`
- `packages/mcp-shared/src/action-store.ts`
- `packages/mcp-shared/src/tools.ts`
- `packages/router/src/index.ts`
- `packages/workshop-backend/format-blueprints/README.md`
- `packages/workshop-backend/package.json`
- `packages/workshop-backend/scripts/build-format-blueprints.mjs`
- `packages/workshop-backend/scripts/import-format-blueprint.mjs`
- `packages/workshop-backend/src/admin-config.ts`
- `packages/workshop-backend/src/admin-settings.ts`
- `packages/workshop-backend/src/agent-catalog.ts`
- `packages/workshop-backend/src/agent-compaction.ts`
- `packages/workshop-backend/src/agent.ts`
- `packages/workshop-backend/src/ai-gateway.ts`
- `packages/workshop-backend/src/auth/config.ts`
- `packages/workshop-backend/src/auth/login-flow.ts`
- `packages/workshop-backend/src/auto-approval.ts`
- `packages/workshop-backend/src/blueprint-archive.ts`
- `packages/workshop-backend/src/client-errors.ts`
- `packages/workshop-backend/src/env.d.ts`
- `packages/workshop-backend/src/format-blueprints.ts`
- `packages/workshop-backend/src/overseer.ts`
- `packages/workshop-backend/src/provisioning-policy.ts`
- `packages/workshop-backend/src/server.ts`
- `packages/workshop-backend/src/sharing.ts`
- `packages/workshop-backend/src/slash-commands.ts`
- `packages/workshop-backend/src/user.ts`
- `packages/workshop-backend/src/web-fetch.ts`
- `packages/workshop-backend/wrangler.jsonc`
- `packages/workshop-shared/src/api.ts`
- `packages/workshop-shared/src/external-message-gateway.ts`
- `packages/workshop-shared/src/feature-flags.ts`
- `packages/workshop-shared/src/gatekeeper.ts`
- `packages/workshop-shared/src/limits.ts`
- `pnpm-workspace.yaml`
- `README.md`
- `run-dev-server.js`
- `scripts/build-gatekeeper-configurator.mjs`
- `scripts/dev-server-config.js`
- `scripts/dev-server-config.test.js`
- `scripts/release-manifest.test.js`
- `scripts/release-promote.test.js`
- `scripts/release/build-release.mjs`
- `scripts/release/manifest-lib.mjs`
- `scripts/release/promote-release.mjs`
- `scripts/release/upload-release.mjs`
- `scripts/run-local.mjs`
- `tsconfig.json`
- `wrangler.jsonc`

---

## 01. Overview

> What Cloudflare OS exposes: a router origin, a Workers kernel, gadget sandboxes, and gatekeeper connectors. Covers the package layout, the OS-analogy mapping to real directories, runtime assumptions (Workers, Durable Objects, Worker Loader, Cap'n Web), and the first routes to read.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/01-overview.md
- Generated: 2026-08-05T16:39:04.325Z

### Source Files

- `README.md`
- `AGENTS.md`
- `package.json`
- `pnpm-workspace.yaml`
- `packages/router/src/index.ts`
- `packages/workshop-shared/src/api.ts`

---
title: "Overview"
description: "What Cloudflare OS exposes: a router origin, a Workers kernel, gadget sandboxes, and gatekeeper connectors. Covers the package layout, the OS-analogy mapping to real directories, runtime assumptions (Workers, Durable Objects, Worker Loader, Cap'n Web), and the first routes to read."
---

Cloudflare OS is a pnpm monorepo (`packages/*`, root package name `gadgets`) that deploys as a set of Cloudflare Workers behind one public origin. `packages/router` is that origin: it path-routes to `WORKSHOP_BACKEND`, to any bound `GATEKEEPER_*` service, or to `ASSETS`. `packages/workshop-backend` is the kernel worker, `packages/workshop-frontend` is a client-side SPA, `packages/workshop-shared` defines the Cap'n Web RPC contract spoken between them over a single long-lived WebSocket, and each `packages/gatekeeper-*` is a separate Worker mediating one external service.

## Runtime assumptions

| Assumption | Where it shows up |
| --- | --- |
| Cloudflare Workers | `workshop-backend` and every `gatekeeper-*` package runs as its own Worker; the router is a Worker too |
| Service bindings | Gatekeeper installation is a `GATEKEEPER_*` service binding on the router — no router code change |
| Durable Objects | Gatekeepers own their state in DOs (e.g. `gatekeeper-context` uses `ContextCollectionDurableObject`, `UserLibraryDurableObject`, `LibraryRegistryDurableObject`, plus a KV namespace) |
| Cap'n Web RPC | Client↔backend API in `packages/workshop-shared/src/api.ts`; gatekeepers expose "a clean Cap'n Web API to the service" |
| Sandboxed gadget frames | Gadgets run in an iframe with no outside-world access except `postMessage()` to the parent, through which they speak RPC to the Workshop |
| workerd / wrangler locally | `pnpm run-local` runs the whole stack on wrangler and workerd; `pnpm-workspace.yaml` overrides `workerd` to `>=1.20260623.1` |
| Pinned Node types | `@types/node` pinned to `26.1.0` via workspace overrides |

<Note>
`pnpm-workspace.yaml` sets `minimumReleaseAge: 1440` (24h) to match the CI supply-chain policy, with `capnweb`, `capnweb-validate`, `workerd`, and `@cloudflare/workerd-*` excluded. Local installs cannot commit a too-fresh lockfile.
</Note>

## What the product exposes

Three surfaces, per the README:

1. An agent chat UI for asking agents to do tasks, preloaded with knowledge about how your company operates.
2. Sandboxed application development — agents build "gadgets" (small personal apps) that can be safely shared.
3. Gatekeepers, a security framework applying guardrails to both agents and apps.

A gadget is a *private instance* of an application per user, running in its own sandbox, rather than a call out to shared SaaS. Two consequences the README calls out: the app cannot leak your data through its own bug, because the sandbox controls all access to the private instance; and the code is freely modifiable by prompting an agent, because of the first point. Blueprints are the "template" analogue — unlike an office template, a blueprint specifies a whole application, and users can create blueprints from their own gadgets and share them.

## Package layout

:::files
```
packages/
├── router/                    # public origin; path-routes by binding set
│   └── src/index.ts
├── workshop-backend/          # the kernel Worker
│   └── format-blueprints/     # shipped output-format blueprints, committed as data
├── workshop-frontend/         # SPA: React, Kumo UI, Phosphor icons, Vite
├── workshop-shared/           # Cap'n Web RPC API definitions
│   └── src/api.ts
├── configurator-ui/           # type-only helpers for gatekeeper configurator UI modules
├── mcp-shared/                # library behind gatekeeper-mcp and gatekeeper-mcp-portal
├── gatekeeper-context/        # Context Library connector
├── gatekeeper-scheduler/      # Scheduled Tasks connector
└── gatekeeper-*/              # one Worker per external service integration
```
:::

### Package responsibilities

| Package | Role |
| --- | --- |
| `packages/router` | The public origin. Routes by path prefix; also carries the inbound `email()` handler. Doubles as the dev router. |
| `packages/workshop-backend` | The kernel: defines the architecture, held to a higher review bar than UI or gatekeeper code. Also owns `format-blueprints/`. |
| `packages/workshop-frontend` | Pure single-page app, entirely client-side, speaking RPC over a persistent WebSocket. React + Kumo UI + Phosphor + Vite. |
| `packages/workshop-shared` | The RPC interface between client and server. Cap'n Web, browser-capable over WebSocket. |
| `packages/configurator-ui` | Type-only component helpers for optional gatekeeper resource configurator UI modules, compiled by `scripts/build-gatekeeper-configurator.mjs` during package builds. |
| `packages/mcp-shared` | Not a Worker — a library holding the MCP client, OAuth chain, account DO base, resource-URL scope grammar, and queued-action store. |
| `packages/gatekeeper-*` | One Worker per integration: OAuth flows plus sandboxed access to external APIs. |

## Router: the first route to read

`packages/router/src/index.ts` is the shortest complete statement of the system's shape. Routing config *is* the binding set.

```ts
// packages/router/src/index.ts
for (const key of Object.keys(env)) {
  if (!key.startsWith("GATEKEEPER_")) continue;
  const suffix = key.slice("GATEKEEPER_".length).toLowerCase().replaceAll("_", "-");
  const prefix = `/gatekeeper/${suffix}`;
  if (url.pathname === prefix || url.pathname.startsWith(prefix + "/")) {
    return (env[key] as Fetcher).fetch(req);
  }
}
```

Resolution order, top to bottom:

| Path | Destination |
| --- | --- |
| `/gatekeeper/<suffix>` and `/gatekeeper/<suffix>/*` | The matching `GATEKEEPER_*` service binding (`_` → `-`, lowercased). Gatekeeper OAuth redirects land here, at `/gatekeeper/<name>/oauth` — there are no backend `/auth` callbacks. |
| `/api`, `/api/*` | `WORKSHOP_BACKEND` |
| `/blueprint-screenshot`, `/blueprint-screenshot/*` | `WORKSHOP_BACKEND` |
| everything else, when `ASSETS` is bound | `ASSETS` (production; the `wrangler.jsonc` assets stanza) |
| everything else, when `ASSETS` is absent | `WORKSHOP_BACKEND` (dev fallback) |

Installing a gatekeeper therefore means re-deploying the router with one more service binding — no code or config change in the router.

<Info>
The `Env` interface declares `ASSETS?: Fetcher` as present in production and absent in dev. `GATEKEEPER_EMAIL?: Service<EmailEntrypoint>` is described in-source as dormant until custom domains plus Email Routing exist; the `email()` handler ships anyway and rejects the message with `"No email gatekeeper is installed on this instance."` when the binding is missing.
</Info>

## Request topology

```mermaid
flowchart TB
  subgraph client["Browser"]
    SPA["workshop-frontend SPA"]
    GADGET["gadget iframe<br/>(postMessage only)"]
  end
  subgraph origin["Public origin"]
    ROUTER["packages/router<br/>src/index.ts"]
  end
  subgraph workers["Workers"]
    BACKEND["workshop-backend<br/>(kernel)"]
    GK["gatekeeper-* Workers<br/>context, scheduler, mcp, ..."]
    ASSETS["ASSETS<br/>(prod only)"]
  end
  subgraph state["Gatekeeper-owned state"]
    DO["Durable Objects"]
    KV["KV namespace"]
  end
  subgraph ext["External services"]
    SVC["third-party APIs / OAuth"]
  end

  SPA -->|"WS /api — Cap'n Web"| ROUTER
  SPA -->|"static assets"| ROUTER
  GADGET -->|"postMessage RPC"| SPA
  ROUTER -->|"/api/*, /blueprint-screenshot/*"| BACKEND
  ROUTER -->|"/gatekeeper/&lt;name&gt;/*"| GK
  ROUTER -->|"fallback"| ASSETS
  BACKEND -->|"service binding"| GK
  GK --> DO
  GK --> KV
  GK -->|"sdkFetch — endpoint + SSRF checks"| SVC
```

## Why a fat client, not SSR

`packages/workshop-shared/src/api.ts` records the reasoning inline: the UI is likely open often or always, so startup time matters less and assets are usually cached; gadgets are sandboxed *client-side* as well as server-side, which requires running code in the browser, so a gadget cannot plausibly be server-rendered; a clean client/server API boundary makes alternative clients easier; and SPA is simpler to reason about. The RPC socket is opened immediately at startup and kept open for the whole session lifetime, reconnecting as needed. Through the `postMessage()` exchange, the Workshop hands the gadget a stub pointing at the gadget's own server-side Durable Object interface.

## The RPC surface entry point

`PublicApi` in `packages/workshop-shared/src/api.ts` is the internet-facing half and shows the shape of everything else:

| Member | Purpose |
| --- | --- |
| `getServerConfig()` | Deployment-level boot config (auth mode, available sign-in vendors, whether the Cloudflare limits flow is enabled). Contains no secrets. |
| `startGatekeeperLogin(vendorId)` | Returns `{ url, attempt }`; the client opens `url` in a new tab and awaits `attempt.wait()`. Vendor must be auth-capable and allowlisted per `ServerConfig.authVendors`, else throws. |
| `authenticate(token)` | Authenticates with a stored token, returning `AuthenticatedApi`. |
| `authenticateFromCfAccess()` | Authenticates from an existing Cloudflare Access session. |
| `login(username, passwordHash)` | Returns a token, or `null` on no-such-user / wrong password. |
| `createAccount(username, displayName, passwordHash)` | Returns a token, or `null` if the username exists. |
| `getBlueprint(id)` | Blueprint metadata; no auth required — knowing the ID suffices, since a blueprint is "just data". |
| `downloadBlueprint(id)` | Streams a `.gadget` archive containing `BlueprintMetadata` plus the current code snapshot, not the full KV record. |

`LoginAttempt` is a capability: holding the stub is the right to receive the resulting session token, and disposing it abandons the attempt and cancels the server-side wait.

<Warning>
`login()` / `createAccount()` take a client-derived `passwordHash`, not a password: `argon2id` with `salt = SERVICE_SALT + utf8(username)`, `parallelism: 1`, `iterations: 3`, `memorySize: 64MiB`, `hashLength: 32`. `SERVICE_SALT` is the 16-byte constant exported from `api.ts`. The server hashes again before storage and never sees the plaintext password. Both methods may be disabled when the deployment uses SSO.
</Warning>

## Gatekeepers: capability-based mediation

A gatekeeper is created when an agent or gadget is introduced to an external resource. Per the README, it wraps the service's native API in a clean Cap'n Web API, handles authorization (e.g. OAuth), enforces narrow access to only the specific resource the user intended, logs every action the gadget or agent performs, and offers human approval for any side-effecting action.

The asynchronous approval model is the notable departure from synchronous human-in-the-loop: when an action needs approval, the gatekeeper *simulates* the outcome locally and tells the agent it completed, serving simulated results on read-back so the agent can keep queueing work. The user approves or rejects later, in bulk or one at a time.

### Ambient accounts and provisioning modes

A vendor may declare `VendorDescription.autoProvisionsAccount`, minting a connected account with no OAuth flow through `GatekeeperVendor.createAccount()` — which takes no user identity. The deployment admin then picks a per-vendor mode in the admin Gatekeepers panel, resolved in `provisioning-policy.ts`:

| Mode | Behavior |
| --- | --- |
| `enabled` | Auto-provisions the account for every user; forced, and hidden from the Connectors list |
| `optional` (default) | Each user opts in from the Connectors page |
| `disabled` | Offered to no one; existing accounts go dormant |

The account is persisted in the user DO like any connected account, and the account capability — not an asserted identity — is the authority thereafter.

An account (`GatekeeperUser`) declares in its `AccountDescription` whether it provides an agent **singleton** (`singleton: { tsType }`) and/or a **management UI** (`providesUi`). These are orthogonal: an account can declare either, both, or neither. Singletons are auto-provided to the owner's workspaces as an ambient gatekeeper record, folded into each chat's env as a named chat binding using the gatekeeper's `suggestedBindingName` (see `prepareChatBindings` in `overseer.ts`), which the agent reads in `executeCode` via `getSession` / `getAgentCatalog`, with each read recorded as an observation. It is not bound to any gadget by default; the agent can wire it in with `setGadgetBinding` when a gadget's persistent code needs it. Management UIs are hosted at `/gatekeepers/$appId` — the vendor id, e.g. `/gatekeepers/context` — via `startAppUi({ isAdmin })`.

<Warning>
Capability-based security rule from `AGENTS.md`: a resource becomes "ambient" (auto-injected) **only** by user or admin configuration. A gatekeeper must never assert its own ambience.
</Warning>

### MCP trust boundary

`packages/mcp-shared` backs two connectors — `gatekeeper-mcp` (endpoints a user pastes) and `gatekeeper-mcp-portal` (one admin-configured portal). The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation; everything else is queued for approval. Auto-*applying* a write additionally requires a `vetted` endpoint, which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`. OAuth uses the official `@modelcontextprotocol/client`, and SDK OAuth operations must always be given `sdkFetch(...)` so every request and redirect retains endpoint and SSRF checks.

### Reference connectors

<AccordionGroup>
<Accordion title="gatekeeper-context — Context Library">
An account providing a singleton read session plus a management UI, for authoring collections of context documents that agents read as observations. Collections are **private** (owned by one account, readable/writable only by it) or **public** (created and edited only by deployment admins, readable by everyone and auto-enabled for all users). State lives in `ContextCollectionDurableObject` (content), `UserLibraryDurableObject` (each account's private collections), and `LibraryRegistryDurableObject` (the domain's public set), plus a KV namespace. All data is namespaced by a `sharingDomain` taken from the binding's props (see `domain.ts`), so multiple workshops sharing one gatekeeper instance stay isolated.

Bound as `GATEKEEPER_CONTEXT`. Declares `autoProvisionsAccount` and mints a `ContextAccount` via `createAccount()` — no user identity is passed in; the account keys private data by its own generated `accountId`. Exposes `getSession()`, `getAgentCatalog()`, and `startAppUi({ isAdmin })`. Its UI is a single-file React SPA in `app/` (Vite + Tailwind + Kumo) bundled by `build-app.mjs` into `src/generated/app.txt`.
</Accordion>
<Accordion title="gatekeeper-scheduler — Scheduled Tasks">
An auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks, plus a read-only management UI.
</Accordion>
</AccordionGroup>

## The OS analogy, mapped to directories

The README uses "operating system" in two senses: an OS for *the company* to be productive with AI safely enough that the security team can sleep at night, and an OS for AI workloads, analogous to how a traditional OS manages compute workloads. The mapping to real code:

```text
  OS concept            Cloudflare OS
  ─────────────────     ────────────────────────────────────────────
  kernel            →   packages/workshop-backend
                        (reviewers read every line; small, elegant diffs)
  syscall ABI       →   packages/workshop-shared/src/api.ts
                        (Cap'n Web RPC; every exported member doc-commented)
  process           →   a gadget: a per-user private app instance
  process sandbox   →   iframe with postMessage() as its only channel out
                        + server-side Durable Object per gadget
  device drivers    →   packages/gatekeeper-*  (one Worker per service)
  capabilities      →   gatekeeper accounts and ambient records
  shell / desktop   →   packages/workshop-frontend
  init / dispatcher →   packages/router
```

<Info>
Per `AGENTS.md`, `workshop-backend` is explicitly "the kernel: it defines the architecture and is held to a higher bar than UI/gatekeeper code." Concrete kernel rules: doc-comment **every** exported member of the `workshop-shared` public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast; prefer reusing existing mechanisms over adding parallel ones; and split large changes by concern into separate PRs, or at minimum group commits so `workshop-backend` / `workshop-shared` can be reviewed apart from UI.
</Info>

## Root commands

| Command | What it does |
| --- | --- |
| `pnpm run-local` | `node scripts/run-local.mjs` — runs the whole stack locally on wrangler and workerd. Not for production. |
| `pnpm build` | `pnpm run --recursive build` |
| `pnpm test` | `node --test scripts/*.test.js && pnpm run --recursive --if-present test` |
| `pnpm dev-server` | `node run-dev-server.js` |
| `pnpm dev-client` | Vite dev server in `packages/workshop-frontend` |
| `pnpm lint` | `lint:check` (oxlint) then `types:check` (recursive `tsc --noEmit`) |
| `pnpm lint:check` / `pnpm lint:fix` | `oxlint` / `oxlint --fix` |
| `pnpm types:check` | `pnpm run --recursive --if-present types:check` |
| `pnpm clean` | `pnpm run --recursive clean` |
| `pnpm import:format-blueprint` | Replace a shipped format blueprint: `<export.gadget> <blueprintId>`; add one with `<export.gadget> --new <name>` |

<Steps>
<Step title="Install pnpm">
Cloudflare OS is pnpm-only. Install pnpm from https://pnpm.io/.
</Step>
<Step title="Run the stack">
```bash
pnpm run-local
```
</Step>
<Step title="Open the origin">
Visit http://localhost:8787. In `run-local` mode the backend has a static `assets` binding (with `run_worker_first` for the API routes) and serves the pre-built SPA, which is why the router's dev fallback to `WORKSHOP_BACKEND` works.
</Step>
<Step title="Exercise it">
Try "Make slides for my upcoming meeting with a customer." (uses the built-in slides blueprint), "Make a collaborative whiteboard app." (creates a new app from scratch), or "Make a tic tac toe game." followed by "I'll be X and you be O. I've made my first move. Your turn."

Prompts like "Make an issue dashboard for this GitHub repo." or "Fix the typos in this Google Doc." need an attached resource and a configured GitHub or Google integration.
</Step>
</Steps>

<Warning>
`pnpm dev-client` runs Vite on port 3000 and you should open localhost:3000 directly. The router deliberately does not forward frontend requests to Vite: HMR's socket gets disconnected every time wrangler restarts workerd.
</Warning>

## Format blueprints

`packages/workshop-backend/format-blueprints/` holds the **output format** blueprints the deployment ships with, committed as data: a `<name>.gadget` archive plus a `<name>.json` sidecar giving its `blueprintId`, prose, and `output` presentation. `scripts/build-format-blueprints.mjs` globs that directory into the gitignored `src/generated/format-blueprints.ts`, so `build`, `types:check`, and `test` all run the generator first. `FORMAT_BLUEPRINTS_DIR` overrides the directory, letting a fork ship its own set without touching the submodule.

<Warning>
Never edit a `blueprintId` after deploy. Install and promotion are keyed on it, and a rename orphans the old entry.
</Warning>

## Project status

This repository is version 2, a complete rewrite of Cloudflare OS on a new foundation. As of the August 2026 release it is described as very capable but with many rough edges — treat it as an early-access release. The stated intent of open-sourcing is not that other companies run "Cloudflare OS", but that they fork it into "*Your Company* OS". `pnpm run-local` is explicitly not meant for production use; the alternative path is deploying to your own Cloudflare account.

## Next

<CardGroup cols={2}>
<Card title="Installation" href="/installation">
Prerequisites and the three install paths: `pnpm run-local`, the hosted deploy wizard, and the starter repository.
</Card>
<Card title="Quickstart" href="/quickstart">
Bring the stack up on workerd, reach http://localhost:8787, and run the first prompts.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
The full router resolution order, backend bindings, and Durable Object migration tags.
</Card>
<Card title="Gadgets and sandboxing" href="/gadgets-and-sandboxing">
Dynamic Worker isolation through `LOADER`, the CSP-restricted iframe, and the Cap'n Web bridge over postMessage.
</Card>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`GatekeeperVendor`, `GatekeeperUser`, `AccountDescription`, and `autoProvisionsAccount` mode resolution.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`PublicApi`, `AuthenticatedApi`, `AdminApi`, `Overseer`, and the stub-disposal constraints.
</Card>
<Card title="Local development" href="/local-development">
The two-terminal `pnpm dev-server` / `pnpm dev-client` workflow and its flags.
</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">
Kernel review standards, doc-comment rules, and the RPC mirror-interface prohibition.
</Card>
</CardGroup>

---

## 02. Installation

> Prerequisites (pnpm, pinned Node 22.14.0) and the three install paths: `pnpm run-local` for a single-command local stack, the hosted deploy wizard, and the starter repository. Documents what run-local builds, its source-hash stamp caching, and where local data is written.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/02-installation.md
- Generated: 2026-08-05T16:38:17.182Z

### Source Files

- `scripts/run-local.mjs`
- `package.json`
- `README.md`
- `pnpm-workspace.yaml`
- `.gitlab-ci.yml`

---
title: "Installation"
description: "Prerequisites (pnpm, pinned Node 22.14.0) and the three install paths: `pnpm run-local` for a single-command local stack, the hosted deploy wizard, and the starter repository. Documents what run-local builds, its source-hash stamp caching, and where local data is written."
---

`pnpm run-local` is the single-command entry point for a local Cloudflare OS stack. It is a Node script (`scripts/run-local.mjs`) wired into the root `package.json` as `"run-local": "node scripts/run-local.mjs"`. It installs dependencies when needed, builds only the two outputs required to serve the app, then spawns `run-dev-server.js --serve-frontend-assets`, which serves the built frontend as static assets from the backend at `http://localhost:8787`. The whole stack runs on wrangler and workerd; the README states this is not meant for production use.

## Prerequisites

<ParamField body="pnpm" type="package manager" required>
The only supported package manager. The README's quick start is "install pnpm, then `pnpm run-local`". CI enables it via `corepack enable`.
</ParamField>

<ParamField body="Node.js" type="22.14.0">
The version CI pins. `.gitlab-ci.yml` sets `NODE_VERSION: "22.14.0"`, downloads that exact tarball from `nodejs.org`, and verifies it against `NODE_LINUX_X64_SHA256` before extracting.
</ParamField>

<ParamField body="git" type="optional">
`run-local` prefers `git ls-files` to enumerate source files for its hash. Without a git checkout (for example an extracted tarball) it falls back to a filesystem walk.
</ParamField>

<Note>
CI also runs `git submodule update --init` before `pnpm install --frozen-lockfile`, so a checkout with submodules should initialize them before installing.
</Note>

### Dependency install policy

`pnpm-workspace.yaml` defines the workspace as `packages/*` and constrains installs:

| Key | Value | Effect |
| --- | --- | --- |
| `minimumReleaseAge` | `1440` | Rejects dependency versions published in the last 24h, matching the CI supply-chain policy so a local install cannot commit a too-fresh lockfile. |
| `minimumReleaseAgeExclude` | `capnweb`, `capnweb-validate`, `workerd`, `@cloudflare/workerd-*` | Exempt from the release-age gate. |
| `overrides.workerd` | `>=1.20260623.1` | Minimum workerd for the local runtime. |
| `overrides` (pins) | `js-yaml@4.1.1 -> ^4.2.0`, `@codemirror/*`, `@types/node 26.1.0`, `@lezer/markdown 1.6.4` | Security patch (CVE-2026-53550) and mature-release pins. |
| `allowBuilds` | `@google/genai`, `core-js-pure`, `esbuild`, `protobufjs`, `sharp`, `workerd` | Only these packages may run install scripts. |

<Warning>
A too-fresh dependency will fail to install locally by design. `minimumReleaseAge: 1440` mirrors CI, which verifies every lockfile entry against the release-age policy.
</Warning>

## Path 1: `pnpm run-local`

<Steps>
<Step title="Install pnpm">
Follow https://pnpm.io/ . No other package manager is supported.
</Step>
<Step title="Run the single command">
```bash
pnpm run-local
```
Extra flags are forwarded verbatim to `run-dev-server.js`. For example, `pnpm run-local --use-workers-ai-binding` passes that flag through; `--serve-frontend-assets` is always added by the script.
</Step>
<Step title="Open the app">
Visit http://localhost:8787 . Expected console output before the server starts:

```text
Starting local server at http://localhost:8787 ...
```
</Step>
<Step title="Confirm it works">
Try a prompt from the README, for example "Make slides for my upcoming meeting with a customer." (uses the built-in slides blueprint) or "Make a collaborative whiteboard app." (creates a new app from scratch). Prompts that attach a GitHub repo or Google Doc require the corresponding integration to be configured.
</Step>
</Steps>

### What run-local builds

`run-local` deliberately builds only what is required to *run* the app — not a full-repo type-check and not the frontend `tsc` pass:

```bash
# scripts/run-local.mjs
pnpm --filter @gadgets/typed-storage build
pnpm --filter @gadgets/workshop-frontend exec vite build
```

| Output | Path | Why |
| --- | --- | --- |
| `@gadgets/typed-storage` dist | `packages/typed-storage/dist` | The backend imports it via its built `dist` output. |
| Frontend bundle | `packages/workshop-frontend/dist` | Served as static assets by the backend. |

Gatekeeper configurator files are not built here — `run-dev-server.js` generates them at startup.

```text
repo root
├── scripts/run-local.mjs        install -> build -> spawn server
├── run-dev-server.js            launched with --serve-frontend-assets
├── .run-local-stamp             source-hash stamp (written after a successful build)
├── node_modules/                presence gates the install step
└── packages/
    ├── typed-storage/dist/      built (backend imports dist)
    └── workshop-frontend/dist/  built (vite build) -> served on :8787
```

### Source-hash stamp caching

Repeat runs skip install and build entirely when nothing changed. The script hashes source file paths and contents with SHA-256 and compares the digest against `.run-local-stamp` in the repo root.

<AccordionGroup>
<Accordion title="How the hash is computed">
- File list comes from `git ls-files -z` plus `git ls-files -z --others --exclude-standard` (tracked files plus untracked-but-not-ignored files).
- If git fails, the script walks the filesystem, skipping `.git`, `node_modules`, `dist`, and `.wrangler`.
- Paths are normalized to `/` separators and sorted, so the digest is stable across platforms.
- For each file, the hash absorbs `path`, `\0`, contents, `\0`. A file listed but unreadable at hash time contributes `path\0<missing>\0`.
</Accordion>
<Accordion title="How the decision is made">
```js
const outputsPresent = existsSync(FRONTEND_DIST) && existsSync(TYPED_STORAGE_DIST);
const needsBuild = stamp !== sourceHash || !outputsPresent;
const needsInstall = needsBuild || !existsSync(NODE_MODULES);
```
Any source change — including a dependency change via `pnpm-lock.yaml`, since the lockfile is a tracked source file — flips the hash and triggers a rebuild. The stamp is written only after a successful build, so an interrupted build retries on the next run.
</Accordion>
</AccordionGroup>

Skip-path console output:

```text
Dependencies up to date; skipping install.
No source changes since last build; skipping build.
```

<Tip>
To force a full rebuild, delete `.run-local-stamp` (or remove `packages/workshop-frontend/dist` / `packages/typed-storage/dist`, since missing outputs also set `needsBuild`).
</Tip>

### Local data and generated paths

Everything `run-local` writes lives inside the repository checkout:

| Path | Written by | Contents |
| --- | --- | --- |
| `.run-local-stamp` | `run-local` | The hex SHA-256 source hash plus a trailing newline. |
| `node_modules/` | `pnpm install` | Workspace dependencies. |
| `packages/typed-storage/dist/` | `pnpm --filter @gadgets/typed-storage build` | Built module the backend imports. |
| `packages/workshop-frontend/dist/` | `vite build` | Frontend assets served on port 8787. |
| `.wrangler/` | local wrangler/workerd runtime | Excluded from the filesystem-walk hash alongside `.git`, `node_modules`, and `dist`. |

### Process lifecycle

The server runs as a child process spawned with `process.execPath` and `stdio: "inherit"`, so its logs appear in your terminal. On exit, `run-local` mirrors the child's outcome: if the child died from a signal it re-raises that signal on itself (`process.kill(process.pid, signal)`), otherwise it exits with the child's code (defaulting to `0`).

## Path 2: hosted deploy

Deploy to your own Cloudflare account from the hosted wizard at https://os.cloudflare.app/deploy .

<Info>
Gatekeepers are each implemented as a separate Worker. This repository ships several Gatekeepers you can deploy together with your own OS instance.
</Info>

## Path 3: starter repository

The intended adoption model is a fork, not a hosted multi-tenant service: "The idea is not that your company uses Cloudflare OS, but rather that you make it *Your Company* OS." The repository maps to OS concepts by directory:

| Normal OS | Cloudflare OS |
| --- | --- |
| kernel | `packages/workshop-backend` |
| device drivers | `packages/gatekeeper-*` |
| shell | `packages/workshop-frontend` |
| processes | gadgets |
| executables | blueprints |
| ACLs | shared permissions |

## Verify the install

Reproduce what CI enforces. `.gitlab-ci.yml` runs a blocking `lint` job and a `test` job that builds first:

<CodeGroup>
```bash lint (oxlint + recursive tsc --noEmit)
pnpm lint
```

```bash test (build first, then tests)
pnpm build
pnpm test
```
</CodeGroup>

Root script definitions:

| Script | Command |
| --- | --- |
| `build` | `pnpm run --recursive build` |
| `lint` | `pnpm run lint:check && pnpm run types:check` |
| `lint:check` | `oxlint` |
| `types:check` | `pnpm run --recursive --if-present types:check` |
| `test` | `node --test scripts/*.test.js && pnpm run --recursive --if-present test` |
| `clean` | `pnpm run --recursive clean` |
| `dev-server` | `node run-dev-server.js` |
| `dev-client` | `cd packages/workshop-frontend && pnpm run dev` |

<Warning>
Cloudflare OS is in heavy development. The repository is version 2, a complete rewrite of version 1; as of the August 2026 release it is described as capable but rough-edged early access.
</Warning>

## Troubleshooting

<AccordionGroup>
<Accordion title="run-local rebuilds on every invocation">
The stamp is only written after both builds succeed, and `needsBuild` is also true whenever `packages/workshop-frontend/dist` or `packages/typed-storage/dist` is missing. Check that both dist directories exist and that `.run-local-stamp` is writable in the repo root.
</Accordion>
<Accordion title="run-local reinstalls unexpectedly">
`needsInstall` is `needsBuild || !existsSync(node_modules)`. Any source change implies an install, so editing a tracked file (or the lockfile) will re-run `pnpm install`.
</Accordion>
<Accordion title="Install fails on a recently published dependency">
`minimumReleaseAge: 1440` rejects versions published within the last 24 hours, except the packages listed in `minimumReleaseAgeExclude`.
</Accordion>
<Accordion title="Node version mismatch">
CI pins `22.14.0`. Match that version locally to reproduce CI's `pnpm lint` / `pnpm build` / `pnpm test` results.
</Accordion>
<Accordion title="Prompts that need an integration fail">
README prompts that attach a GitHub repo or a Google Doc require the corresponding integration to be configured; the from-scratch and blueprint prompts do not.
</Accordion>
</AccordionGroup>

## Next

<CardGroup>
<Card title="Quickstart" href="/quickstart">Reach http://localhost:8787, confirm success, and run the first prompts.</Card>
<Card title="Local development" href="/local-development">The two-terminal `pnpm dev-server` / `pnpm dev-client` workflow and its flags.</Card>
<Card title="Overview" href="/overview">Package layout, the OS-analogy mapping, and runtime assumptions.</Card>
<Card title="Build, lint, and test" href="/build-lint-test">What CI enforces and the required ordering.</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">Wire OAuth credentials into a connector for integration-dependent prompts.</Card>
<Card title="Troubleshooting" href="/troubleshooting">Known failure modes across the wider stack.</Card>
</CardGroup>

---

## 03. Quickstart

> Run the full stack on workerd with `pnpm run-local`, reach http://localhost:8787, and confirm success. Includes the first prompts that exercise bundled format blueprints versus from-scratch gadget creation, and which prompts require a configured gatekeeper.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/03-quickstart.md
- Generated: 2026-08-05T16:38:18.900Z

### Source Files

- `README.md`
- `scripts/run-local.mjs`
- `run-dev-server.js`
- `wrangler.jsonc`
- `packages/workshop-backend/format-blueprints/README.md`

---
title: "Quickstart"
description: "Run the full stack on workerd with `pnpm run-local`, reach http://localhost:8787, and confirm success. Includes the first prompts that exercise bundled format blueprints versus from-scratch gadget creation, and which prompts require a configured gatekeeper."
---

`pnpm run-local` is the single command that boots the whole Cloudflare OS stack on wrangler and workerd. It installs dependencies if needed, builds only the two artifacts required to serve the app (`@gadgets/typed-storage` and the frontend Vite bundle), then launches `run-dev-server.js --serve-frontend-assets`, which serves the built frontend as static assets from the backend at http://localhost:8787. It is not meant for production use; it is the fastest way to see what the product does.

## Prerequisites

| Requirement | Notes |
| --- | --- |
| pnpm | The only supported package manager for this repo. |
| Node | `scripts/run-local.mjs` runs with `process.execPath`; it re-invokes the same Node binary for `run-dev-server.js` and the generators. |
| Cloudflare login | Only needed if you pass `--use-workers-ai-binding`. |

<Note>
A git checkout is preferred but not required. `run-local` hashes sources via `git ls-files` and falls back to a filesystem walk (skipping `.git`, `node_modules`, `dist`, `.wrangler`) when the tree is not a git checkout — for example an extracted tarball.
</Note>

## Run the stack

<Steps>
<Step title="Start the local stack">
```bash
pnpm run-local
```

Extra flags are forwarded verbatim to `run-dev-server.js`, so `pnpm run-local --use-workers-ai-binding` adds the Workers AI binding to `workshop-backend`.
</Step>

<Step title="Watch the startup output">
On a cold checkout you see the install and both builds, then the server line:

```text
> pnpm install
> pnpm --filter @gadgets/typed-storage build
> pnpm --filter @gadgets/workshop-frontend exec vite build

Starting local server at http://localhost:8787 ...
generated: /path/to/repo/wrangler.dev.jsonc
```

On a warm run with no source changes it short-circuits:

```text
Dependencies up to date; skipping install.
No source changes since last build; skipping build.

Starting local server at http://localhost:8787 ...
```
</Step>

<Step title="Open the app">
Visit http://localhost:8787. The request lands on the `dev-router` worker (`packages/router/src/index.ts`). Because the root `wrangler.jsonc` configures no `ASSETS` binding, the router forwards frontend requests to `workshop-backend`, which serves them from its own assets binding in `run-local` mode.
</Step>
</Steps>

## Confirm success

<Check>
Signals that the stack came up correctly:

- `generated: <repo>/wrangler.dev.jsonc` printed at startup — the dev router config was written with one service binding per discovered gatekeeper package.
- http://localhost:8787 serves the frontend rather than a 404 or connection refused.
- No `... watcher exited unexpectedly (code=…, signal=…)` lines on stderr; those come from the configurator/app UI watchers `run-dev-server.js` keeps alive.
- `.run-local-stamp` exists at the repo root after the build completes. It is written only after a successful build, so an interrupted build retries on the next run.
</Check>

The very first `/api` request also provisions the deployment: the bundled `.gadget` format blueprints in `packages/workshop-backend/format-blueprints/` are installed into the BLUEPRINTS KV namespace and the BLUEPRINT_CONTENT R2 bucket. Nothing wakes on deploy, so a fresh instance is provisioned by its first visitor.

## What run-local starts

```text
pnpm run-local  (scripts/run-local.mjs)
  ├─ source hash vs .run-local-stamp  → skip install/build when unchanged
  ├─ pnpm install                     (if needed)
  ├─ build @gadgets/typed-storage     → packages/typed-storage/dist
  ├─ vite build workshop-frontend     → packages/workshop-frontend/dist
  └─ run-dev-server.js --serve-frontend-assets
       ├─ load root .dev.vars into process.env (shell wins)
       ├─ build-format-blueprints.mjs  → src/generated/format-blueprints.ts
       ├─ discover packages/gatekeeper-*  (must have wrangler.jsonc)
       ├─ build + watch gatekeeper configurator / app UIs
       └─ write wrangler.dev.jsonc, then `wrangler dev`

  http://localhost:8787
       dev-router ──(no ASSETS binding)──▶ WORKSHOP_BACKEND ──▶ built frontend
```

Gatekeeper packages are discovered by name prefix (`gatekeeper-`) plus the presence of `wrangler.jsonc`, and each becomes a service binding whose name is the directory uppercased with `-` replaced by `_` — `gatekeeper-github` → `GATEKEEPER_GITHUB`.

## First prompts to try

The prompts below split into three groups by what they exercise.

| Prompt | Exercises | Needs a gatekeeper? |
| --- | --- | --- |
| "Make slides for my upcoming meeting with a customer." | Built-in slides blueprint | No |
| "Make a collaborative whiteboard app." | New gadget created from scratch | No |
| "Make a tic tac toe game." then "I'll be X and you be O. I've made my first move. Your turn." | New gadget plus agent interaction with the running gadget | No |
| "Make an issue dashboard for this GitHub repo." (attach a repo) | Gatekeeper-mediated external resource | Yes — GitHub integration configured |
| "Fix the typos in this Google Doc." (attach a doc) | Gatekeeper-mediated external resource | Yes — Google integration configured |

<Info>
The bundled format blueprints exist so a fresh deployment can write a doc or build a deck without anyone building one first. A *format* is an ordinary blueprint the deployment has promoted (`AdminConfig.formats`); the bundled set is the one it promotes out of the box.
</Info>

<Warning>
The two attachment prompts fail without credentials. `run-dev-server.js` seeds each connector's `CLIENT_ID` / `CLIENT_SECRET` from shared shell or `.dev.vars` variables — `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` for `gatekeeper-github`, `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` for `gatekeeper-google`. Credentials already defined in a gatekeeper's own `wrangler.jsonc` win over the seeded values.
</Warning>

## Local configuration

<ParamField body=".dev.vars" type="KEY=VALUE file at repo root">
Loaded by `run-dev-server.js` before anything else. Blank lines and `#` comments are skipped; surrounding single or double quotes are stripped. Existing shell environment values take precedence over file values. The file is gitignored, so local secrets cannot leave the machine.
</ParamField>

<ParamField body="--use-workers-ai-binding" type="flag">
Includes the Workers AI binding in `workshop-backend`. Requires a Cloudflare login. Pass it through `pnpm run-local --use-workers-ai-binding`.
</ParamField>

<ParamField body="--serve-frontend-assets" type="flag">
Set automatically by `run-local`. The backend serves the pre-built frontend bundle as static assets because there is no Vite dev server in this mode.
</ParamField>

<ParamField body="VITE_BACKEND_HOST" type="string" default="localhost:8787">
`run-dev-server.js` derives the wrangler port from this value via `getWranglerPortFromBackendHost`, so `VITE_BACKEND_HOST=localhost:9000` also passes `--port 9000` to `wrangler dev`.
</ParamField>

<ParamField body="FORMAT_BLUEPRINTS_DIR" type="path" default="packages/workshop-backend/format-blueprints">
Points the build at a different format-blueprint directory. Whatever directory it names *is* the deployment's format set — it replaces the bundled one rather than adding to it. Same `<name>.gadget` + `<name>.json` layout.
</ParamField>

## Troubleshooting

<AccordionGroup>
<Accordion title="Nothing rebuilt after I edited a file">
`run-local` skips install and build only when the source hash matches `.run-local-stamp` **and** both `packages/workshop-frontend/dist` and `packages/typed-storage/dist` exist. The hash covers everything git tracks plus untracked-but-not-ignored files, including `pnpm-lock.yaml`, so any tracked source change flips it. If a change lives in an ignored path, it is invisible to the stamp.
</Accordion>

<Accordion title="Missing generated module on a clean checkout">
`src/generated/format-blueprints.ts` is gitignored and therefore absent on a fresh clone. `run-dev-server.js` runs `packages/workshop-backend/scripts/build-format-blueprints.mjs` before wrangler bundles the backend, so start through `pnpm run-local` (or `run-dev-server.js`) rather than invoking `wrangler dev` directly.
</Accordion>

<Accordion title="A gatekeeper does not appear">
Discovery requires both conditions: the directory name under `packages/` starts with `gatekeeper-`, and `packages/<name>/wrangler.jsonc` is a file. If either fails the package is silently skipped and no `GATEKEEPER_*` service binding is added to `wrangler.dev.jsonc`.
</Accordion>

<Accordion title="Port 8787 is taken">
Set `VITE_BACKEND_HOST` to another host:port before starting; the port is forwarded to `wrangler dev` as `--port`.
</Accordion>

<Accordion title="Interrupted build, then a stale-looking run">
The stamp is written only after both builds succeed. If you `Ctrl-C` mid-build, the next `pnpm run-local` rebuilds. `SIGINT` exits 130 and `SIGTERM` exits 143 after stopping the UI watchers.
</Accordion>
</AccordionGroup>

<Note>
This release is early access. Cloudflare OS v2 is a complete rewrite of v1 and, as of the August 2026 release, is capable but still has rough edges.
</Note>

## Next

<CardGroup cols={2}>
<Card title="Installation" href="/installation">
The three install paths, what `run-local` builds, its source-hash stamp caching, and where local data is written.
</Card>
<Card title="Local development" href="/local-development">
The two-terminal workflow with `pnpm dev-server` and `pnpm dev-client` on Vite port 3000.
</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
Register a third-party OAuth app and wire `CLIENT_ID` / `CLIENT_SECRET` so the GitHub and Google prompts work.
</Card>
<Card title="Manage bundled format blueprints" href="/bundled-format-blueprints">
The `.gadget` plus `.json` sidecar split, `FORMAT_BLUEPRINTS_DIR`, and `pnpm import:format-blueprint`.
</Card>
<Card title="Overview" href="/overview">
The router origin, Workers kernel, gadget sandboxes, and gatekeeper connectors.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How the router resolves `/api/*`, `/gatekeeper/<name>/*`, and asset requests.
</Card>
</CardGroup>

---

## 04. Local development

> The two-terminal workflow: `pnpm dev-server` (router plus workers on wrangler) and `pnpm dev-client` (Vite on port 3000). Covers `.dev.vars` loading, generated dev wrangler files, dynamic gatekeeper service-binding discovery, the `--use-workers-ai-binding` and `--serve-frontend-assets` flags, and `VITE_BACKEND_HOST` port selection.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/04-local-development.md
- Generated: 2026-08-05T16:38:33.695Z

### Source Files

- `run-dev-server.js`
- `scripts/dev-server-config.js`
- `scripts/dev-server-config.test.js`
- `wrangler.jsonc`
- `packages/router/src/index.ts`
- `docs/public-server.md`

---
title: "Local development"
description: "The two-terminal workflow: `pnpm dev-server` (router plus workers on wrangler) and `pnpm dev-client` (Vite on port 3000). Covers `.dev.vars` loading, generated dev wrangler files, dynamic gatekeeper service-binding discovery, the `--use-workers-ai-binding` and `--serve-frontend-assets` flags, and `VITE_BACKEND_HOST` port selection."
---

Local development runs two processes. `pnpm dev-server` executes `run-dev-server.js`, which loads a root `.dev.vars`, generates the format-blueprint module, discovers gatekeeper packages under `packages/`, writes dev-only `wrangler.dev.jsonc` files with the resulting service bindings, and launches `wrangler dev` with all discovered workers. `pnpm dev-client` runs the Vite dev server on port 3000, which is where you open the frontend — the dev router deliberately does not proxy to Vite, because Vite's HMR socket disconnects every time wrangler restarts workerd.

## The two terminals

<Steps>
<Step title="Terminal 1 — router plus workers on wrangler">

```bash
pnpm run dev-server
```

Generates the dev wrangler configs, then starts a single multi-config `wrangler dev` process from the repo root covering the `dev-router` (`wrangler.jsonc`, `main: packages/router/src/index.ts`), `workshop-backend`, and every discovered `gatekeeper-*` package.

</Step>
<Step title="Terminal 2 — Vite frontend">

```bash
pnpm dev-client
```

Serves the frontend on `localhost:3000`. Open that port directly; do not expect the wrangler origin to serve frontend routes in normal dev mode.

</Step>
</Steps>

<Warning>
In normal dev mode the backend has no assets binding, and the dev router has no `ASSETS` binding, so any non-API request that reaches wrangler falls through to `WORKSHOP_BACKEND` rather than to a frontend build. That fallback is only useful in `run-local` mode.
</Warning>

## Dev routing model

The router package doubles as the dev router. Routing config is the binding set: gatekeepers are discovered at request time by scanning `GATEKEEPER_*` keys on `env`, lowercasing the suffix and replacing `_` with `-`, so `GATEKEEPER_GITHUB` serves `/gatekeeper/github` and everything below it.

```mermaid
flowchart TB
  subgraph browser["Browser"]
    vite["localhost:3000 (Vite dev server)"]
    wrang["wrangler dev origin"]
  end

  subgraph devwrangler["wrangler dev (multi-config, repo root)"]
    router["dev-router<br/>packages/router/src/index.ts"]
    backend["workshop-backend"]
    gk["gatekeeper-* workers<br/>(GATEKEEPER_GITHUB, GATEKEEPER_GOOGLE,<br/>GATEKEEPER_CONTEXT, GATEKEEPER_EMAIL, …)"]
  end

  subgraph generated["Generated dev config (gitignored outputs)"]
    rootcfg["wrangler.dev.jsonc (root)"]
    gkcfg["packages/gatekeeper-*/wrangler.dev.jsonc"]
    fmt["workshop-backend format-blueprints module"]
  end

  vite -->|"VITE_BACKEND_HOST"| router
  wrang --> router
  router -->|"/gatekeeper/&lt;name&gt;/*"| gk
  router -->|"/api, /api/*, /blueprint-screenshot*"| backend
  router -->|"no ASSETS binding in dev: fallback"| backend
  rootcfg -.-> router
  gkcfg -.-> gk
  fmt -.-> backend
```

| Request | Dev destination |
| --- | --- |
| `/gatekeeper/<suffix>` and `/gatekeeper/<suffix>/*` | The matching `GATEKEEPER_*` service binding |
| `/api`, `/api/*` | `WORKSHOP_BACKEND` |
| `/blueprint-screenshot`, `/blueprint-screenshot/*` | `WORKSHOP_BACKEND` |
| Everything else | `env.ASSETS` when present (production); otherwise `WORKSHOP_BACKEND` |
| Inbound email | `GATEKEEPER_EMAIL.email(message)`, or `message.setReject(...)` when unbound |

<Note>
Gatekeeper OAuth redirects land on the gatekeeper Workers themselves at `/gatekeeper/<name>/oauth`, handled by the discovery loop above. There are no backend `/auth` callbacks.
</Note>

## `.dev.vars` loading

`run-dev-server.js` calls `loadDevVars()` before anything else. It reads a root `.dev.vars` file — gitignored, `KEY=VALUE` per line — and copies entries into `process.env`.

Parsing rules, as implemented:

- Lines are trimmed; empty lines and lines starting with `#` are skipped.
- The first `=` splits key from value; lines with no `=` are skipped.
- Key and value are trimmed, and a single matching pair of surrounding `"` or `'` is stripped from the value.
- An existing shell environment value wins: assignment happens only when `process.env[key] === undefined`.
- A missing `.dev.vars` is not an error — the loader returns immediately.

A minimal public-service example:

```ini title=".dev.vars"
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

# Each gatekeeper's OAuth app (client id/secret). In dev these seed the gatekeeper Workers:
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier:
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set (all inference goes over HTTPS with tokens):
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...

# To send Workers AI straight to its REST endpoint (no gateway, no cost logs):
CF_AI_GATEWAY_WAI_DIRECT=true
```

## Generated dev files and prebuild steps

Before wrangler starts, `run-dev-server.js` performs generation work whose outputs are gitignored and therefore absent on a clean checkout.

:::files
```
<repo root>
├── wrangler.jsonc                     # checked in: dev-router, WORKSHOP_BACKEND service binding
├── wrangler.dev.jsonc                 # generated: wrangler.jsonc + one service per gatekeeper
├── .dev.vars                          # gitignored, KEY=VALUE per line
├── run-dev-server.js
├── scripts/
│   ├── dev-server-config.js           # getWranglerPortFromBackendHost()
│   ├── dev-server-config.test.js
│   └── build-gatekeeper-configurator.mjs
└── packages/
    ├── router/src/index.ts            # dev router + production router
    ├── workshop-backend/
    │   └── scripts/build-format-blueprints.mjs
    └── gatekeeper-*/
        ├── wrangler.jsonc             # checked in
        ├── wrangler.dev.jsonc         # generated
        ├── build-app.mjs              # optional single-file app UI build
        └── src/
            ├── configurator/          # optional configurator UI source
            └── generated/             # configurator output, app.txt
```
:::

| Generation step | Trigger | Purpose |
| --- | --- | --- |
| `packages/workshop-backend/scripts/build-format-blueprints.mjs` | Always, before wrangler bundles the backend | Produces the format-blueprint module; gitignored, so it does not exist on a clean checkout |
| `scripts/build-gatekeeper-configurator.mjs <gkDir> --quiet` | When `<gkDir>/src/configurator` exists | Compiles the configurator UI into `src/generated` |
| `<gkDir>/build-app.mjs` | When that file exists | Writes the single-file Vite app bundle to `src/generated/app.txt` |
| Root `wrangler.dev.jsonc` | Always | Parses `wrangler.jsonc` with `jsonc-parser`, appends one `services` entry per gatekeeper, writes JSON and logs `generated: <path>` |
| Per-gatekeeper `wrangler.dev.jsonc` | Per discovered gatekeeper | Adds the explicit `cwd` a root-launched multi-config wrangler process needs, and injects shared OAuth credentials |

### Watchers

For each gatekeeper with a configurator or `build-app.mjs`, the script first runs a one-shot build, then spawns a persistent `--watch` child so UI edits show up on reload; `wrangler dev`'s `watch_dir: src` then re-bundles the worker. Watchers are killed on `exit`, and on `SIGINT` (exit code `130`) and `SIGTERM` (exit code `143`). An unexpected watcher exit logs `<label> exited unexpectedly (code=…, signal=…)`.

## Gatekeeper discovery and binding names

`findGatekeepers(packages/)` selects entries whose name starts with `gatekeeper-` **and** that contain a `wrangler.jsonc` file. Directory-read failures and per-entry `statSync` failures are swallowed, yielding an empty list or skipping the entry rather than throwing.

Binding names come from `bindingName(gk)`: uppercase the package name and replace `-` with `_`.

```text
packages/gatekeeper-github   ->  binding GATEKEEPER_GITHUB   service gatekeeper-github
packages/gatekeeper-context  ->  binding GATEKEEPER_CONTEXT  service gatekeeper-context

router request-time reverse mapping:
GATEKEEPER_GITHUB  ->  suffix "github"   ->  /gatekeeper/github/*
```

The Context Library (`packages/gatekeeper-context`) is discovered by `findGatekeepers` and bound like any other gatekeeper. Its `describe()` reports `autoProvisionsAccount`, so core auto-provisions one Context account per user; the only extra wiring it needs is a `sharingDomain` in its binding props.

### Shared OAuth credential seeding

`SHARED_GATEKEEPER_CREDS` maps a gatekeeper package name to the shell/`.dev.vars` variables that seed its `CLIENT_ID` / `CLIENT_SECRET`, so one OAuth app can drive both sign-in and capability connection. For example, `gatekeeper-github` reads `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. Gatekeepers without shared creds keep their raw config, and credentials already defined in a gatekeeper's own config still win.

Register each OAuth app's redirect URI against `PUBLIC_BASE_URL`:

- GitHub: `${PUBLIC_BASE_URL}/gatekeeper/github/oauth`
- Google: `${PUBLIC_BASE_URL}/gatekeeper/google/oauth`
- Cloudflare: `${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth`

## Flags

<ParamField body="--use-workers-ai-binding" type="flag">
Include the Workers AI binding in `workshop-backend`. Requires a Cloudflare login. Use it when running with `CF_AI_GATEWAY*` configured, so the `webFetch` tool's document-to-Markdown conversion still has a `WORKERS_AI` binding — inference itself no longer uses the binding and goes over HTTPS with the gateway tokens.
</ParamField>

<ParamField body="--serve-frontend-assets" type="flag">
Configure the backend to serve the pre-built frontend bundle as static assets, as used by `run-local` mode. Omitted in normal dev mode so the frontend is served by Vite on `:3000` and no `vite build` is required to start the dev server.
</ParamField>

Both flags are detected with `process.argv.includes(...)`, so pass them through `pnpm` with `--`:

```bash
pnpm run dev-server -- --use-workers-ai-binding
pnpm run dev-server -- --serve-frontend-assets
```

<Info>
In `run-local` mode the backend's static `assets` binding uses `run_worker_first` for the API routes, so the router's dev fallback to `WORKSHOP_BACKEND` returns the pre-built single-page app for frontend requests.
</Info>

## `VITE_BACKEND_HOST` and wrangler port selection

`VITE_BACKEND_HOST` names the host (and optional port) the frontend talks to. `run-dev-server.js` feeds it through `getWranglerPortFromBackendHost()` from `scripts/dev-server-config.js`; when a port is present, that port is also passed to `wrangler dev` as `--port`, keeping both sides on the same number.

```bash
VITE_BACKEND_HOST=localhost:9000 pnpm run dev-server   # also runs wrangler dev --port 9000
```

Behavior of `getWranglerPortFromBackendHost(backendHost)`:

<ResponseField name="return" type="string | null">
The port as a string when the trimmed host contains one; `null` for an empty string or a host with no port. Throws on invalid input.
</ResponseField>

| Input | Result |
| --- | --- |
| `"localhost:9000"` | `"9000"` |
| `"[::1]:9001"` | `"9001"` |
| `"localhost"` | `null` |
| `""` (or whitespace only) | `null` |
| `"localhost:0"` | Throws `VITE_BACKEND_HOST must include a valid port between 1 and 65535.` |
| `"localhost:99999"` | Throws `VITE_BACKEND_HOST must include a valid port …` |
| `"[::1]:99999"` | Throws `VITE_BACKEND_HOST must include a valid port …` |
| `"http://localhost:9000"` | Throws `VITE_BACKEND_HOST must include a valid host with an optional port.` |

Implementation notes: a value containing `://` is rejected before parsing; otherwise the host is parsed as `new URL("http://" + trimmed)`. A parse failure that matches a `host:port`-shaped pattern reports the port error, everything else reports the host error. A successfully parsed port below `1` also throws the port error.

## Troubleshooting

<AccordionGroup>
<Accordion title="Frontend requests hit the backend instead of the app">
In normal dev mode neither the dev router nor the backend has assets configured, so the router's final fallback sends the request to `WORKSHOP_BACKEND`. Run `pnpm dev-client` and open `localhost:3000` directly, or start the server with `--serve-frontend-assets` for the `run-local` posture.
</Accordion>

<Accordion title="A module generated at build time is missing on a clean checkout">
The format-blueprint module and each gatekeeper's `src/generated` output are gitignored. `pnpm run dev-server` regenerates them on startup — run it (or the individual generator scripts) before bundling.
</Accordion>

<Accordion title="A gatekeeper never gets a service binding">
`findGatekeepers` requires both the `gatekeeper-` name prefix and a `wrangler.jsonc` file in the package directory. Without `wrangler.jsonc` the package is silently skipped, so no `GATEKEEPER_*` binding is generated and `/gatekeeper/<name>/*` falls through to the backend.
</Accordion>

<Accordion title="Values in .dev.vars appear to be ignored">
Shell environment values take precedence: `loadDevVars()` only sets a key when `process.env[key]` is `undefined`. Unset the shell variable, or change it there instead.
</Accordion>

<Accordion title="Gateway mode fails on document conversion">
When `CF_AI_GATEWAY*` is set locally, start with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` tool still has a `WORKERS_AI` binding. Gateway mode itself always requires `CF_AI_GATEWAY_ACCOUNT_ID` and `CF_AI_GATEWAY_API_TOKEN`, with AI Gateway Run and Read permissions.
</Accordion>

<Accordion title="Vite HMR keeps disconnecting">
Expected if you try to reach the frontend through the wrangler origin — the dev router intentionally does not forward to `localhost:3000`, because HMR's socket drops on every wrangler restart of workerd. Use port 3000 directly.
</Accordion>
</AccordionGroup>

## Verification

```bash
# Port-selection helper unit tests
node --test scripts/dev-server-config.test.js
```

Startup signals to look for in terminal 1: the format-blueprint generator output, one `generated: <path>` line for the root `wrangler.dev.jsonc`, and per-gatekeeper watcher output for packages that build a configurator or app UI.

## Related pages

<CardGroup>
<Card title="Installation" href="/installation">Prerequisites, the pinned Node version, and what `pnpm run-local` builds.</Card>
<Card title="Quickstart" href="/quickstart">Run the whole stack on workerd and reach http://localhost:8787.</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">How the router resolves `/api/*`, `/gatekeeper/<name>/*`, assets, and inbound email.</Card>
<Card title="Environment variables" href="/environment-variables">Every backend variable and its default, including the `CF_AI_GATEWAY*` family.</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">OAuth redirect-URI contract and dev seeding from shell variables.</Card>
<Card title="Configure sign-in and AI Gateway billing" href="/configure-signin-and-billing">`AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, and `ENABLE_CLOUDFLARE_LIMITS`.</Card>
<Card title="Build, lint, and test" href="/build-lint-test">Generator prerequisites and the commands CI enforces.</Card>
<Card title="Troubleshooting" href="/troubleshooting">Missing generated modules, gateway-mode binding errors, and other failure modes.</Card>
</CardGroup>

---

## 05. Gadgets and sandboxing

> How a gadget is isolated: a Dynamic Worker loaded through the `LOADER` binding with internet access disabled, a sandboxed client iframe restricted by Content-Security-Policy, and a Cap'n Web session bridged over postMessage. Covers per-gadget Durable Object storage, code versions, and the `global_fetch_strictly_public` SSRF posture.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/05-gadgets-and-sandboxing.md
- Generated: 2026-08-05T16:39:02.829Z

### Source Files

- `packages/workshop-backend/src/overseer.ts`
- `packages/workshop-backend/wrangler.jsonc`
- `packages/workshop-backend/src/web-fetch.ts`
- `packages/workshop-shared/src/api.ts`
- `AGENTS.md`

---
title: "Gadgets and sandboxing"
description: "How a gadget is isolated: a Dynamic Worker loaded through the `LOADER` binding with internet access disabled, a sandboxed client iframe restricted by Content-Security-Policy, and a Cap'n Web session bridged over postMessage. Covers per-gadget Durable Object storage, code versions, and the `global_fetch_strictly_public` SSRF posture."
---

A gadget is isolated on two sides at once. Server-side, gadget code runs as a Dynamic Worker loaded through the `LOADER` worker-loader binding declared in `packages/workshop-backend/wrangler.jsonc`, wrapped in the `CODE_MODE_HARNESS` module defined in `packages/workshop-backend/src/overseer.ts`. Client-side, the gadget UI runs in a sandboxed iframe that, per the contract documented at the top of `packages/workshop-shared/src/api.ts`, "has no ability to talk to the outside world at all, except `postMessage()` to the parent frame" — and through that channel it speaks Cap'n Web RPC back to the Workshop, which hands it a stub pointing at the gadget's server-side Durable Object interface.

## The two sandboxes

```text
  ┌──── browser ─────────────────────────────────┐   ┌──── Workers ──────────────────────────────┐
  │                                              │   │                                           │
  │  workshop-frontend (SPA, React + Vite)       │   │  workshop-backend (the "kernel")          │
  │    │  persistent WebSocket, Cap'n Web        │   │    OverseerDurableObject  ── ctx.exports  │
  │    ├────────────── /api ───────────────────────────▶  UserDurableObject                      │
  │    │                                         │   │      │                                    │
  │    ▼  postMessage() is the ONLY exit         │   │      │ env.LOADER (worker_loaders)         │
  │  ┌────────────────────────────┐              │   │      ▼                                    │
  │  │ sandboxed gadget iframe    │              │   │  Dynamic Worker                            │
  │  │  - CSP-restricted          │◀── stub ─────────▶    CODE_MODE_HARNESS + agent.js            │
  │  │  - no network of its own   │   (DO iface) │   │    globalOutbound disabled                 │
  │  └────────────────────────────┘              │   │                                           │
  └──────────────────────────────────────────────┘   └───────────────────────────────────────────┘
```

Both halves are deliberate: the frontend is a pure client-side SPA precisely *because* "the Gadgets themselves are sandboxed on the client side in addition to the server side," and that sandboxing requires running code in the browser — a gadget cannot be server-side rendered.

## Server side: the Dynamic Worker

Gadget code is not deployed as a Worker script. It is loaded at request time through `env.LOADER`, declared as a `worker_loaders` binding:

```jsonc title="packages/workshop-backend/wrangler.jsonc"
"worker_loaders": [
  { "binding": "LOADER" }
]
```

The loaded module is not the gadget's file directly. `overseer.ts` wraps it in a fixed harness module that imports the gadget's `agent.js` and exposes a `WorkerEntrypoint`:

```js title="CODE_MODE_HARNESS (packages/workshop-backend/src/overseer.ts)"
import { WorkerEntrypoint, restore, RpcStub, RpcTarget } from "cloudflare:workers";
import agent from "agent.js";

export default class extends WorkerEntrypoint {
  verify() {}
  async run(self, callbackResolvers) {
    let env = this.env;
    if (callbackResolvers) {
      for (let [index, {resolve, reject}] of Object.entries(callbackResolvers)) {
        env[index] = { args: env[index], resolve, reject };
      }
    }
    await agent(self, env, this.ctx);
  }
  [restore](params) { /* placeholder stub for persistent hook callbacks */ }
}
```

The corresponding TypeScript view of the loaded entrypoint is `CodeModeEntrypoint`:

<ResponseField name="verify()" type="() => void">
Cheap liveness/compile check on the loaded module. Called with no arguments.
</ResponseField>

<ResponseField name="run(self?, callbackResolvers?)" type="Promise<void>">
Invokes the gadget's default export as `agent(self, env, this.ctx)`. `callbackResolvers` is a record keyed by binding index, each holding native `RpcStub` values for `resolve` and `reject`; the harness folds each one into `env[index]` as `{ args, resolve, reject }` before the gadget runs.
</ResponseField>

### The `[restore]` placeholder

The harness implements `[restore]` from `cloudflare:workers`, but not to return a live target. Per the comment in the harness, the runtime does not yet let the backend invoke the gadget's own `[restore]()` to produce the real target stub, so `[restore]` returns a `PlaceholderRpcTarget` — a `Proxy` that returns `undefined` for `then` and `dup` and throws on every other property access:

> "Tried to invoke a placeholder stub for a persistent hook callback. This stub is only intended to be stored; once loaded back from storage it will work properly."

<Warning>
This is explicitly a temporary hack pending runtime APIs for sealing/unsealing. The placeholder is only safe because such stubs are constructed to be handed to `bindHook()` and stored; once read back from storage the stub has been replaced with the real thing. Calling one before it round-trips through storage throws.
</Warning>

Stub storage is enabled by the `allow_irrevocable_stub_storage` compatibility flag in `wrangler.jsonc`, alongside `enhanced_error_serialization` and `nodejs_compat`.

## Client side: the sandboxed iframe

The gadget UI runs inside a sandboxed iframe with no route to the outside world other than `postMessage()` to its parent frame. That single channel is used to carry Cap'n Web RPC exchanges between the gadget and the Workshop, and one of the capabilities the Workshop passes across it is a stub for the gadget's own server-side Durable Object interface.

The RPC protocol is Cap'n Web (`capnweb`), chosen because it has semantics similar to Cloudflare's Worker-to-Worker RPC while being able to run in a browser over WebSocket. The client's own link to the backend is a separate persistent WebSocket to `/api`, opened at startup and kept open for the whole session, reconnecting as needed.

<Note>
Because the gadget frame has no network of its own, every effect a gadget has — storage, connector access, outbound fetches — is mediated by a capability handed to it over `postMessage`. A capability the gadget was never given is a capability it cannot reach.
</Note>

## Capability posture around gadget bindings

The kernel rules in `AGENTS.md` constrain how a gadget acquires capabilities:

| Rule | Consequence for a gadget |
| --- | --- |
| A resource becomes "ambient" (auto-injected) only by user or admin configuration | A gatekeeper must never assert its own ambience into a gadget's env |
| Ambient singletons are folded into each chat's env as a named chat binding | Named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in `overseer.ts` |
| Ambient singletons are not bound to any gadget by default | Most gadgets never call one programmatically; the agent must wire it in with `setGadgetBinding` when the gadget's persistent code needs it |
| The account capability, not an asserted identity, is the authority | Auto-provisioned accounts are persisted in the user DO like any connected account |

Binding names are validated before they can appear as `env.NAME` in gadget code. `workshop-shared/src/api.ts` restricts them with `IDENTIFIER_REGEX` (`/^[A-Za-z_][A-Za-z0-9_]*$/`) and a `RESERVED_WORDS` set:

- `$` is legal in JavaScript identifiers but deliberately excluded — it is conventionally reserved for code generators, so agents should not use it.
- Full Unicode identifiers buy nothing, since binding names are typed by agents and rendered as `env.NAME`.
- ECMAScript reserved words (`class`, `default`, `import`, `false`, …) pass the regex but cannot follow `.` in all contexts, so they are rejected separately.

## Durable Object storage and DO topology

The backend's Durable Object classes are declared through migrations in `wrangler.jsonc` and reached without an explicit `durable_objects` binding:

:::updates

@update v0 - `new_sqlite_classes: ["UserDurableObject", "OverseerDurableObject"]`

@update v1 - `new_sqlite_classes: ["AdminSettings"]`

@update v2 - `new_sqlite_classes: ["PendingLogin"]` — sign-in via authentication gatekeepers: a short-lived `PendingLogin` DO bridges each gatekeeper login back to the waiting browser.

:::

> All DO classes (`UserDurableObject`, `OverseerDurableObject`, `AdminSettings`, `PendingLogin`, …) are reached via `ctx.exports` and need no explicit `durable_objects` binding.

All classes are SQLite-backed. `overseer.ts` builds its storage layer with `createTypedStorage`, `collection`, and `keyString` from `@gadgets/typed-storage`, and uses `yjs` for collaborative file state — the same package also holds the per-chat compaction checkpoint keys (the `chatCompactions` collection) and `Y`-rooted file trees. `WorkpieceId` in `workshop-shared/src/api.ts` is the identifier that ties these together:

<ResponseField name="WorkpieceId" type="number">
A numbered thing the user or agent is working on inside a workspace — currently a gadget or a gatekeeper (connection), with more types expected later. All workpiece types share **one sequential per-workspace ID namespace**, so a bare number unambiguously identifies a workpiece of any type, and derived names (Yjs file roots, facet names) can never collide across types.
</ResponseField>

Beyond DO storage, the backend holds gadget/blueprint artifacts in `BLUEPRINTS` and `AVATARS` KV namespaces, the `BLUEPRINT_CONTENT` R2 bucket, and a `BROWSER` binding (used for gadget PDF export via `renderGadgetPdf` in `browser-export.ts`).

## Live chat state and agent callbacks

While an agent is running against a gadget, `overseer.ts` keeps per-chat in-memory state rather than persisting transient stubs:

```ts title="packages/workshop-backend/src/overseer.ts"
type LiveChatContext = {
  cancelController: AbortController;
  pendingAgentCallbacks: QueuedAgentCallback[];
  activeAgentCallbacks: Map<number, {
    transientStubs: any[];
    resolve: (v: unknown) => void;
    reject: (e: unknown) => void;
  }>;
};
```

`activeAgentCallbacks` is keyed by message sequence number, and its transient RPC stubs live only until the `deliverAgentCallback` RPC returns. A callback arriving while the agent is running is queued as a `QueuedAgentCallback` (`methodName`, raw `args` with live transient stubs, a depth-limited `argsSummary`, `initiatorUserId` as the hex DO ID of the user DO, `initiatorModelId`, plus `resolve`/`reject`) and delivered once the agent finishes. Attempting a conflicting operation mid-run surfaces `AGENT_RUNNING_ERROR_MESSAGE`: `"Agent is running, wait for it to finish."`

## Outbound network posture: `global_fetch_strictly_public`

Gadget-adjacent outbound HTTP is constrained by a runtime flag rather than by hostname heuristics:

```jsonc title="packages/workshop-backend/wrangler.jsonc"
"compatibility_flags": [
  "allow_irrevocable_stub_storage",
  "enhanced_error_serialization",
  "global_fetch_strictly_public",
  "nodejs_compat"
]
```

`global_fetch_strictly_public` makes the global `fetch()` strictly fetch from the public internet in production, instead of the legacy behavior where same-zone requests go directly to origin, bypassing Cloudflare. Enforcement happens in workerd **after** DNS resolution: reserved ranges (loopback, RFC1918, link-local, cloud-metadata, and similar) are rejected post-lookup.

<Warning>
`wrangler dev` intentionally reconfigures its global outbound to permit fetching from any address so that localhost services stay reachable. The flag therefore only takes effect in production or when running `workerd` stand-alone — an accepted tradeoff for dev. When self-hosting with stand-alone `workerd`, blocking private-network addresses (and hostnames that resolve to them) is already the default.
</Warning>

### Why post-DNS filtering, not a hostname blocklist

`web-fetch.ts` makes the reasoning explicit: it does **not** inspect hostnames for "looks-internal" patterns, because that kind of blocklist is fundamentally unsound — a symbolic hostname can resolve to any IP at fetch time. Post-DNS-lookup filtering in the runtime is described as "the only correct place to enforce such restrictions."

`validateWebFetchUrl(input: string): URL` therefore checks only what is decidable from the URL string:

| Check | Failure message |
| --- | --- |
| Parses as a `URL` | `Invalid URL: <input>` |
| `protocol === "https:"` | `Only https:// URLs are allowed; got <scheme>//. Use the HTTPS version of this URL.` |
| No `username` / `password` | `URLs with embedded credentials are not allowed.` |

### `webFetch` shape and limits

The agent's built-in fetch capability is HTTP GET only against public HTTPS URLs. There is intentionally no support for POST/PUT/DELETE/PATCH and no credential forwarding.

<ParamField body="url" type="string" required>
Target URL. Must be `https:` with no embedded credentials.
</ParamField>

<ParamField body="raw" type="boolean">
If true, return the exact response bytes decoded as UTF-8 with no document conversion. If false or omitted, supported document formats are converted to Markdown via `env.WORKERS_AI.toMarkdown()`.
</ParamField>

<ParamField body="maxBytes" type="number">
Caller-requested cap on body length in characters. The server enforces its own hard cap on top.
</ParamField>

Result fields are `status`, `finalUrl`, `contentType`, `body`, and `truncated`.

| Server-side limit | Value |
| --- | --- |
| `HARD_MAX_BYTES` | `5 * 1024 * 1024` (5 MiB — always truncate beyond this) |
| `DEFAULT_MAX_BYTES` | `1 * 1024 * 1024` (1 MiB when the caller did not specify) |
| `FETCH_TIMEOUT_MS` | `30_000` |
| `USER_AGENT` | `GadgetsWebFetch/1.0` |

`readBodyCapped` fills the byte budget exactly, then cancels the remainder of the stream to free server-side resources and releases the reader lock.

### Conversion allow-list

`TO_MARKDOWN_MIME_TYPES` gates which responses are handed to `toMarkdown()`: `text/html`, `application/xhtml+xml`, `application/pdf`, `application/xml`, `text/xml`, `text/csv`, plus Office/OpenDocument types (`.docx`, `.xlsx`, `.xls`, `.xlsm`, `.xlsb`, `.ods`, `.odt`, `.numbers`). Plain-text, JSON, and other unknown content types pass through unconverted.

Image MIME types are excluded on purpose: image conversion uses paid Workers AI models (object detection plus Gemma-3 for image-to-text), and `webFetch` should not silently incur per-fetch cost. For the same reason HTML conversion passes `images: { convert: false, convertOGImage: false }`, giving the agent a Markdown skeleton with alt text and `src` URLs, and sets `html.hostname` to the page origin so relative links resolve.

Gateway routing is narrow: `buildGatewayOptions` returns options only when an `AiGatewayConfig` exists **and** it has a `workersAiGateway`, since `toMarkdown()` runs on the Workers AI binding and a cross-account platform gateway cannot be used by that binding. The tag sent through is `{ tool: "webFetch", automated: true }`.

<Info>
`WebFetchEnv` is deliberately narrow — `{ ai: Ai; gateway: AiGatewayConfig | null }` — so callers can pass a stub in tests without constructing a full `Cloudflare.Env`.
</Info>

## Code versions and gadget code flow

Code updates and their subscriptions are part of the `Overseer` RPC surface in `workshop-shared/src/api.ts`: `overseer.ts` imports `CodeUpdate` and `CodeSubscriber` alongside `GadgetMetadata`, `UiBundle`, `GadgetClient`, `GadgetBindingInfo`, and `WorkpieceSummary`/`WorkpiecesSubscriber`. Gadget file state itself is Yjs-backed (`import * as Y from "yjs"`), rooted per workpiece, and compaction checkpoints for the driving chat are stored in the `chatCompactions` collection via the typed-storage layer.

Blueprint-side artifacts are separate from live gadget code. `PublicApi` exposes them without authentication, on the grounds that knowing the ID is sufficient since a blueprint is "just data":

| Method | Behavior |
| --- | --- |
| `getBlueprint(id)` | Returns `BlueprintPublicInfo` or `null` if the blueprint does not exist |
| `downloadBlueprint(id)` | Returns a `.gadget` archive stream containing only `BlueprintMetadata` plus the current blueprint code snapshot — not the full KV record |

## Verification signals

<Steps>
<Step title="Confirm the loader binding exists">
`packages/workshop-backend/wrangler.jsonc` must contain a `worker_loaders` entry with `"binding": "LOADER"`. Without it, gadget code cannot be loaded as a Dynamic Worker.
</Step>
<Step title="Confirm the SSRF flag is present">
`global_fetch_strictly_public` must appear in `compatibility_flags`. Remember it is inert under `wrangler dev` — verify SSRF behavior in production or with stand-alone `workerd`, not against localhost.
</Step>
<Step title="Confirm DO migrations cover every class">
Each SQLite DO class must appear in exactly one migration tag (`v0`–`v2`). Classes are reached via `ctx.exports`, so a missing migration fails at runtime rather than at config-parse time.
</Step>
<Step title="Confirm binding names are validated">
Any new binding name path must run through `validateBindingName` from `@gadgets/workshop-shared/api`; names containing `$`, starting with a digit, or matching a reserved word are rejected.
</Step>
</Steps>

## Troubleshooting

<AccordionGroup>
<Accordion title="`Agent is running, wait for it to finish.`">
`AGENT_RUNNING_ERROR_MESSAGE` from `overseer.ts`. A conflicting operation was attempted while a `LiveChatContext` had an active agent turn. Callbacks that arrive in this window are queued as `QueuedAgentCallback` and delivered after the turn; other operations must wait or cancel via the context's `cancelController`.
</Accordion>
<Accordion title="Placeholder stub error mentioning persistent hook callbacks">
A stub produced by the harness's `[restore]` was invoked before being stored and read back. Such stubs are intended only to be passed to `bindHook()` and persisted; the real target appears after the storage round-trip.
</Accordion>
<Accordion title="`Only https:// URLs are allowed`">
`validateWebFetchUrl` rejects any non-`https:` scheme, and separately rejects URLs with embedded credentials. Internal-looking hostnames are *not* rejected here by design — those are blocked post-DNS by the runtime.
</Accordion>
<Accordion title="A localhost URL fetches successfully in dev">
Expected. `wrangler dev` reconfigures its global outbound to allow any address so local services stay reachable, so `global_fetch_strictly_public` has no effect there.
</Accordion>
<Accordion title="An image URL comes back unconverted">
Image MIME types are excluded from `TO_MARKDOWN_MIME_TYPES` on purpose, since image conversion invokes paid Workers AI models. Plain-text, JSON, and other types outside the allow-list also pass through unconverted.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
Backend bindings including `LOADER`, `BROWSER`, `BLUEPRINTS`, and `BLUEPRINT_CONTENT`, plus DO migration tags and path-prefix routing.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`PublicApi`, `AuthenticatedApi`, and `Overseer`, plus `GadgetMetadata`, `UiBundle`, `CodeUpdate`, `validateBindingName`, and stub-disposal constraints.
</Card>
<Card title="Agent runtime and tools" href="/agent-runtime">
The Code Mode agent loop, `executeCode`, `setGadgetBinding`, `webFetch`, and how `prepareChatBindings` folds ambient gatekeepers into `env`.
</Card>
<Card title="Blueprints" href="/blueprints">
What a blueprint captures, its binding types, and `.gadget` export/import semantics.
</Card>
<Card title="Overview" href="/overview">
The router origin, Workers kernel, gadget sandboxes, and gatekeeper connectors, with the package layout.
</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">
Kernel review standards for `workshop-backend` and `workshop-shared`, promise pipelining, and stub disposal.
</Card>
</CardGroup>

---

## 06. Gatekeeper protocol

> The interfaces every connector implements: `GatekeeperVendor`, `GatekeeperConnectCallback`, `GatekeeperUser`, `GatekeeperUserVerifier`, and `Gatekeeper<Session>`. Documents `VendorDescription`, `AccountDescription` (`singleton`, `providesUi`, `providesAuth`), `ResourceDescription` and URL-pattern matching, agent catalog limits, and the `autoProvisionsAccount` disabled/optional/enabled mode resolution.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/06-gatekeeper-protocol.md
- Generated: 2026-08-05T16:39:03.711Z

### Source Files

- `packages/workshop-shared/src/gatekeeper.ts`
- `packages/workshop-backend/src/provisioning-policy.ts`
- `packages/workshop-backend/src/user.ts`
- `packages/gatekeeper-github/src/github.ts`
- `packages/gatekeeper-context/src/context-api.ts`
- `AGENTS.md`

---
title: "Gatekeeper protocol"
description: "The interfaces every connector implements: `GatekeeperVendor`, `GatekeeperConnectCallback`, `GatekeeperUser`, `GatekeeperUserVerifier`, and `Gatekeeper<Session>`. Documents `VendorDescription`, `AccountDescription` (`singleton`, `providesUi`, `providesAuth`), `ResourceDescription` and URL-pattern matching, agent catalog limits, and the `autoProvisionsAccount` disabled/optional/enabled mode resolution."
---

`packages/workshop-shared/src/gatekeeper.ts` defines the RPC interface between the Gadgets Workshop and each connector ("gatekeeper"). Every gatekeeper is deployed as an independent Cloudflare Workers application and reaches the Workshop as a service binding named `GATEKEEPER_*`; the vendor id is the lowercased binding suffix (`GATEKEEPER_GITHUB` → `github`, `GATEKEEPER_CONTEXT` → `context`). Communication is JavaScript RPC, so the types in this file are a live protocol contract, not a local abstraction: `WorkerEntrypoint`, `DurableObject`, `RpcTarget`, and `RpcStub` from `cloudflare:workers` appear directly in the declarations.

The design is capability-based. A gatekeeper hands out object references to individual resources — a single Google Doc, a single GitHub repository, issue, or pull request — so the Workshop can grant one gadget access to exactly the things a user selected, rather than to the account as a whole.

## Interface layering

```text
GatekeeperVendor            (WorkerEntrypoint; the service binding itself)
  ├─ getDescription() ............... VendorDescription
  ├─ connect(callback, options) ..... OAuth / connect flow
  └─ createAccount() ................ optional, no user identity
        │
        ▼
GatekeeperUser              (one connected account)
  ├─ AccountDescription ............. singleton? providesUi? granted patterns
  ├─ getAuthenticatedEmail() ........ only when providesAuth
  ├─ getSingletonGatekeeperClass() .. optional
  └─ startAppUi({ isAdmin }) ........ optional management UI
        │
        ▼
Gatekeeper<Session>         (one resource capability)
  ├─ getSession() ................... the object the agent calls
  ├─ getAgentCatalog(request) ....... bounded discovery metadata
  └─ addObserver(...) ............... observation registration

GatekeeperUserVerifier      (re-checks an account still exists / is valid)
GatekeeperConnectCallback   (Workshop-side callback during connect)
```

<Note>
The `GatekeeperVendor` entrypoint is the root of the protocol; `AccountDescription` is attached to the account, not the vendor. That split is deliberate — a vendor may serve many accounts with different declared capabilities.
</Note>

## `VendorDescription`

Returned by the vendor entrypoint and used for display and for policy decisions on the Connectors page.

<ParamField body="displayName" type="string" required>
Human-readable service name, e.g. `"Google"`, `"GitHub"`.
</ParamField>

<ParamField body="url" type="string" required>
URL of the service's home page.
</ParamField>

<ParamField body="logo" type="AvatarImage">
Service logo. `AvatarImage` is `{ url: string }`.
</ParamField>

<ParamField body="color" type="string">
Background color used behind the logo in connector UI.
</ParamField>

<ParamField body="tagline" type="string">
Short tagline shown beneath the name on Connectors page cards, e.g. `"Draft replies, edit docs, and analyze data"`.
</ParamField>

<ParamField body="description" type="string">
Two-to-three sentence description of what the gatekeeper enables, shown in the Connectors detail modal.
</ParamField>

<ParamField body="providesAuth" type="boolean" default="false">
True if the connect flow yields a provider-verified email via `GatekeeperUser.getAuthenticatedEmail()`. The Workshop may then offer the vendor as a sign-in method, subject to its own auth allowlist.
</ParamField>

<ParamField body="autoProvisionsAccount" type="boolean">
True if the vendor can mint an account with no OAuth flow via `GatekeeperVendor.createAccount()` and recommends the Workshop auto-provision one account per user.
</ParamField>

<Warning>
`providesAuth` is only a declaration of capability. Whether a vendor is actually accepted as a login method is decided by the Workshop's allowlist, never by the gatekeeper.
</Warning>

## `AccountDescription`

Describes one connected account (`GatekeeperUser`) for display, and declares whether the account provides an agent singleton and/or a management UI.

| Field | Type | Meaning |
|---|---|---|
| `displayName` | `string?` | Non-unique human-readable name, e.g. `"John Doe"`. |
| `uniqueName` | `string?` | Canonical login-form name: an email address or Unix-style username. |
| `avatar` | `AvatarImage` | Account avatar. Required. |
| `grantedResourceUrlPatterns` | `string[]?` | `urlPattern`s of the grantable resource types currently enabled on this account. |
| `singleton` | `{ tsType }?` | The account provides an agent singleton session. |
| `providesUi` | `boolean?` | The account provides a management UI. |

`grantedResourceUrlPatterns` distinguishes resource types that are usable now from those needing an additional grant. When omitted, treat the account as having every resource granted — that covers legacy accounts and gatekeepers with no grantable resource types.

`singleton` and `providesUi` are orthogonal: an account can declare either, both, or neither. In `packages/workshop-backend/src/user.ts` the Workshop never probes for the optional methods; the declaration flags are the gate. Because `getSingletonGatekeeperClass` and `startAppUi` are optional on `GatekeeperUser`, and TypeScript cannot call an optional method on a mapped stub type, `user.ts` views the stub through derived plain shapes:

```ts
// packages/workshop-backend/src/user.ts
type AccountCreatorStub = Required<Pick<GatekeeperVendor, "createAccount">>;
type SingletonAccountStub =
    Required<Pick<GatekeeperUser, "getSingletonGatekeeperClass" | "startAppUi">>;
```

These are derived with `Pick` + `Required` from the source interfaces rather than re-declared, so they cannot drift, and they are intentionally not wrapped in `Service`/`Fetcher` so declared return types (such as `createAccount`'s `Fetcher<GatekeeperUser>`) stay usable as the runtime stub actually behaves.

<Warning>
Never hand-write an interface that mirrors an RPC interface plus an `as unknown as` cast. Derive from the real type, as above, or rethink the design. This is an enforced kernel review standard for `workshop-shared` API changes.
</Warning>

### Management UI

A management UI is hosted at `/gatekeepers/$appId`, where `$appId` is the vendor id — e.g. `/gatekeepers/context` — and is opened via `startAppUi({ isAdmin })`. The `AppUiContext` is supplied fresh on every open rather than baked into the account, because a user's admin status can change over time:

```ts
// packages/workshop-shared/src/gatekeeper.ts
export type AppUiContext = {
  isAdmin: boolean;
}
```

## Resource descriptions and URL-pattern matching

A gatekeeper's resources are described by `ResourceDescription` / `SupportedResource`, and each grantable resource type carries a `urlPattern`. The pattern is the identity used across the protocol: `AccountDescription.grantedResourceUrlPatterns` lists the granted types by `urlPattern`, and the Workshop's admin config disables resources by the same key (`disabledResources`, consumed through `filterEnabledResources` / `isResourceDisabled` in `admin-config.ts`).

The GitHub connector shows the shape a multi-resource vendor takes. It declares three resource kinds and a configurator UI per kind:

```ts
// packages/gatekeeper-github/src/github.ts
type ResourceKind = "repo" | "issue" | "pull";
type EntityKind = "issue" | "pull";

type GitHubGatekeeperImplProps = {
  userObjectId: string;
  resourceKind: ResourceKind;
  owner: string;
  repo: string;
  issueNumber?: number;
};
```

Each kind ships a `ResourceConfiguratorFrame`-backed UI (`GitHubRepoConfiguratorUI`, `GitHubIssueConfiguratorUI`, `GitHubPullRequestConfiguratorUI`), built into `src/generated/*-configurator-ui.txt` by `scripts/build-gatekeeper-configurator.mjs` during the package build. The type-only helpers those modules compile against live in `packages/configurator-ui`.

## Sessions and pagination

`Gatekeeper<Session>` is the per-resource capability: it exposes the session object the agent actually calls. Multi-item reads use `Cursor<T>`, an RPC object rather than an array:

```ts
// packages/workshop-shared/src/gatekeeper.ts
export interface Cursor<T> {
  next(): Promise<T[] | null>;
}
```

Call `next()` repeatedly on the same cursor for subsequent batches; it returns `null` once exhausted. Dispose the cursor when finished.

## Agent catalog

`Gatekeeper.getAgentCatalog()` returns bounded discovery metadata so an agent can see *what* is reachable through a session — for example the titles of the Context Library collections it can search — without reading everything first. The catalog is injected into the agent's context as untrusted data: entries carry no authority and are size-capped.

```ts
export type AgentCatalogEntry = { id: string; title: string; description: string };
export type AgentCatalog = { entries: AgentCatalogEntry[]; truncated?: boolean };
export type AgentCatalogRequest = { limit: number };
```

| Constant | Value | Applies to |
|---|---|---|
| `AGENT_CATALOG_MAX_ENTRIES` | `25` | `entries.length` |
| `AGENT_CATALOG_MAX_ID_LENGTH` | `256` | `entry.id` |
| `AGENT_CATALOG_MAX_TITLE_LENGTH` | `100` | `entry.title` |
| `AGENT_CATALOG_MAX_DESCRIPTION_LENGTH` | `400` | `entry.description` |

The Workshop enforces these caps regardless of what the gatekeeper returns. Gatekeepers should not hand-roll the limits — call `boundAgentCatalog()`, which clamps the count to `min(request.limit, AGENT_CATALOG_MAX_ENTRIES)`, truncates each field to its cap, and sets `truncated` when entries were dropped:

```ts
export function boundAgentCatalog(
    entries: AgentCatalogEntry[], request: AgentCatalogRequest): AgentCatalog {
  let requestedLimit = Number.isFinite(request.limit) ? Math.max(0, Math.floor(request.limit)) : 0;
  let limit = Math.min(requestedLimit, AGENT_CATALOG_MAX_ENTRIES);
  return {
    entries: entries.slice(0, limit).map(entry => ({
      id: entry.id.slice(0, AGENT_CATALOG_MAX_ID_LENGTH),
      title: entry.title.slice(0, AGENT_CATALOG_MAX_TITLE_LENGTH),
      description: entry.description.slice(0, AGENT_CATALOG_MAX_DESCRIPTION_LENGTH),
    })),
    truncated: entries.length > limit,
  };
}
```

Note the defensive handling of `request.limit`: a non-finite limit becomes `0`, and negative or fractional limits are floored at zero.

`getAgentCatalog` is one of the two session entry points the agent reaches through `executeCode` (`getSession` being the other); each read is recorded as an observation.

## Auto-provisioned (ambient) gatekeepers

A vendor that sets `VendorDescription.autoProvisionsAccount` can mint a connected account with no OAuth flow through `GatekeeperVendor.createAccount()`, which takes no user identity. The Context Library (`GATEKEEPER_CONTEXT`) is the reference example: `createAccount()` returns a `ContextAccount` that keys its private data by its own generated `accountId`, and the account exposes `getSession()`, `getAgentCatalog()`, and `startAppUi({ isAdmin })`.

The Workshop persists such an account in the user Durable Object like any other connected account. The account capability — not an asserted identity — is the authority from that point on.

### Mode resolution

Per-vendor availability is a three-state admin decision stored in `AdminConfig.ambientGatekeeperModes` and resolved in `packages/workshop-backend/src/provisioning-policy.ts`.

| Mode | Behavior |
|---|---|
| `disabled` | Not available; no account is provisioned, and any existing one stays dormant. |
| `optional` | **Default.** Users opt in from the Connectors page; not forced on anyone. |
| `enabled` | Auto-provisioned for every user (forced); users cannot remove it, and it is hidden from the Connectors list. |

```ts
// packages/workshop-backend/src/provisioning-policy.ts
export const DEFAULT_AMBIENT_GATEKEEPER_MODE: AmbientGatekeeperMode = "optional";

export function ambientGatekeeperMode(config: AdminConfig, vendorId: string): AmbientGatekeeperMode {
  return config.ambientGatekeeperModes?.[vendorId.toLowerCase()] ?? DEFAULT_AMBIENT_GATEKEEPER_MODE;
}

export function shouldAutoProvisionAccount(config: AdminConfig, vendorId: string): boolean {
  return ambientGatekeeperMode(config, vendorId) === "enabled";
}
```

Two details matter for implementers:

- Lookup is by **lowercased** vendor id, matching the lowercased `GATEKEEPER_*` binding suffix.
- `ambientGatekeeperModes` may be `undefined` on a config persisted before the field existed, so the optional chain plus `??` default is load-bearing, not cosmetic.

These two helpers are the single chokepoint for the decision. `UserDurableObject` reads `AdminConfig` and calls them when provisioning, listing, and surfacing ambient accounts; `user.ts` imports both alongside the gatekeeper types.

<Warning>
The default is `optional` on purpose: ambient authority is not imposed on every user unless an admin explicitly turns it on. A gatekeeper must never assert its own ambience — a resource becomes ambient only through user or admin configuration.
</Warning>

### Ambient account records

An auto-provisioned account is stored with an `autoProvisioned` flag, which protects it from manual disconnect, because deleting one permanently destroys the user's data in that gatekeeper:

```ts
// packages/workshop-backend/src/user.ts
type ConnectedAccountRecord = {
  id: number;
  account: Fetcher<GatekeeperUser>;
  description: AccountDescription;
  vendorId: string;   // Derived from the GATEKEEPER_ binding name (e.g. "google", "email").
  credentialExpiresAt?: Date;    // When credentials are expected to expire, if known.
  credentialsExpired?: boolean;  // Set true by async notification from gatekeeper.
  autoProvisioned?: boolean;
};

export type ProvidedAccountInfo = {
  accountId: number;
  vendorId: string;
  description: AccountDescription;   // carries `singleton` / `providesUi` declarations
};
```

`ProvidedAccountInfo` is what the overseer receives for ambient capsules and catalog assembly, and what the management-UI listing reads.

Credential validity is computed from both signals — an explicit expiry notification from the gatekeeper and a known expiry timestamp:

```ts
function areCredentialsValid(record: ConnectedAccountRecord): boolean {
  if (record.credentialsExpired) return false;
  if (record.credentialExpiresAt && record.credentialExpiresAt.valueOf() < Date.now()) return false;
  return true;
}
```

### Singleton delivery to the agent

When an account declares `singleton: { tsType }`, the Workshop auto-provides it to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's `env` as a named chat binding. The name comes from the gatekeeper's `suggestedBindingName`; the folding happens in `prepareChatBindings` in `overseer.ts`. The agent reads it inside `executeCode` via `getSession` / `getAgentCatalog`, and each read is recorded as an observation.

A singleton is **not** bound to any gadget by default — most gadgets never call it programmatically. The agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it.

## Ownership boundaries

```mermaid
flowchart TB
  subgraph shared["packages/workshop-shared/src/gatekeeper.ts"]
    IFACE["GatekeeperVendor / GatekeeperUser<br/>Gatekeeper&lt;Session&gt; / GatekeeperUserVerifier<br/>GatekeeperConnectCallback<br/>VendorDescription / AccountDescription<br/>ResourceDescription / SupportedResource<br/>Cursor&lt;T&gt; / AgentCatalog + caps"]
  end

  subgraph kernel["packages/workshop-backend (kernel)"]
    USER["user.ts<br/>ConnectedAccountRecord<br/>ProvidedAccountInfo"]
    POLICY["provisioning-policy.ts<br/>ambientGatekeeperMode()<br/>shouldAutoProvisionAccount()"]
    ADMIN["admin-config.ts<br/>ambientGatekeeperModes<br/>disabledResources"]
    OVERSEER["overseer.ts<br/>prepareChatBindings()"]
  end

  subgraph connectors["packages/gatekeeper-* (independent Workers)"]
    GH["gatekeeper-github<br/>repo / issue / pull<br/>+ configurator UIs"]
    CTX["gatekeeper-context<br/>autoProvisionsAccount<br/>singleton + providesUi"]
    MCPGK["gatekeeper-mcp / -mcp-portal<br/>via packages/mcp-shared"]
  end

  subgraph helpers["packages/configurator-ui (type-only)"]
    CFGUI["ResourceConfiguratorFrame helpers"]
  end

  GH -->|implements| IFACE
  CTX -->|implements| IFACE
  MCPGK -->|implements| IFACE
  CFGUI -.->|compiled by build-gatekeeper-configurator.mjs| GH
  USER -->|imports types| IFACE
  USER --> POLICY
  POLICY --> ADMIN
  USER -->|ProvidedAccountInfo| OVERSEER
  USER -->|GATEKEEPER_* service binding| connectors
```

The direction of dependency is one-way: connectors depend on `workshop-shared` types and are reached only through service bindings. They never import kernel internals, and they cannot influence provisioning policy.

## Implementation notes for connector authors

<AccordionGroup>
<Accordion title="Import types from the gatekeeper subpath">
Connectors import the protocol from `@gadgets/workshop-shared/gatekeeper` as `type`-only imports where possible. The GitHub connector's import list is the canonical example: `ApprovalQueue` is a value import while `Gatekeeper`, `GatekeeperUser`, `GatekeeperUserVerifier`, `GatekeeperVendor`, `AccountDescription`, `VendorDescription`, `GatekeeperConnectCallback`, `GatekeeperConnectOptions`, `ResourceDescription`, `ResourceConfiguratorFrame`, `SupportedResource`, `ActionDescription`, and `Cursor` come in as types. It aliases the entrypoint interface (`GatekeeperVendor as GatekeeperVendorIface`) so the local class can keep the plain name.
</Accordion>

<Accordion title="Own your Durable Objects and namespace by sharing domain">
A gatekeeper owns its own state. `gatekeeper-context` uses three Durable Objects — `ContextCollectionDurableObject` for content, `UserLibraryDurableObject` for each account's private collections, and `LibraryRegistryDurableObject` for the domain's public set — plus a KV namespace. All data is namespaced by a `sharingDomain` taken from the binding's props (see `domain.ts`), so multiple Workshops sharing one gatekeeper instance stay isolated. In `context-api.ts` the namespacing is explicit at every stub lookup, e.g. `this.collections.idFromName(domainName(this.domain, id))`.
</Accordion>

<Accordion title="Authorize reads and writes separately">
`ContextApiImpl` splits authorization into `#assertCanRead` (own private collections or any public collection) and `#assertCanWrite` (own private collections, or public collections for admins). Both resolve ownership and public status concurrently and throw the same opaque message — `"Collection not found or you don't have access."` — so a failed check does not disclose existence. Admin-only operations go through a separate `#assertAdmin()`, and optional-binding features guard with `#assertArtifactsAvailable()`.
</Accordion>

<Accordion title="Use a structured logger with component and vendorId">
Each connector creates a logger tagged with its component and vendor id, e.g. in `packages/gatekeeper-github/src/github.ts`:

```ts
const VENDOR_ID = "github";
const logger = obsContext.createLogger({
  component: "gatekeeper.github", vendorId: VENDOR_ID,
});
```
</Accordion>

<Accordion title="Keep OAuth requests inside the SSRF-checked fetch">
For the MCP connectors, OAuth uses the official `@modelcontextprotocol/client`, and every SDK OAuth operation must be given `sdkFetch(...)` so all requests and redirects retain endpoint and SSRF checks.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Build a gatekeeper" href="/build-a-gatekeeper">
Add a connector package: implement `GatekeeperVendor`, declare descriptions, own your Durable Objects, and expose a session.
</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
Register a third-party OAuth app and wire `CLIENT_ID` / `CLIENT_SECRET` into a connector.
</Card>
<Card title="Observations, actions, and approval queues" href="/observations-and-actions">
The read/write split behind `ApprovalQueue`, `ActionDescription`, and the MCP `readOnlyHint` trust boundary.
</Card>
<Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
`Gatekeeper.addObserver()`, verifier minting, and how a failing re-check blocks new observations.
</Card>
<Card title="Agent runtime and tools" href="/agent-runtime">
How `prepareChatBindings` folds ambient gatekeepers into `env` under `suggestedBindingName`.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
`ambientGatekeeperModes`, `disabledResources`, `disabledGatekeepers`, and the `AdminSettings` DO.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How `/gatekeeper/<name>/*` routes are derived by lowercasing `GATEKEEPER_*` env keys.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
The Cap'n Web interfaces shared between client and backend, plus stub-disposal constraints.
</Card>
</CardGroup>

---

## 07. Observations, actions, and approval queues

> The read/write split that makes asynchronous human-in-the-loop work: `ObservationDescription` (including `prohibitAllSharing`), `ActionDescription` and `ActionKind`, the `ObservationAuthorizer` and `ApprovalQueue` interfaces, simulated results while an action is pending, and `ActionState` transitions. Includes the MCP trust boundary where `readOnlyHint` decides observation versus queued action.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/07-observations-actions-and-approval-queues.md
- Generated: 2026-08-05T16:38:42.942Z

### Source Files

- `packages/workshop-shared/src/gatekeeper.ts`
- `packages/mcp-shared/src/tools.ts`
- `packages/mcp-shared/src/action-store.ts`
- `packages/workshop-backend/src/auto-approval.ts`
- `packages/workshop-shared/src/api.ts`
- `packages/mcp-shared/README.md`

---
title: "Observations, actions, and approval queues"
description: "The read/write split that makes asynchronous human-in-the-loop work: `ObservationDescription` (including `prohibitAllSharing`), `ActionDescription` and `ActionKind`, the `ObservationAuthorizer` and `ApprovalQueue` interfaces, simulated results while an action is pending, and `ActionState` transitions. Includes the MCP trust boundary where `readOnlyHint` decides observation versus queued action."
---

Every gatekeeper call a gadget makes is classified as either a read (an observation, executed immediately and recorded) or a write (an action, staged for approval before dispatch). In `packages/mcp-shared`, `classifyTool()` is the single place where this decision is made, and `packages/workshop-backend/src/auto-approval.ts` is where a staged action can be drained without a prompt. The lifecycle of a staged call lives in `ActionStore` (`packages/mcp-shared/src/action-store.ts`), which persists to a facet-local SQLite table and prefers losing a result over repeating a write.

## The read/action split

`ClassifiedTool` in `packages/mcp-shared/src/tools.ts` carries the decisions the gatekeeper made about one tool:

```ts
export type ClassifiedTool = {
  tool: McpTool;
  // `read` runs immediately and is recorded as an observation; `action` goes to the queue.
  mode: "read" | "action";
  // Whether the deployment may let this action through without a prompt.
  autoApprovable: boolean;
  // Whose word `mode` rests on.
  classifiedBy: ClassificationSource;
};
```

`ClassificationSource` is `"server-annotation" | "default"`. It is recorded rather than re-derived so no consumer can answer "who decided this was a read?" differently from the classifier that decided it. `toolInfo()` carries `classifiedBy` through to the gadget-facing `McpToolInfo`, so an audit can find every call that was trusted on the server's word.

<Note>
Nothing outside `packages/mcp-shared/src/tools.ts` reads a tool's `annotations`. This is stated both in the module header and in the package README's module table.
</Note>

## The MCP trust boundary

`classifyTool(tool, trust)` reduces an MCP server's self-description to a policy decision:

```ts
export function classifyTool(tool: McpTool, trust: ServerTrust): ClassifiedTool {
  const annotations = tool.annotations ?? {};
  const readOnly = isDeclaredReadOnly(tool);

  const autoApprovable = !readOnly
    && trust === "vetted"
    && annotations.destructiveHint === false
    && annotations.idempotentHint === true;

  return {
    tool,
    mode: readOnly ? "read" : "action",
    autoApprovable,
    classifiedBy: readOnly ? "server-annotation" : "default",
  };
}
```

`isDeclaredReadOnly()` tests `tool.annotations?.readOnlyHint === true`. Every hint is compared with `=== true` or `=== false` rather than for truthiness, so an unannotated tool is an action, needs approval, and can never auto-apply — matching the MCP spec's own defaults (`readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`).

### Trust tiers

`ServerTrust` is `"vetted" | "byo"`. It governs trust in annotations only, is deployment configuration rather than account state, and is read afresh at each point of use, so withdrawing it takes effect without a reconnect.

| Tier | Endpoint provenance | `readOnlyHint` classifies reads | Annotations may drive auto-approval |
| --- | --- | --- | --- |
| `byo` | a user typed the URL in | yes | no |
| `vetted` | a deployment asserted the annotations are reliable | yes | yes (`destructiveHint: false` and `idempotentHint: true`) |

Honouring `readOnlyHint` on `byo` is documented as a knowing tradeoff, not a free win: a tool the server mislabels runs with no approval, where an unlabelled one would have been queued. It is accepted because prompting on every read makes the connector unusable for its main purpose, and because the owner chose to connect the server. Auto-applying a write is not accepted on those terms and additionally requires a vetted endpoint, so the deployment casts the deciding vote.

<Warning>
Configuring an endpoint does not by itself earn `vetted`. A portal aggregates upstream servers whose annotations the administrator never saw, which is why `gatekeeper-mcp-portal` defaults to `byo` and requires `MCP_PORTAL_TRUST_ANNOTATIONS=true`.
</Warning>

An account records `provenance` (`"user"` or `"deployment"`) instead of a tier, settled when it connects. Provenance decides whether a server may rename itself over an administrator's chosen label in an approval prompt — a question that should not move when an annotation setting does.

```mermaid
flowchart TD
  subgraph gk["MCP gatekeeper (byo or vetted)"]
    tool["McpTool + annotations"] --> classify["classifyTool()<br/>tools.ts — sole reader of annotations"]
  end
  classify -->|"mode: read"| obs["Runs immediately<br/>recorded as an observation"]
  classify -->|"mode: action"| store["ActionStore.stage()<br/>action-store.ts"]
  subgraph queue["Approval queue"]
    store --> pending["state: pending"]
    pending -->|"discard()"| gone["row deleted"]
    pending -->|"apply()"| applying["state: applying (claimed)"]
  end
  classify -->|"autoApprovable: true"| drain["AutoApprovalDrainer.drain()<br/>workshop-backend/auto-approval.ts"]
  drain -->|"requires enabled AutoApproveTagRecord"| pending
  applying --> settled["applied / failed"]
```

## Scoping an approval to a tool

`actionKindFor()` builds the approval-policy identity of one tool on one binding:

```ts
export function actionKindFor(scopeTag: string, toolName: string): ActionKind {
  return { tag: `${encodeURIComponent(scopeTag)}:${encodeURIComponent(toolName)}`, label: toolName };
}
```

`ActionKind` is imported from `@gadgets/workshop-shared/gatekeeper`. `scopeTag` is caller-supplied so that two connectors using the same binding id cannot share pre-approvals. Both components are percent-encoded before being joined, so a tool name containing `:` cannot forge another tool's tag.

## Detecting a catalog that changed under you

`catalogRevision(tools)` produces a stable 16-hex-character fingerprint (`SHA-256`, truncated) over each tool's name plus every claim a grant was decided against:

```ts
function policyClaims(tool: McpTool): string {
  return [
    isDeclaredReadOnly(tool) ? "r" : "w",
    claimChar(tool.annotations?.destructiveHint),
    claimChar(tool.annotations?.idempotentHint),
  ].join("");
}
```

`claimChar()` is tri-state (`"1"` / `"0"` / `"-"`), so a server starting or stopping making a claim is visible even where both lead to the same decision today. Descriptions are excluded so copy edits do not fire the signal.

## Action state transitions

The `mcp_actions` table constrains `state` to `'pending' | 'applying' | 'applied' | 'rejected' | 'failed'`, with a `STRICT` table and `json_valid` checks on `args_json` and `result_json`.

```mermaid
stateDiagram-v2
  [*] --> pending: stage()
  pending --> [*]: discard() (row deleted)
  pending --> applying: apply() claims + persists claimedAt
  applying --> applied: call returned; state settled before result attached
  applying --> failed: call threw
  applying --> failed: activation died / APPLY_CLAIM_TIMEOUT_MS<br/>retryable = 0
  failed --> applying: apply() again, only if retryable !== false
  rejected --> [*]
  note right of failed
    retryable = !callMayHaveTakenEffect(err)
  end note
```

`apply(id, call, log)` rejects re-entry explicitly before claiming:

| Stored state | `apply()` behavior |
| --- | --- |
| `applied` | returns immediately (idempotent no-op) |
| `rejected` | throws `MCP action <id> was already rejected.` |
| `failed` with `retryable === false` | throws the stored `error`, or `MCP action <id> cannot be retried.` |
| `applying` | throws `MCP action <id> is already being applied.` |
| unknown id | throws `MCP action <id> is unknown.` |

## At-most-once application

The guarantee is *at most once*, not exactly once. MCP has no idempotency key that would make a repeated call harmless and no inverse operation that would undo one, so where the two conflict the store prefers losing a result over repeating a write.

<Steps>
<Step title="Claim before I/O">
`apply()` sets `state = "applying"`, stamps `claimedAt = Date.now()`, clears `error` and `result`, and persists — before the call is sent. This is what stops two concurrent `applyAction` calls from both reaching the server.
</Step>
<Step title="Settle before attaching the result">
Once the call returns, `state` is set to `"applied"` in its own small write *before* the result is attached, so nothing about handling a server-controlled payload — normalizing it, encoding it, or finding it too large for the Durable Object to store — can lose the fact that the write already happened.
</Step>
<Step title="Never release a stale claim">
The `ActionStore` constructor runs on every fresh Durable Object activation and closes any persisted `applying` row: `UPDATE mcp_actions SET state = 'failed', retryable = 0, error = ?` with `APPLY_OUTCOME_UNKNOWN_MESSAGE`. The claim is not released for another attempt; after `APPLY_CLAIM_TIMEOUT_MS` an action is closed the same way.
</Step>
</Steps>

### Failure classification

Failures are split by what the server is known to have done, because the caller cannot work that out afterwards. `callMayHaveTakenEffect(err)` fails safe: anything it cannot positively identify as declined counts as possibly performed.

| Outcome | `retryable` | Recorded `error` |
| --- | --- | --- |
| Refused before dispatch (`401`, `403`) | `true` | the underlying error message |
| Generic HTTP / JSON-RPC error, dropped connection, malformed reply, oversized body | `false` | "This call failed after it had been sent, so it may or may not have taken effect. Check the server before staging it again." |
| Activation died between send and reply, or claim expired | `false` | `APPLY_OUTCOME_UNKNOWN_MESSAGE` |

The log event is `action.apply.outcome-unknown` when the call may have landed and `action.apply.failed` when it was declined, both with `actionId`, `toolName`, and `error`.

## Staging limits

Fixed rather than configurable.

| Constant | Value | Enforced in |
| --- | --- | --- |
| `MAX_ARGUMENT_BYTES` | 64 KiB | `stage()` — throws `MCP tool arguments are too large (maximum 65536 bytes).` |
| `MAX_PENDING_ACTIONS` | 50 | `stage()` — counts rows in `('pending', 'applying')` |
| `MAX_RESULT_BYTES` | 128 KiB | `apply()` — oversized results are replaced with an `status: "ok"` placeholder |
| `MAX_RETAINED_ACTIONS` | 100 | `#prune()` |
| `MAX_TOOLS_PER_SERVER` | 200 | `tools.ts` |
| Catalog size | 96 KiB UTF-8 | `client.ts` — leaves room below the Durable Object 128 KiB per-value limit |
| `MAX_DESCRIPTION` | 600 chars | approval-prompt rendering |
| `MAX_ARGUMENTS` | 4000 chars | approval-prompt rendering |

`stage()` also round-trips arguments through `JSON.stringify` / `JSON.parse` and rejects `null` or arrays, throwing `MCP tool arguments must be JSON-compatible.` The pending-queue message is: `50 calls to this MCP server are already awaiting approval. Wait for them to be approved or rejected before queueing more.`

<Warning>
Approval prompts reproduce server-supplied text. `defuseFences()` rewrites runs of three or more backticks to `'''` before the text is placed inside a fence — without it a tool description can close the fence and continue in the prompt's own voice, writing its own "Endpoint:" line and arguing the server's case.
</Warning>

## Auto-approval drain

`AutoApprovalDrainer` in `packages/workshop-backend/src/auto-approval.ts` applies eligible pending actions in ascending id order, over a storage shape of two typed collections:

```ts
export interface AutoApprovalStorage {
  actions: Collection<ActionRecord, number>;
  autoApproveTags: Collection<AutoApproveTagRecord>;
}
```

Eligibility requires **both** signals:

<ParamField body="record.description.autoApprovable" type="boolean" required>
Must be `=== true`. This is the author's verdict on the action, ultimately sourced from `classifyTool()` on a `vetted` endpoint.
</ParamField>

<ParamField body="autoApproveTags[`${gatekeeperId}:${tag}`]" type="AutoApproveTagRecord" required>
A user-enabled rule for `record.description.actionKind?.tag` on this gatekeeper. If the action has no `actionKind`, no rule can match.
</ParamField>

Ordering rules the drainer preserves:

- The first pending action that is **not** auto-eligible is a manual gate: the drain `break`s rather than skipping ahead, so nothing is silently applied past a human gate.
- An action that throws while applying is left `pending` for manual handling and also stops the drain. The failure is logged as `auto.approval.failed` with `actionId` under the `workshop.auto.approval` logger.
- `applyPendingAction(fresh, rule.enabledBy, true)` attributes the auto-approval to the `AiChatAuthorInfo` of the user who enabled the rule — it runs under their authority.

Concurrency is handled by a per-gatekeeper single-flight map, because the Durable Object input gate is open across the `apply` await:

```ts
async drain(gatekeeperId: number): Promise<void> {
  if (this.#draining.has(gatekeeperId)) {
    this.#draining.set(gatekeeperId, true);  // ask the running drain to loop again
    return;
  }
  this.#draining.set(gatekeeperId, false);
  try {
    do {
      this.#draining.set(gatekeeperId, false);
      await this.#drainOnce(gatekeeperId);
    } while (this.#draining.get(gatekeeperId));
  } finally {
    this.#draining.delete(gatekeeperId);
  }
}
```

`#drainOnce()` materializes a snapshot with `[...this.storage.actions.list()]` first, because `list()` is a lazy generator over storage and the actions collection is mutated as the drain proceeds. Immediately before applying, it re-reads `this.storage.actions.get(record.id)` and skips the record unless it is still a pending `"action"` — a guard against a concurrent drain having already taken it. `applyPendingAction` is injected, which keeps the drainer constructible over mock storage in tests.

## Result shape a gadget sees

`toCallResult()` flattens MCP content into the gadget-facing `McpCallResult`:

```ts
{
  status: "ok",
  content,                        // the raw McpContentBlock[]
  text,                           // text blocks joined with "\n"
  structuredContent: result.structuredContent,
  isError: result.isError,
}
```

When the encoded result exceeds `MAX_RESULT_BYTES`, `apply()` stores a `status: "ok"` record with `content: []` and a `text` note that the server's response was too large to retain — the action still settles as `applied`.

## Sharing

Neither trust tier can be shared. A gadget bound to any MCP endpoint is owner-only, enforced by `packages/mcp-shared/src/sharing-policy.ts` (listed in the module table as "The owner-only sharing rule"), for reasons unrelated to annotation provenance.

## Related pages

<CardGroup cols={2}>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
The interfaces every connector implements, including `VendorDescription`, `AccountDescription`, and resource URL-pattern matching.
</Card>
<Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
Collaborator roles, the `use` allowlist, and how a failing observer re-check blocks new observations.
</Card>
<Card title="Build a gatekeeper" href="/build-a-gatekeeper">
Adding a connector package: vendor entrypoint, Durable Object classes and migrations, and structured logging.
</Card>
<Card title="Environment variables" href="/environment-variables">
Deployment configuration including `MCP_PORTAL_URL` and `MCP_PORTAL_TRUST_ANNOTATIONS`.
</Card>
</CardGroup>

---

## 08. Blueprints

> What a blueprint captures and omits, the three binding types (`gatekeeper`, `aiModel`, `agentSpawner`), blueprint annotations stored on `GatekeeperRecord`, 128-bit hex IDs versus stable bundled IDs, and the one-way Gadget DO to User DO to Workers KV propagation with its `dirty` flag. Includes `.gadget` export/import and share-link semantics.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/08-blueprints.md
- Generated: 2026-08-05T16:38:30.410Z

### Source Files

- `docs/blueprints.md`
- `packages/workshop-backend/src/blueprint-archive.ts`
- `packages/workshop-shared/src/api.ts`
- `packages/workshop-backend/src/format-blueprints.ts`
- `packages/workshop-backend/src/overseer.ts`

---
title: "Blueprints"
description: "What a blueprint captures and omits, the three binding types (`gatekeeper`, `aiModel`, `agentSpawner`), blueprint annotations stored on `GatekeeperRecord`, 128-bit hex IDs versus stable bundled IDs, and the one-way Gadget DO to User DO to Workers KV propagation with its `dirty` flag. Includes `.gadget` export/import and share-link semantics."
---

A blueprint is a shareable snapshot of a gadget's source code plus a description of the bindings that code requires. It carries no credentials, no SQLite storage, and no chat history, so every gadget created from a blueprint gets its own bindings, storage, and chat history. Blueprint metadata lives in the `BLUEPRINTS` KV namespace, code snapshots live in the `BLUEPRINT_CONTENT` R2 bucket under `<blueprintId>/<version>`, and the authoritative record lives in the source gadget's Durable Object.

## What a blueprint captures

| Captured | Detail |
| --- | --- |
| Source code | Snapshot of the gadget's committed Yjs document, stripped of edit history. Only final file contents (one insert operation per file), producing a minimal encoding. |
| Binding requirements | One description per named binding: connection type, gatekeeper name, URL pattern, and configuration hints. No credentials, no live connections. |
| Metadata | Title, description, optional screenshot metadata, author info, version number, timestamps. |

Not captured:

- Gadget SQLite storage contents.
- AI chat history and edit history.
- Live connections or credentials — only the *shape* of each binding.

<Note>
A single gadget can have multiple blueprints at different code versions (for example a "stable" and a "latest" blueprint of the same gadget). A blueprint is always owned by the gadget's owner regardless of which collaborator created it; bundled blueprints have no owning user.
</Note>

## Blueprint IDs and share links

Blueprint IDs come in two flavors:

- **Random 128-bit hex** — generated server-side by `randomBlueprintId()` in `packages/workshop-backend/src/blueprint-archive.ts`, which fills 16 bytes from `crypto.getRandomValues()` and calls `.toHex()`.
- **Stable bundled IDs** — deployment-installed format blueprints carry readable, stable IDs such as `format.document`. These are ordinary blueprint records: no reserved prefix, no fallback branch in the read path.

```ts
// packages/workshop-backend/src/blueprint-archive.ts
export function randomBlueprintId(): string {
  let idBytes = new Uint8Array(16);
  crypto.getRandomValues(idBytes);
  return idBytes.toHex();
}
```

Share links have the form `https://<host>/blueprint/<blueprint-id>`. Knowing the ID is sufficient to read metadata:

| Operation | Auth required |
| --- | --- |
| View title, description, author, required bindings | No — knowing the ID is enough, a blueprint is "just data" |
| Create a gadget from the blueprint | Yes |

<Warning>
Two keys in the `BLUEPRINTS` namespace are reserved and are never treated as blueprint IDs: `.featured` (`FEATURED_BLUEPRINTS_KEY`) and `.adminConfig` (`ADMIN_CONFIG_KEY`). `isReservedBlueprintKey()` guards `readBlueprintKvRecord()`, which returns `null` for either key.
</Warning>

## Binding types

Blueprints support three binding types, matching the three gatekeeper types.

<ParamField body="gatekeeper" type='type: "gatekeeper"'>
An external resource connection (for example Google Drive or a REST API). The blueprint records the gatekeeper adapter name and a URL pattern describing the expected resource. On instantiation, the user picks a connected account and configures a matching resource.
</ParamField>

<ParamField body="aiModel" type='type: "aiModel"'>
A language model binding. The blueprint may suggest a specific provider/model. On instantiation, the user picks from their own configured models.
</ParamField>

<ParamField body="agentSpawner" type='type: "agentSpawner"'>
An agent spawner binding. The blueprint carries over the spawner configuration (prompt types, env restrictions) from the source gadget. The user only chooses which model the spawner should use, or no model.
</ParamField>

## Binding annotations

Before publishing, the author can annotate the gadget's named bindings from the **Blueprint** modal in the gadget editor header. Annotations control how each required connection is presented to someone creating a gadget from the blueprint. All named bindings are included in the blueprint regardless of annotation.

| Annotation | Behavior |
| --- | --- |
| Name | Friendly connection name shown to consumers. Defaults to the current resource title; the binding name remains the stable key used by code. |
| Description | Optional helper text explaining what kind of resource to connect. |
| Suggest value | Optionally embeds the specific resource URL or model name as a suggestion — a suggestion, not a requirement. |

The annotation is persisted on the `GatekeeperRecord` as the `blueprintAnnotation` field. `packages/workshop-backend/src/overseer.ts` defines a `LegacyBlueprintBindingAnnotation` variant (`BlueprintBindingAnnotation & { included?: boolean }`) for older records, and derives the default title with:

```ts
// packages/workshop-backend/src/overseer.ts
function defaultBlueprintBindingTitle(record: GatekeeperRecord, bindingName?: string): string {
  return record.resourceTitle || bindingName || "Connection";
}
```

## Storage architecture

Blueprint data is written to three stores with strictly one-way propagation, plus R2 for the code snapshot.

```mermaid
flowchart LR
  subgraph gadget["Gadget DO — authoritative"]
    A["blueprints collection<br/>BlueprintGadgetRecord<br/>metadata + exported code version + dirty"]
  end
  subgraph user["User DO — denormalized"]
    B["blueprints collection<br/>BlueprintUserRecord<br/>metadata + source gadget ref"]
  end
  subgraph kv["Workers KV — public lookup"]
    C["BLUEPRINTS namespace<br/>BlueprintKvRecord keyed by hex ID<br/>reserved: .featured, .adminConfig"]
  end
  subgraph r2["R2 — code content"]
    D["BLUEPRINT_CONTENT<br/>key &lt;blueprintId&gt;/&lt;version&gt;<br/>gzip Yjs V2 full state"]
  end
  A -->|propagate| B
  B -->|propagate| C
  A -->|put snapshot| D
  C -->|read| E["PublicApi.getBlueprint()"]
  D -->|read| F["readBlueprintContent()"]
```

<AccordionGroup>
<Accordion title="Gadget DO — blueprints collection">
The authoritative source. Stores `BlueprintGadgetRecord` including full metadata, the code version that was exported, and the `dirty` flag used to track propagation failures.
</Accordion>
<Accordion title="User DO — blueprints collection">
A denormalized copy for efficient listing. Stores `BlueprintUserRecord` with metadata and a reference to the source gadget, so a user can audit and manage their blueprints even after the source gadget is deleted.
</Accordion>
<Accordion title="Workers KV — BLUEPRINTS namespace">
The public-facing lookup store. Stores `BlueprintKvRecord` keyed by blueprint hex ID; this is what `PublicApi.getBlueprint()` reads. The record shape is `{ metadata, ownerId?, gadgetId? }`, where a missing `gadgetId` means the blueprint was uploaded rather than published from a gadget on this instance, and a missing `ownerId` means the deployment installed it itself.
</Accordion>
<Accordion title="R2 — BLUEPRINT_CONTENT bucket">
Code content only, keyed `<blueprintId>/<version>`, stored as a Yjs V2-encoded document (full state, not incremental updates). `readBlueprintContent()` pipes the object through `DecompressionStream("gzip")` and returns the decompressed bytes, or `null` when the object does not exist. Old versions are retained on update to avoid race conditions during concurrent instantiation; deleting a blueprint cleans up all its R2 versions.
</Accordion>
</AccordionGroup>

### The `dirty` flag

`dirty` is set to `true` before propagation begins and cleared only after every downstream write succeeds. If a failure leaves it set, the UI surfaces a warning with a **Retry** button. This is the only recovery mechanism for a partially propagated blueprint — the propagation direction is one-way, so KV is never treated as a source of truth for the Gadget DO.

## `.gadget` archive format

Blueprints download from `/blueprint/<id>` as `.gadget` files and upload from the home blueprints tab into another Workshop instance. `PublicApi.downloadBlueprint(id)` returns a `ReadableStream<Uint8Array>` containing only `BlueprintMetadata` plus the current code snapshot — not the full KV record.

```text
byte offset  size  field
0            8     magic  0xec2e2d3a2300e317   (BigUint64, big-endian)
8            4     format version = 1          (Uint32)
12           4     JSON metadata byte length   (Uint32)
16           8     raw content byte length     (BigUint64)
24           N     UTF-8 JSON BlueprintMetadata
24 + N       M     gzip-compressed Yjs snapshot, copied from
                   BLUEPRINT_CONTENT/<blueprintId>/<version>
```

The 24-byte prefix is emitted by `encodeBlueprintArchivePrefix()`; `buildBlueprintArchiveStream()` writes the prefix with `preventClose: true` and then pipes the R2 content stream into the same writable, aborting the transform on error. Import and export stream through `pipeTo()` rather than buffering the whole archive in worker memory.

### Limits and validation

| Constant | Value | Purpose |
| --- | --- | --- |
| `BLUEPRINT_ARCHIVE_MAGIC` | `0xec2e2d3a2300e317n` | Identifies a `.gadget` container |
| `BLUEPRINT_ARCHIVE_VERSION` | `1` | Format version |
| `BLUEPRINT_ARCHIVE_PREFIX_BYTES` | `24` | Fixed header size |
| `MAX_BLUEPRINT_METADATA_BYTES` | `64 * 1024` (64 KiB) | Caps JSON metadata |
| `MAX_BLUEPRINT_CONTENT_BYTES` | `32 * 1024 * 1024` (32 MiB) | Caps the snapshot payload |
| `MAX_OUTPUT_STRING_LENGTH` | `40` | Longest accepted output slug/noun (UI display limit, not a safety limit) |

The two size caps exist so a malformed archive cannot force unbounded allocation in the worker. The prefix reader throws `Unexpected end of gadget archive.` when the stream ends early, and `Archive content stream already opened.` if `readExact()` or `takeTail()` is called after the tail has been taken.

`sanitizeBlueprintOutput()` accepts a declared output format only if `id`, `noun`, and `plural` all pass `outputString()` (non-empty after trim, ≤ 40 chars) and `icon` passes `isOutputIcon()`. Anything else degrades to `undefined` — the blueprint is treated as declaring a generic app rather than reaching the UI with an unknown icon key.

<Warning>
The archive omits `ownerId`, `gadgetId`, and screenshot bytes. Imported archives clear any screenshot marker, because screenshots are stored separately from archive content.
</Warning>

## Public read surface

| RPC | Behavior |
| --- | --- |
| `PublicApi.getBlueprint(id)` | Returns `BlueprintPublicInfo` or `null`. No authentication — knowing the ID is sufficient. |
| `PublicApi.downloadBlueprint(id)` | Returns a `ReadableStream<Uint8Array>` of the `.gadget` archive. |
| `AuthenticatedApi.adminIsBlueprintFeatured()` | Admin-only. Whether a published blueprint is currently featured. |
| `AuthenticatedApi.adminSetBlueprintFeatured()` | Admin-only. Marks or unmarks a blueprint as featured. |

Admin usernames come from the backend worker's `ADMINS` binding, configured as an array of usernames. Only gadget-backed published blueprints are featureable; uploaded/imported library blueprints are intentionally excluded.

KV read helpers in `blueprint-archive.ts` narrow their env to exactly the bindings they need:

```ts
export type BlueprintKvEnv = Pick<Cloudflare.Env, 'BLUEPRINTS'>;

export async function readBlueprintKvRecord(
  env: BlueprintKvEnv,
  blueprintId: string,
): Promise<BlueprintKvRecord | null>
```

Dates round-trip through JSON as strings, so `reviveBlueprintMetadata()` re-hydrates `metadata.created` and `metadata.lastUpdated` into `Date` objects on every parse — including inside `parseFeaturedBlueprints()`, which revives each entry of the `.featured` list.

## Library, pinning, and explore

The home page blueprints tab lists blueprints the user has published plus what is in their library. Pinning keeps a blueprint at the top; pinning a public blueprint that is not already in the library adds it to the library first, then pins it.

| Library entry kind | Created by | Ownership | Removal effect |
| --- | --- | --- | --- |
| Saved by reference | `addBlueprintToLibrary()` | Blueprint stays owned by the original publisher; the entry caches public metadata for list rendering | Deletes only your personal library entry |
| Uploaded | `importBlueprint()` from a `.gadget` archive | New local blueprint ID on the current deployment, snapshot stored in this deployment's R2/KV, recorded with `uploaded: true` | Deletes the imported blueprint content as well |

The Explore page (`/explore`) lists featured blueprints, which admins control via the two admin RPCs above.

## Bundled format blueprints

`packages/workshop-backend/src/format-blueprints.ts` installs the deployment's bundled output-format blueprints. Archives and their presentation come from a directory chosen at build time (`scripts/build-format-blueprints.mjs`), so a fork ships its own formats by pointing `FORMAT_BLUEPRINTS_DIR` at its own tree.

Installation writes an ordinary blueprint — metadata into `BLUEPRINTS`, code snapshot into `BLUEPRINT_CONTENT` — exactly as publishing does. Nothing downstream knows these are special.

<Steps>
<Step title="Parse through the ordinary archive reader">
`installOne()` calls `parseBlueprintArchive()` on the base64-decoded bundle bytes, so a corrupt bundled file fails the same way an uploaded one would rather than producing a half-installed blueprint.
</Step>
<Step title="Verify declared length">
The content is buffered (it already lives in the Worker bundle) and its byte length is compared with the archive's declared `contentLength`. A mismatch throws `Archive declares N content bytes but holds M.`
</Step>
<Step title="Overlay sidecar presentation">
The archive supplies code, bindings, and export dates. `title`, `description`, `author`, and `output` are overwritten from the sidecar entry.
</Step>
<Step title="Write content before metadata">
`BLUEPRINT_CONTENT.put(\`${entry.blueprintId}/${installed.version}\`, contentBytes)` runs first. Metadata without an R2 object is broken; an orphaned R2 object is merely overwritten by the next install. The archive's content section is already gzip-compressed, which is exactly what R2 holds.
</Step>
<Step title="Write the KV record">
`BLUEPRINTS.put(entry.blueprintId, JSON.stringify({metadata: installed}))`. The `BlueprintKvRecord` has no `ownerId` and no `gadgetId`, marking it as deployment-installed.
</Step>
</Steps>

`installFormatBlueprints()` iterates every entry, pushing successes onto the returned `BlueprintPublicInfo[]` and logging failures via the `workshop.formats` logger with `event: "formats.install.ok"` or `"formats.install.failed"`. One bad archive does not deny the deployment the others; a deployment with none installed simply has no standard formats.

### Reinstall detection

`formatBlueprintsManifestVersion()` builds a comparison string from every bundled entry so any change triggers reinstallation:

```ts
// packages/workshop-backend/src/format-blueprints.ts
export function formatBlueprintsManifestVersion(): string {
  return FORMAT_BLUEPRINTS
      .map(e => `${e.blueprintId}@${e.revision}+` +
          fingerprint(JSON.stringify([e.title, e.description, e.author, e.output])))
      .toSorted()
      .join(",");
}
```

Presentation fields contribute alongside `revision`, because editing a description would otherwise build, deploy, and change nothing on a deployment that had already installed. `revision` covers the one input the fingerprint cannot see: the archive bytes.

## Related pages

<CardGroup cols={2}>
<Card title="Manage bundled format blueprints" href="/bundled-format-blueprints">
The `.gadget` plus `.json` sidecar split, `FORMAT_BLUEPRINTS_DIR`, generation into `src/generated/format-blueprints.ts`, and why a deployed `blueprintId` must never be renamed.
</Card>
<Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
Collaborator roles, the `use` allowlist, and share-link keys stored only as HMAC-SHA-256 hashes.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`PublicApi`, `AuthenticatedApi`, and `Overseer`, plus `GadgetMetadata`, `AgentSpawnerConfig`, and `AiModelConfig`.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
Where `BLUEPRINTS`, `BLUEPRINT_CONTENT`, and `/blueprint-screenshot/*` are wired into the router and backend.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
The `AdminConfig` schema, the `AdminSettings` DO as sole writer, and the reserved `.adminConfig` KV key.
</Card>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`GatekeeperVendor`, `ResourceDescription`, and URL-pattern matching behind blueprint gatekeeper bindings.
</Card>
</CardGroup>

---

## 09. Sharing, roles, and observer re-verification

> Collaborator roles (`build` > `use`), the `use` allowlist enforced by `UseOverseerInterface` with its default-deny compile-time check, the two inert telemetry subscriptions, and share-link keys stored only as HMAC-SHA-256 hashes. Documents observer registration through `Gatekeeper.addObserver()`, verifier minting, and how a failing re-check blocks new observations.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/09-sharing-roles-and-observer-re-verification.md
- Generated: 2026-08-05T16:39:51.553Z

### Source Files

- `docs/sharing.md`
- `docs/observers.md`
- `packages/workshop-backend/src/sharing.ts`
- `packages/workshop-backend/src/overseer.ts`
- `packages/workshop-shared/src/api.ts`
- `packages/integration-tests/__tests__/observer-reverification.test.ts`

---
title: "Sharing, roles, and observer re-verification"
description: "Collaborator roles (`build` > `use`), the `use` allowlist enforced by `UseOverseerInterface` with its default-deny compile-time check, the two inert telemetry subscriptions, and share-link keys stored only as HMAC-SHA-256 hashes. Documents observer registration through `Gatekeeper.addObserver()`, verifier minting, and how a failing re-check blocks new observations."
---

A gadget can be shared with other users in two ways: as a **collaborator** (direct access to the same gadget) or as a **blueprint** (a code snapshot others fork). Collaborator access is capability-based: `open()` computes the caller's effective role from the permission graph and returns a different object depending on the result — the full `OverseerClientInterface` for owner/`build` sessions, and a restricted `UseOverseerInterface` for `use` sessions. Layered on top of roles is the observer mechanism: before a non-owner may open a shared gadget, each relevant gatekeeper must confirm through `Gatekeeper.addObserver()` that the person could have read the gadget's historical observations directly.

## Collaborator roles

Roles are totally ordered: `build` > `use`. `sharing.ts` ranks them numerically (`build` → 2, `use` → 1) and treats edges or share keys written before roles existed as `build` for backwards compatibility.

| Role | Capabilities |
|---|---|
| `build` | Full access: edit code, use the AI chat, manage bindings, interact with the gadget UI — the same as the owner apart from the exceptions below. |
| `use` | Render and interact with the deployed UI only. Every `Overseer` method outside the `use` allowlist throws `Unauthorized`. |

`build` collaborators differ from the owner in four ways:

- **Cannot delete the gadget.** Owner-only.
- **Use their own AI models.** When a collaborator engages AI chat, the model resolves from their own account, so BYOK billing lands on whoever prompted the AI rather than the gadget owner.
- **Use their own connected accounts for bindings.** A collaborator adding a gatekeeper binding connects through their own third-party accounts, which prevents them from gaining access to the owner's accounts beyond what existing bindings already expose.
- **Limited revocation authority.** A collaborator can remove only users they themselves added.

A caller may never grant a role higher than their own effective role. Because the sharing methods are not in the `use` allowlist, only the owner and `build` collaborators can share at all today; the permission graph still models roles generally, so permitting `use` collaborators to reshare `use` access later needs no algorithmic change.

## The `use` allowlist and `UseOverseerInterface`

`UseOverseerInterface` implements the entire `Overseer` interface. Everything outside the allowlist throws `Unauthorized`.

<Info>
Because the class declares `implements Overseer`, any newly added interface method fails to compile until a developer consciously decides whether `use` callers may invoke it. This is the default-deny compile-time check: forgetting to classify a new method is a build error, not a silent capability grant.
</Info>

Allowed for `use` sessions:

| Method | Constraint |
|---|---|
| `getUiBundle()` | Mainline code only — `chatId` must be omitted. |
| `connectToGadget()` | Mainline code only — `chatId` must be omitted. |
| `getMetadata()` | Restricted to `id` / `title` / `owner` / `role`. |
| `subscribeToMetadata()` | Same field restriction as `getMetadata()`. |
| `subscribeToPresence()` | Deliberately allowlisted; exposes active viewers' names, profile IDs, and roles. |

### The two inert telemetry subscriptions

`subscribeToConsoleLogs()` and `subscribeToActions()` are the two exceptions to the throw-everything-else rule. They resolve, but deliver nothing: no console logs, and an empty action log that immediately calls `ready()`.

The reason is client structure, not policy: the editor opens both speculatively from top-level hooks before it switches to the use-only view. Resolving them quietly avoids spurious client-side errors while still revealing nothing.

```text
use session ──► UseOverseerInterface (implements Overseer)
                 │
                 ├─ allowlist ──────────► real behavior
                 │   getUiBundle, connectToGadget,
                 │   getMetadata, subscribeToMetadata,
                 │   subscribeToPresence
                 │
                 ├─ inert pair ─────────► resolves, never delivers
                 │   subscribeToConsoleLogs, subscribeToActions
                 │
                 └─ everything else ────► throws Unauthorized
                     (new Overseer methods break the build
                      until explicitly classified)
```

## Adding collaborators

<Steps>
<Step title="Direct add">
The owner or an existing collaborator enters a username (email address) in the Share modal. The system looks up the corresponding user account; if it exists, a collaborator record is created. The target user receives no in-product notification — the sharer is expected to send a link or tell them out of band.
</Step>
<Step title="Share link">
Any collaborator or the owner can create a share link, which encodes a secret key in the URL as a `#share=<key>` fragment. Opening the link automatically adds the visitor as a collaborator.
</Step>
</Steps>

A link is a durable handle owning one or more keys. Creating the link mints its first key; "copying" the link later mints another key for the same link. The raw key is shown to the creator once at mint time and is never stored server-side, so re-copying cannot reproduce an old key — it mints a new one. Any of a link's keys may be redeemed by multiple people, or by the same person repeatedly, until the link is revoked, which invalidates every key minted for it.

Redemption and opening happen atomically in one RPC call, `openGadget(id, shareKey)`, so subsequent calls can be pipelined on the returned `Overseer` stub without waiting for a separate redemption step.

### Share-key storage and hashing

The server generates a random 128-bit key and stores only its HMAC-SHA-256 hash, computed with a fixed domain-separation constant `SHARE_KEY_HMAC_KEY` (a 256-bit non-secret personalization value defined in `packages/workshop-backend/src/sharing.ts`). On redemption the client sends the raw key, the server hashes it and looks it up.

```ts
// packages/workshop-backend/src/sharing.ts
async function hashShareKey(rawKey: string): Promise<string> {
  let hmacKey = await crypto.subtle.importKey(
      "raw", SHARE_KEY_HMAC_KEY, { name: "HMAC", hash: "SHA-256" },
      false, ["sign"]);
  let sig = new Uint8Array(await crypto.subtle.sign(
      "HMAC", hmacKey, Uint8Array.fromHex(rawKey)));
  return sig.toHex();
}
```

<Check>
The server cannot reconstruct share links from its stored data, so a database leak does not expose valid share keys.
</Check>

Storage shape: a link *is* its first key. The `shareKeys` collection holds one row per key. The row for the first key carries the link's metadata and is keyed by that key's hash, which doubles as the link id (`ShareLinkRecord.id`). Each later copy stores only `alias`, pointing back at that id (`ShareKeyAliasRecord`). Because a link is itself a key record, keys written before copies existed are already valid links — no migration was needed.

<ResponseField name="ShareLinkRecord" type="object">
  <ResponseField name="id" type="string" required>HMAC-SHA-256 hex of the raw key; also the link id.</ResponseField>
  <ResponseField name="alias" type="never">Never set on a link; presence discriminates the union toward `ShareKeyAliasRecord`.</ResponseField>
  <ResponseField name="note" type="string">Optional free-text note.</ResponseField>
  <ResponseField name="created" type="Date" required>Mint time.</ResponseField>
  <ResponseField name="createdBy" type="string" required>`profile.id` of the creator.</ResponseField>
  <ResponseField name="role" type="CollaboratorRole">Role granted on redemption. Absent on pre-roles links; treated as `build`.</ResponseField>
  <ResponseField name="revoked" type="boolean">Soft-revocation flag. Revoking sets this instead of deleting, keeping `shareKey` permission edges free of dangling references.</ResponseField>
</ResponseField>

## Permission graph and lazy revocation

Each collaborator record holds a denormalized `profile` snapshot plus `addedBy: PermissionEdge[]` explaining how access was obtained. There are two edge types:

- **User edge** — a specific sharer (identified by `profile.id`) directly added this collaborator. Carries a timestamp, the granted role, and an optional note.
- **Share-link edge** — this collaborator redeemed a key for a specific share link, identified by `keyId` (the id of the link's first key). Carries a timestamp; the role comes from the link.

A collaborator can accumulate multiple edges — added directly by Alice *and* having redeemed Bob's link, for example.

Access is reachability from the owner in the graph, recomputed live at every `open()` via `getEffectiveRole`. Revocation is therefore lazy: removing a collaborator severs only the edges granting *them* access, and revoking a share link only flags the link `revoked`. Nothing cascades, no records are deleted, and downstream edges are untouched — users who lose their last path to the owner become unreachable and are denied at open time. Because the graph is never destructively pruned, revocation is reversible: re-adding a removed collaborator restores them and, transitively, everyone they shared with. Records and revoked keys accumulate in storage; a future GC could reclaim long-dead entries.

<Note>
`prohibitAllSharing` deliberately does not live in `sharing.ts`. It is a broader "may this gadget communicate with anyone other than the owner?" policy that also gates gatekeeper writes and web fetches, and the Overseer enforces it. `sharing.ts` exposes only `hasAnyShares()` so that policy can ask about current sharing state.
</Note>

## Home page behavior for shared gadgets

A shared gadget does not appear on a collaborator's home page until they first open it. At that point `UserDurableObject.recordSharedGadgetOpen()` creates a record in the collaborator's user account holding a cached copy of the gadget's title and the owner's profile; `lastActive` is refreshed on each subsequent open.

Shared gadgets appear in the same list as owned ones, distinguished by the owner's name in the "Owner" column. A collaborator can dismiss a shared gadget from their home page, which removes the record but does not revoke access — reopening the URL makes it reappear.

When access is revoked, the stale record stays on the collaborator's home page; nothing proactively reaches into their account. The next open returns a workspace access-denied error, and the client reports the loss of access without disclosing the workspace name or other metadata. The dead entry can be dismissed manually.

## The observer invariant

Gadgets enforce a core security invariant: if a gadget can read restricted information, any user who cannot read that information is also prohibited from interacting with the gadget, to prevent data leaks.

The original mechanism enforcing this was the blunt `prohibitAllSharing` flag on `ObservationDescription` (`packages/workshop-shared/src/gatekeeper.ts`). When a gatekeeper marks an observation as maximally sensitive, the gadget can no longer be shared with anyone and drops into lockdown — no further actions, no web fetches. That flag cannot express "this data may be shared, but only with people who also have access to it."

The observer mechanism replaces that all-or-nothing posture with a per-user, gatekeeper-mediated check:

1. When Bob opens a gadget Alice shared, he must specify a connected account of his own for each of the gadget's gatekeepers.
2. Each gatekeeper verifies that Bob's account has sufficient privileges to directly read everything the gadget has historically read through that gatekeeper. If not, Bob is denied access.
3. If the checks pass, Bob is registered as an **observer** of the gadget, recording his connected accounts.
4. Going forward, any new observation that at least one registered observer lacks the privileges to make directly is **blocked, throwing an exception**. Alice can resolve this by revoking Bob's access.
5. Bob's access is re-checked every time he opens the gadget.

### Observers, verifiers, forward exclusion

<AccordionGroup>
<Accordion title="Observers">
Every non-owner who can see data the gadget read is an observer. When a user becomes an observer, each relevant gatekeeper is asked — via `Gatekeeper.addObserver()` — to verify that this specific person may directly observe everything the gadget already read through that gatekeeper. The gatekeeper is the authority on its own resource's ACL, so the check runs inside the gatekeeper's trust domain.
</Accordion>
<Accordion title="Verifiers">
The overseer cannot itself reason about a vendor's identity or ACL model. Instead, the prospective observer's own connected account mints an opaque `GatekeeperUserVerifier` via `GatekeeperUser.getVerifier()`, which the overseer hands back to the gatekeeper. The gatekeeper unwraps it — today by calling semi-private methods it defined on its own verifier object — to learn the observer's vendor-level identity and check access.
</Accordion>
<Accordion title="Forward exclusion">
For observations made *after* a user becomes an observer, a gatekeeper can name observers who must not see a given observation via `ObservationDescription.excludeObservers`. The overseer must then guarantee those observers never see it, or block the observation.
</Accordion>
</AccordionGroup>

The interface surface — `GatekeeperUser.getVerifier()`, `GatekeeperUserVerifier`, `Gatekeeper.addObserver()` / `removeObserver()`, and `ObservationDescription.excludeObservers` — lives in `packages/workshop-shared/src/gatekeeper.ts`.

```mermaid
classDiagram
    class Gatekeeper {
        <<interface>>
        +addObserver(verifier)
        +removeObserver(verifier)
    }
    class GatekeeperUser {
        <<interface>>
        +getVerifier() GatekeeperUserVerifier
    }
    class GatekeeperUserVerifier {
        <<opaque>>
        semi-private vendor methods
    }
    class ObservationDescription {
        +prohibitAllSharing
        +excludeObservers
    }
    class Overseer {
        +open()
        +authorizeObservation()
    }
    class SharingManager {
        +getEffectiveRole()
        +computeEffectiveRoles()
        +hasAnyShares()
    }
    Overseer --> SharingManager : keys authorization on sharing table
    Overseer --> GatekeeperUser : requests verifier from observer's own account
    GatekeeperUser ..> GatekeeperUserVerifier : mints
    Overseer --> Gatekeeper : hands verifier to addObserver()
    Gatekeeper ..> GatekeeperUserVerifier : unwraps to learn vendor identity
    Gatekeeper --> ObservationDescription : names excluded observers
    Overseer --> ObservationDescription : blocks observation if exclusion unenforceable
```

### Breadth of verification by role

Verification breadth follows the role, because the role bounds what the collaborator can invoke:

| Role | Gatekeepers verified against |
|---|---|
| `build` | **Every** gatekeeper the gadget has — full access means chat, code, and all bindings. |
| `use` | Only **named bindings** (gatekeepers with a `bindingName`), since that is all the UI can invoke. |

### Account selection

A collaborator must have their own connected account for each vendor the gadget depends on. For ordinary bindings they choose which account to use — work or personal Google, for instance. If an account cannot be selected automatically, the configuration modal prompts them to choose or connect one; declining denies the open.

Ambient bindings are an exception to account *selection*, not to verification: when the collaborator already holds the matching provided singleton account, the overseer uses it automatically and still runs the gatekeeper's normal `addObserver` check.

### v1 scope limits

- **No per-thread enforcement.** v1 is all-or-nothing per observer; individual chat threads and observations are not hidden from individual collaborators.
- **Authorization keys on the sharing table, not live sessions.** Because a gadget may store observed data and re-display it later — even to a `use` observer opening much later — every exclusion and enforcement decision keys off whether a user is still *authorized* in the sharing graph (`computeEffectiveRoles`), never off whether they currently have the gadget open.

## Re-verification on re-open

A collaborator's observer account choice is persisted after their first successful open, so later opens re-verify through `ensureObserver()` without prompting. Verification failure at that point is routine — credentials lapse.

The regression suite in `packages/integration-tests/__tests__/observer-reverification.test.ts` pins the required behavior: a failed re-check must re-prompt through `ObserverConfigCallback` with the failure attached, and if the re-prompt does not fix it, the error must name which connection and which account failed. The older behavior dead-ended the open with "You are not permitted to observe all of the data this Gadget has accessed" and no way forward.

<Warning>
The overseer cannot distinguish a lapsed credential from a genuine access denial — both arrive as a thrown error from the gatekeeper. What the user reads is the gatekeeper's own reason string, which is why the tests exercise both shapes (`"credentials expired — please reconnect"` and `"You do not have access to this thing."`).
</Warning>

### How the re-verification tests are wired

Nothing is stubbed but the network. The real `workshop-backend` runs under wrangler, tests speak Cap'n Web over a WebSocket to `/api` exactly as the browser does, and the gatekeeper is a real Worker speaking the real protocol — a fixture (`fixtures/gatekeeper-test/src/test-gatekeeper.ts`) whose verification outcome the tests set. That controllability is the whole reason the fixture exists.

The fixture exposes control routes fetched through `harness.fetchWorker`:

| Control route | Method | Body | Response |
|---|---|---|---|
| `/control/verify-outcome` | `POST` | `{ label, allow: true }` or `{ label, allow: false, reason }` | `204`; `400` with a reason if the body is rejected |
| `/control/ambient-verification-count` | `POST` | `{ label }` | `200` with `{ count: number }` |

Setting the next verification outcome for an account label:

```ts
// packages/integration-tests/__tests__/observer-reverification.test.ts
await harness.fetchWorker(
  TEST_GATEKEEPER_WORKER, "http://gatekeeper-test.test/control/verify-outcome",
  { method: "POST", body: JSON.stringify({ label, allow: false, reason }) });
```

The scenario helper builds the exact state the bug lived in: Alice signs up, provisions an ambient test-gatekeeper account via `provisionAmbientAccount(TEST_VENDOR_ID)`, creates a gadget with one gatekeeper per named "Test Thing" resource (`overseer.newGatekeeper(aliceAccount.id, thingUrl(thingName))`), and adds Bob with `overseer.addCollaborator(bob, "build")`. Bob must already exist before he can be added. Bob then provisions his own account, and `failBob(reason)` flips the gatekeeper to refuse his label from that point on — the same shape as a credential lapsing between opens. Bob's account label is what the gatekeeper keys outcomes on and what the Workshop names in the failure message.

Test hygiene worth copying:

- A `NetworkInterceptor` is installed with **no handlers**, so any outbound request is a failure. Unmocked calls are asserted once in `afterAll` rather than per test, because the tests run concurrently and an `afterEach` would inspect and clear state sibling tests are still using.
- Each test opens its own RPC session (`connect(harness.url)`, disposed in a `finally`), so a disposal in one cannot disturb another running alongside.
- Resource names are passed per test so they appear in the asserted failure message (for example `"Test Thing multi-a"`).

## Related pages

<CardGroup cols={2}>
<Card title="Observations, actions, and approval queues" href="/observations-and-actions">
`ObservationDescription` including `prohibitAllSharing`, the `ObservationAuthorizer` and `ApprovalQueue` interfaces, and `ActionState` transitions.
</Card>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`GatekeeperVendor`, `GatekeeperUser`, `GatekeeperUserVerifier`, and `Gatekeeper<Session>`, plus resource descriptions and ambient account modes.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`Overseer`, `AuthenticatedApi`, observer-config callbacks, and stub-disposal and promise-pipelining constraints.
</Card>
<Card title="Integration testing" href="/integration-testing">
`createTestHarness()`, why a fixture gatekeeper covers overseer logic, and the pluggable network interceptor.
</Card>
<Card title="Blueprints" href="/blueprints">
The other sharing mechanism: code snapshots, `.gadget` export/import, and share-link semantics for blueprints.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Observer binding failures, gadget lockdown from `prohibitAllSharing`, and RPC stub leaks.
</Card>
</CardGroup>

---

## 10. Agent runtime and tools

> The Code Mode agent loop: the tool set (`readFile`, `writeFile`, `editFile`, `executeCode`, `describeBinding`, `setGadgetBinding`, `createGadget`, `listBlueprints`, `listConnectableResources`, `requestConnection`, `webFetch`, `observeUserChanges`, `giveUp`), how `prepareChatBindings` folds ambient gatekeepers into `env` under `suggestedBindingName`, slash-command collection, and chat compaction checkpoints.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/10-agent-runtime-and-tools.md
- Generated: 2026-08-05T16:40:12.095Z

### Source Files

- `packages/workshop-backend/src/agent.ts`
- `packages/workshop-backend/src/agent-catalog.ts`
- `packages/workshop-backend/src/agent-compaction.ts`
- `packages/workshop-backend/src/slash-commands.ts`
- `packages/workshop-backend/src/overseer.ts`
- `packages/workshop-backend/src/web-fetch.ts`

---
title: "Agent runtime and tools"
description: "The Code Mode agent loop: the tool set (`readFile`, `writeFile`, `editFile`, `executeCode`, `describeBinding`, `setGadgetBinding`, `createGadget`, `listBlueprints`, `listConnectableResources`, `requestConnection`, `webFetch`, `observeUserChanges`, `giveUp`), how `prepareChatBindings` folds ambient gatekeepers into `env` under `suggestedBindingName`, slash-command collection, and chat compaction checkpoints."
---

The agent runs in Code Mode: instead of calling resource APIs through individual tool schemas, the agent writes JavaScript that is loaded as a Dynamic Worker and executed against an `env` object of named bindings. `packages/workshop-backend/src/overseer.ts` holds the harness source that wraps the agent's module, `packages/workshop-backend/src/agent.ts` runs the loop through `runAgentLoopContinue` from `@earendil-works/pi-agent-core`, `packages/workshop-backend/src/agent-compaction.ts` implements context compaction, `packages/workshop-backend/src/agent-catalog.ts` builds the always-available-resources prompt section, `packages/workshop-backend/src/slash-commands.ts` collects and invokes gatekeeper-provided commands, and `packages/workshop-backend/src/web-fetch.ts` implements the `webFetch` capability.

## Code Mode harness

`CODE_MODE_HARNESS` in `overseer.ts` is the module the runtime loads around the agent's generated `agent.js`. It exposes a `WorkerEntrypoint` with two methods, matching the `CodeModeEntrypoint` interface:

```js
// packages/workshop-backend/src/overseer.ts — CODE_MODE_HARNESS
import { WorkerEntrypoint, restore, RpcStub, RpcTarget } from "cloudflare:workers";
import agent from "agent.js";

export default class extends WorkerEntrypoint {
  verify() {}
  async run(self, callbackResolvers) {
    let env = this.env;
    if (callbackResolvers) {
      for (let [index, {resolve, reject}] of Object.entries(callbackResolvers)) {
        env[index] = {
          args: env[index],
          resolve,
          reject,
        };
      }
    }
    await agent(self, env, this.ctx);
  }
  ...
}
```

| Member | Purpose |
| --- | --- |
| `verify()` | No-op entrypoint used to check that the generated module loads and links. |
| `run(self, callbackResolvers)` | Invokes the agent's default export as `agent(self, env, this.ctx)`. |
| `callbackResolvers` | Per-index `{resolve, reject}` native RPC stubs. Each named `env` entry for that index is rewritten to `{args, resolve, reject}`. |
| `[restore](params)` | Returns a stub wrapping `PlaceholderRpcTarget` instead of the real target. |

`PlaceholderRpcTarget` is a `Proxy` that returns `undefined` for `then` and `dup` and throws for every other property access, with a message stating the stub is only intended to be stored and will work once loaded back from storage. The code comments this as a temporary hack until the runtime provides sealing/unsealing APIs, and notes such stubs are generally not expected to be called before being passed to `bindHook()`.

<Warning>
The `[restore]` placeholder means a persistent hook callback stub must be round-tripped through storage before it is invocable. Calling it in the same execution that constructed it throws.
</Warning>

## Tool set

The agent loop uses `AgentTool` values from `@earendil-works/pi-agent-core`, declared in `agent.ts`. The tools split into code-and-file tools, binding/resource tools, and control tools.

| Tool | Role in the loop |
| --- | --- |
| `readFile` | Read agent-visible source. |
| `writeFile` | Write a whole file. |
| `editFile` | Patch a file; `agent.ts` imports `createTwoFilesPatch` and `FILE_HEADERS_ONLY` from `diff` for change presentation. |
| `executeCode` | Run agent-authored JavaScript against `env` through the Code Mode harness. |
| `describeBinding` | Learn a binding's API by name before using it. |
| `setGadgetBinding` | Wire a binding into a gadget's persistent code. |
| `createGadget` | Create a new gadget workpiece. |
| `listBlueprints` | Enumerate available blueprints. |
| `listConnectableResources` | Enumerate resources the user could connect. |
| `requestConnection` | Ask the user to connect a resource; recorded as a `connectionRequest` chat message. |
| `webFetch` | HTTPS GET with optional document-to-Markdown conversion. |
| `observeUserChanges` | Observe user-side code changes. |
| `giveUp` | End the turn without completing the request. |

### Binding discovery guidance

`formatAlwaysAvailableResourcesPrompt` in `agent-catalog.ts` emits the system-prompt text that ties `describeBinding` and `setGadgetBinding` together. For each always-available resource it renders a line of the form `- ${title}: \`env.${name}\`` followed by the resource's catalog JSON, then appends the fixed instruction:

> When one is relevant, use describeBinding with the binding's name to learn its API before using it. If a Gadget's persistent code needs one, wire it into that gadget with setGadgetBinding.

The preamble states these bindings are always available in `env` for use with `executeCode` and do not need to be requested.

## Chat bindings and ambient gatekeepers

A chat's `env` name space is described by `AiChatAgentContext` in `agent.ts`:

<ResponseField name="chatId" type="number" required>
Chat ID, corresponding to `chatMeta`.
</ResponseField>

<ResponseField name="spawnerConfig" type="AgentSpawnerConfig">
Present when the chat was spawned through a spawner; the spawner config as it was at spawn time.
</ResponseField>

<ResponseField name="bindings" type="Record<string, WorkpieceId>">
Initial `env` binding set gathered when the chat started — typically all gadgets plus the gatekeepers those gadgets bind to. Frozen after the chat starts: `"changes"` messages may introduce new bindings but are not added here, so the current binding set is obtained by replaying the chat log. Absent for chats created before named chat bindings existed; those are seeded lazily at the next turn start.
</ResponseField>

<ResponseField name="alwaysAvailableCapsuleIds" type="WorkpieceId[]">
Gatekeeper IDs for ambient capsules instantiated when the chat started. Predates named chat bindings, when ambient gatekeepers were delivered as numbered "capsules" occupying the lowest capsule numbers. Retained to support migration of old chats and as a record of which bindings came from ambient gatekeepers.
</ResponseField>

<ResponseField name="alwaysAvailableCatalogs" type="AgentCatalogSnapshot[]">
Cached discovery catalogs for the always-available resources, keyed per gatekeeper. Regenerable — re-fetched when missing or stale by `prepareChatBindings`.
</ResponseField>

`AgentHooks.prepareChatBindings()` returns the chat's seed binding layer as `SeedBindingInfo` entries:

| Field | Type | Meaning |
| --- | --- | --- |
| `name` | `string` | The name in the chat's `env`. |
| `target` | `WorkpieceId` | The workpiece the name resolves to. |
| `title` | `string` | Human title: a gadget's title, or a gatekeeper's resource title. |
| `isGadget` | `boolean` | Gadget target versus external resource gatekeeper. |
| `catalog` | `AgentCatalog \| null` | Present only for always-available (ambient) entries; `null` when the gatekeeper provides no catalog. |

The `catalog` field is documented as being present when the entry is an always-available (ambient) resource — for example the read session of a connected account that provides a singleton — and such entries get their own system-prompt section, which is the section `formatAlwaysAvailableResourcesPrompt` renders. Ambient gatekeeper mode resolution enters the overseer through `ambientGatekeeperMode` from `./provisioning-policy`.

Binding names are validated with `validateBindingName` from `@gadgets/workshop-shared/api`, imported by both `agent.ts` and `overseer.ts`.

At execution time, a name resolves through `ChatBindingEntry`:

```ts
// packages/workshop-backend/src/agent.ts
export type ChatBindingEntry =
  | { type: "workpiece"; id: WorkpieceId }
  | { type: "value"; messageSequence: number };
```

`"workpiece"` covers gadgets and gatekeepers — the comment notes the overseer distinguishes the two at env-build time — while `"value"` carries the value arguments of an agent callback, addressed by the chat message sequence.

<Note>
`AiChatAgentContext.bindings` documents that if a referenced workpiece is deleted, this is detected when `env` is materialized for a particular execution and the corresponding bindings are dropped.
</Note>

```mermaid
flowchart TB
  subgraph shared["@gadgets/workshop-shared"]
    api["api: WorkpieceId, validateBindingName,\nSlashCommandChoice/Request"]
    gk["gatekeeper: Gatekeeper, AgentCatalog,\nObservationAuthorizer"]
  end

  subgraph backend["packages/workshop-backend/src"]
    overseer["overseer.ts\nCODE_MODE_HARNESS, AgentHooks impl"]
    agent["agent.ts\nrunAgent, AiChatAgentContext,\nChatBindingEntry, CompactionCheckpoint"]
    catalog["agent-catalog.ts\nnormalizeAgentCatalog,\nformatAlwaysAvailableResourcesPrompt"]
    compaction["agent-compaction.ts\nshouldCompactChat, foldProposedChanges,\nfindCompactionBoundary"]
    slash["slash-commands.ts\ncollectSlashCommands, invokeSlashCommand"]
    fetch["web-fetch.ts\nvalidateWebFetchUrl, webFetch"]
  end

  subgraph runtime["Workers runtime"]
    dyn["Dynamic Worker\nCODE_MODE_HARNESS + agent.js"]
    ai["env.WORKERS_AI.toMarkdown()"]
    gkw["Gatekeeper workers\n(Fetcher<Gatekeeper<any>>)"]
  end

  overseer --> agent
  agent --> compaction
  agent --> catalog
  agent --> fetch
  overseer --> slash
  overseer --> catalog
  overseer --> compaction
  agent -->|executeCode| dyn
  dyn -->|env.NAME| gkw
  slash --> gkw
  catalog --> gkw
  fetch --> ai
  agent --> api
  slash --> api
  catalog --> gk
  slash --> gk
```

## Agent catalogs

Gatekeeper-supplied catalogs are untrusted output and are re-validated on the workshop side.

### `normalizeAgentCatalog(catalog)`

Strips control characters (`\p{Cc}`), collapses whitespace, trims, and slices each field to its bound; drops entries whose `id` or `title` is empty; sorts by `title` then `id` with `localeCompare`; and clamps to `AGENT_CATALOG_MAX_ENTRIES`. `truncated: true` is set when the input already declared it or when the entry count exceeded the maximum.

| Field | Bound |
| --- | --- |
| `id` | `AGENT_CATALOG_MAX_ID_LENGTH` — kept at full bound because it is the opaque key the agent passes back |
| `title` | `AGENT_CATALOG_MAX_TITLE_LENGTH` |
| `description` | `AGENT_CATALOG_MAX_DESCRIPTION_LENGTH` |
| entry count | `AGENT_CATALOG_MAX_ENTRIES` |

The code comments this as defense-in-depth that intentionally overlaps the provider-side `boundAgentCatalog()` in shared code, because the gatekeeper is not trusted to have applied it.

### `completeAgentCatalogSnapshot(existing, gatekeeperIds, loadCatalog)`

Fills in missing snapshots for the active gatekeeper IDs and drops stale entries.

<ParamField body="existing" type="AgentCatalogSnapshot[] | undefined">
Previously cached snapshots. Entries whose `gatekeeperId` is not in `gatekeeperIds` are removed.
</ParamField>

<ParamField body="gatekeeperIds" type="number[]" required>
The active gatekeeper IDs to complete against.
</ParamField>

<ParamField body="loadCatalog" type="(gatekeeperId: number) => Promise<AgentCatalog | null>" required>
Loader for one missing catalog. Failures are isolated per entry: a throw is logged as `agent.catalog.load.failed` with the `gatekeeperId` and recorded as `null`.
</ParamField>

Returns `{snapshots, changed}`. `snapshots` is sorted ascending by `gatekeeperId`; `changed` is true when any entry was loaded or any stale entry was removed. The isolation comment states the reason directly: one failing loader must not reject the whole snapshot, since that would lose every other catalog and abort the turn.

`formatAgentCatalogPrompt(catalog)` returns `"\n" + JSON.stringify(catalog)` for a non-empty catalog and `""` otherwise, so an empty catalog contributes nothing to the prompt.

## Slash commands

Slash commands come from attached gatekeepers that implement `getSlashCommandProvider()`. `collectSlashCommands` fans out across sources, tolerates per-gatekeeper failure, and returns one sorted catalog.

```ts
// packages/workshop-backend/src/slash-commands.ts
type SlashCommandSource = {
  gatekeeperId: number;
  providerLabel: string;
  gatekeeper: Fetcher<Gatekeeper<any>>;
};

export async function collectSlashCommands(
    sources: SlashCommandSource[]): Promise<SlashCommandChoice[]>
```

Each produced `SlashCommandChoice` carries `selection: {gatekeeperId, commandId}`, `name`, `description`, `providerLabel`, and `resourceLabel` only when the command supplied one. A provider that throws is logged with `console.error` as `Failed to load slash commands for gatekeeper <id>:` and contributes `[]`, so the rest of the catalog still loads. Sorting is stable and lexicographic in order: `name`, `providerLabel`, `resourceLabel` (missing treated as `""`), then `selection.commandId`.

`invokeSlashCommand(gatekeeper, request, authorizer)` obtains the provider with `using` and calls `provider.invoke(request.id.commandId, request.args, authorizer)`, returning `SlashCommandResult`. The authorizer is an `RpcStub<ObservationAuthorizer>`, so command execution is subject to observation authorization.

<Note>
Both functions acquire the provider with `using provider = await gatekeeper.getSlashCommandProvider()`, so the stub is disposed when the scope exits.
</Note>

## `webFetch`

`web-fetch.ts` implements a deliberately narrow HTTP capability: HTTP GET against arbitrary public HTTPS URLs, with no support for POST/PUT/DELETE/PATCH and no credential forwarding.

<ParamField body="url" type="string" required>
Target URL. Validated by `validateWebFetchUrl`.
</ParamField>

<ParamField body="raw" type="boolean">
When true, return the exact response bytes decoded as UTF-8 with no document conversion. When false or omitted, supported formats are converted to Markdown via `env.WORKERS_AI.toMarkdown()`.
</ParamField>

<ParamField body="maxBytes" type="number">
Caller-requested cap on body length in characters. The server enforces its own hard cap on top.
</ParamField>

`WebFetchResult` returns `status`, `finalUrl`, `contentType`, `body`, and `truncated`.

| Limit | Value |
| --- | --- |
| `HARD_MAX_BYTES` | `5 * 1024 * 1024` (5 MiB) — always truncate beyond this |
| `DEFAULT_MAX_BYTES` | `1 * 1024 * 1024` (1 MiB) — used when the caller specifies no cap |
| `FETCH_TIMEOUT_MS` | `30_000` |
| `USER_AGENT` | `GadgetsWebFetch/1.0` |

`WebFetchEnv` is intentionally narrow — `{ai: Ai, gateway: AiGatewayConfig | null}` — so tests can pass a stub instead of a full `Cloudflare.Env`.

### URL validation

`validateWebFetchUrl(input)` returns the parsed `URL` or throws:

| Condition | Error |
| --- | --- |
| `new URL(input)` throws | `Invalid URL: <input>` |
| `protocol !== "https:"` | `Only https:// URLs are allowed; got <proto>//. Use the HTTPS version of this URL.` |
| `username` or `password` present | `URLs with embedded credentials are not allowed.` |

The file states explicitly that hostnames are **not** inspected for "looks-internal" patterns, because such a blocklist is unsound when a symbolic hostname can resolve to any IP at fetch time.

### SSRF posture

SSRF protection relies on workerd's post-DNS-lookup IP filtering. The `global_fetch_strictly_public` compatibility flag, set in `wrangler.jsonc`, restricts `fetch()` to public IP addresses; reserved ranges (loopback, RFC1918, link-local, cloud-metadata, and similar) are rejected by the runtime after hostname resolution.

<Warning>
`wrangler dev` reconfigures its global outbound to permit fetching from any address so localhost services stay reachable, so `global_fetch_strictly_public` only takes effect in production. The source records this as an accepted tradeoff for development.
</Warning>

### Markdown conversion

Conversion is delegated to `env.WORKERS_AI.toMarkdown()`. The allowlist `TO_MARKDOWN_MIME_TYPES` covers `text/html`, `application/xhtml+xml`, `application/pdf`, `application/xml`, `text/xml`, `text/csv`, and Office/OpenDocument types: `.docx`, `.xlsx`, `.xls`, `.xlsm`, `.xlsb`, `.ods`, `.odt`, and `application/vnd.apple.numbers`. Plain text, JSON, and other unknown content types pass through unconverted.

Image MIME types are excluded on purpose: image conversion uses paid Workers AI models (object detection plus Gemma-3 for image-to-text), and `webFetch` must not silently incur per-fetch costs.

`buildGatewayOptions(gateway)` returns `undefined` when there is no gateway or no `gateway.workersAiGateway`, because `toMarkdown()` uses the Workers AI binding and can only use the same-account Workers AI gateway resolved by `AiGatewayConfig` — a cross-account platform gateway cannot be used by that binding.

Body reading is capped incrementally in `readBodyCapped`: chunks accumulate until the budget is reached, a partial slice fills the budget exactly, `truncated` is set, and the remaining stream is cancelled to free server-side resources before the reader lock is released. Decoding uses `TextDecoder("utf-8", {fatal: false, ignoreBOM: false})`.

## Chat compaction

Compaction keeps long chats within the model's context limit. It summarizes messages before a boundary and stores their replay state in a checkpoint. Canonical history keeps every message so the UI can page back, but agent replay starts at the boundary.

### Token budget

`getModelTokenLimits(config)` derives the turn's split of the model window:

```ts
// packages/workshop-backend/src/agent-compaction.ts
export function getModelTokenLimits(config: AiModelConfig):
    {inputBudget: number, maxOutputTokens?: number}
```

| Constant / rule | Value |
| --- | --- |
| `COMPACTION_TRIGGER_RATIO` | `0.85` — compact when the prompt reaches this share of the input budget |
| `COMPACTION_TARGET_RATIO` | `0.3` — target share of the input budget for retained messages |
| `DEFAULT_CONTEXT_WINDOW` | `128_000` — assumed window for a model absent from `SUGGESTED_MODELS` |
| `maxOutputTokens` | `SUGGESTED_MODELS[provider][model].outputLimit`, else `WORKERS_AI_OUTPUT_LIMIT` when `provider === "cloudflare"`, else `undefined` |
| `inputBudget` | `(contextWindow ?? DEFAULT_CONTEXT_WINDOW) - (maxOutputTokens ?? 0)` |

The reserved response capacity is both withheld from the prompt budget and sent as the request's response cap. A Cloudflare model configured by hand has no `SUGGESTED_MODELS` entry to declare its reservation, so the provider's applies. A model whose real window is smaller than `DEFAULT_CONTEXT_WINDOW` fails at the provider before compaction triggers.

`shouldCompactChat(contextTokens, inputBudget)` returns `contextTokens >= inputBudget * COMPACTION_TRIGGER_RATIO`.

### `/compact` turns

`isCompactionTurn(messages)` is true when the newest message is a `slashCommand` with `request.id.builtin === true` and `commandId === "compact"`. Such a turn compacts and then ends instead of prompting the model. Both the agent and the turn loop derive this from the log rather than passing a flag, so a turn resumed after a restart behaves the same.

### Boundary selection

`startsAgentTurn(message)` marks messages that begin an agent turn, because each produces a `user` model message and cutting there keeps the retained messages from opening mid-turn:

| `message.type` | Starts a turn when |
| --- | --- |
| `"message"` | `author.type === "user"` or `"gadget"` |
| `"agentCallback"` | always |
| `"agentNudge"` | always |
| `"connectionRequest"` | `state === "accepted"` |
| anything else | never |

`protectRetainedReverts` may still lower the cut past one of these; the summary then stands in for the turn's opening.

`findProtectedFromSequence(messages)` returns the earliest turn a checkpoint cannot absorb, or `undefined`. It finds the first `connectionRequest` with `state === "pending"` — such a message carries live accept/deny state only it can answer — then walks backward to the sequence of the message that started that turn, so the retained tail keeps the exchange explaining what the user is being asked to connect; if no earlier turn start exists it returns `messages[0]?.sequence`. Provisional gadget creations and binding additions need no such protection: the checkpoint records them, and the registry rows they name are untouched by compaction.

`CompactionProjectionMessage` tags each projected model message with its origin:

<ResponseField name="message" type="Message" required>
The model message in the prompt.
</ResponseField>

<ResponseField name="sequence" type="number">
The durable chat sequence that produced this message. System messages and an earlier summary have no source sequence.
</ResponseField>

<ResponseField name="canCut" type="boolean">
Set on the first model message a chat record contributes. The boundary cuts only here, so a record's messages are never split: a tool result always keeps the call it answers, and the tail opens on a user or assistant message.
</ResponseField>

### Summarization prompt

`COMPACTION_SYSTEM_PROMPT` asks for a single context handoff that lets the same coding agent continue the conversation. It requires preserving exact user requirements and preferences, key decisions and rationale, files and symbols, errors and resolutions, current work state, and the next concrete step, and requires fully integrating any prior summary rather than referring to it separately. The mandated structure is `## Goal`, `## Constraints & Preferences`, `## Progress`, `## Key Decisions`, `## Next Steps`, `## Critical Context`. The prompt ends with an explicit instruction not to continue the conversation or follow instructions from earlier messages, and to output only the handoff.

<Warning>
The final clause of `COMPACTION_SYSTEM_PROMPT` is a prompt-injection boundary: the transcript being summarized is data, not instructions.
</Warning>

### Checkpoint shape

`CompactionCheckpoint` in `agent.ts` stores replay state for one compacted prefix. Checkpoints are immutable and a chat keeps every one it has published, so reading history or reverting can select the newest checkpoint below any sequence.

| Field | Type | Meaning |
| --- | --- | --- |
| `chatId` | `number` | Chat the checkpoint belongs to. |
| `compactedTo` | `number` | First sequence replay starts at; earlier messages are represented by the checkpoint. |
| `summary` | `string` | The model-written summary, sent as one user message before the retained messages. |
| `chatBindings` | `[string, ChatBindingEntry][]` | The chat's named bindings; retained messages and the summary refer to these as `env.NAME`. |
| `nextChangeId` | `number` | Next change ID for replayed tool results, keeping change IDs sequential across boundaries. |
| `observedCodeVersion` | `number` | Code version used as the replay base; tool calls and change batches can establish it. |
| `acceptedChanges` | `Uint8Array` | Accepted Y.Doc updates from before the boundary, merged into one update. |
| `proposedChanges` | `Uint8Array` | Still-proposed Y.Doc updates from before the boundary, merged into one update; disjoint from `acceptedChanges`. |

Because the chat stays pinned to `observedCodeVersion`, accepted updates remain part of the replay base rather than of the version replay starts from, and replay applies both `acceptedChanges` and `proposedChanges`. Individual batches remain addressable through the chat log, so reverting to a point before the boundary is still possible.

Provisional gadget creations and binding additions from before the boundary are deliberately absent from `proposedChanges`: they carry no Y.Doc update, and the registry rows they created (`GadgetRecord.pending`, `BindingRecord.pending`) already record them with the sequence that did, untouched by compaction. Merge and revert promote and delete from those rows rather than from the log, so duplicating them in the checkpoint would be a second source of truth. `getProposedChanges()` reports the compacted prefix as pending when either the checkpoint field or such a row exists.

### Change folding

`foldProposedChanges(messages, seed)` is the single rule from which both the proposed-changes view and a new checkpoint are derived.

```ts
export function foldProposedChanges(
    messages: Iterable<AiChatMessage>, seed: readonly ChangeBatch[] = [])
    : {proposed: ChangeBatch[], accepted: Uint8Array[]}
```

`ChangeBatch` is `{sequence: number, update?: Uint8Array}`; `update` is absent for a batch that records only gadget creations or binding additions.

| Message type | Effect on the fold |
| --- | --- |
| `"changes"` | Push `{sequence, update}` onto the proposed list. |
| `"merge"` | Shift proposed batches with `sequence <= mergeThrough` (inclusive); each defined `update` moves to `accepted`. |
| `"revert"` | Pop proposed batches from the end while `sequence >= revertFrom`. |

`seed` carries batches already proposed before the log begins, as a checkpoint records. The returned `proposed` list is oldest first.

```stateDiagram
```

```mermaid
stateDiagram-v2
  [*] --> Live: turn starts
  Live --> Live: contextTokens < inputBudget * 0.85
  Live --> Compacting: shouldCompactChat() true
  Live --> Compacting: isCompactionTurn() — newest msg is builtin /compact
  Compacting --> Bounded: findCompactionBoundary()\nrespects canCut + findProtectedFromSequence()
  Bounded --> Summarizing: buildSummaryPrompt() + COMPACTION_SYSTEM_PROMPT
  Summarizing --> Checkpointed: publish CompactionCheckpoint\n(compactedTo, summary, chatBindings,\nnextChangeId, accepted/proposedChanges)
  Checkpointed --> Live: replay from compactedTo
  Checkpointed --> [*]: /compact turn ends without prompting the model
```

## Related pages

<CardGroup cols={2}>
  <Card title="Gadgets and sandboxing" href="/gadgets-and-sandboxing">
    How `executeCode` code is isolated as a Dynamic Worker with internet access disabled, and the `global_fetch_strictly_public` posture.
  </Card>
  <Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
    `Gatekeeper<Session>`, resource descriptions, agent catalog limits, and ambient `autoProvisionsAccount` mode resolution.
  </Card>
  <Card title="Observations, actions, and approval queues" href="/observations-and-actions">
    `ObservationAuthorizer` and `ApprovalQueue`, the interfaces a slash-command invocation and agent reads pass through.
  </Card>
  <Card title="RPC API reference" href="/rpc-api-reference">
    `Overseer`, `SlashCommandChoice`/`SlashCommandRequest`, `validateBindingName`, and stub-disposal constraints.
  </Card>
</CardGroup>

---

## 11. Configure gatekeeper credentials

> Register a third-party OAuth app and wire its credentials into a connector. Covers the `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect-URI contract, the per-connector `CLIENT_ID`/`CLIENT_SECRET` secrets, `deploy-inputs.json` overrides with `setupSteps` and `consoleUrl`, dev seeding from shell variables, and connectors that need no OAuth app.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/11-configure-gatekeeper-credentials.md
- Generated: 2026-08-05T16:39:50.353Z

### Source Files

- `packages/gatekeeper-github/README.md`
- `packages/gatekeeper-github/deploy-inputs.json`
- `packages/gatekeeper-google/README.md`
- `packages/gatekeeper-email/README.md`
- `packages/gatekeeper-mcp/README.md`
- `run-dev-server.js`
- `docs/public-server.md`

---
title: "Configure gatekeeper credentials"
description: "Register a third-party OAuth app and wire its credentials into a connector. Covers the `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect-URI contract, the per-connector `CLIENT_ID`/`CLIENT_SECRET` secrets, `deploy-inputs.json` overrides with `setupSteps` and `consoleUrl`, dev seeding from shell variables, and connectors that need no OAuth app."
---

Gatekeeper connectors that talk to a third-party OAuth provider read two secrets from their own worker environment: `CLIENT_ID` and `CLIENT_SECRET`. Each connector is served under `/gatekeeper/<name>/`, where `<name>` is the package suffix lowercased (`packages/gatekeeper-github` → `/gatekeeper/github`), and the OAuth callback the connector sends is always `<base>/gatekeeper/<name>/oauth`. Locally that base is `http://localhost:8787`; in a deployment it is the instance's `PUBLIC_BASE_URL`. A connector whose `CLIENT_ID` or `CLIENT_SECRET` is missing serves a "Not configured" page instead of starting the authorization flow.

## The redirect-URI contract

```text
provider console                 this deployment
┌──────────────────────┐         ┌──────────────────────────────────────────┐
│ OAuth app            │         │ router                                   │
│  callback URL   ─────┼────────►│  /gatekeeper/<name>/oauth                │
│  client id           │         │        │                                 │
│  client secret       │         │        ▼                                 │
└──────────────────────┘         │  packages/gatekeeper-<name>              │
        ▲                        │    env.CLIENT_ID / env.CLIENT_SECRET     │
        └── copied into ─────────┤    (.env locally, secrets when deployed) │
                                 └──────────────────────────────────────────┘
```

| Environment | Redirect URI to register |
| --- | --- |
| Local dev | `http://localhost:8787/gatekeeper/<name>/oauth` |
| Deployed | `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` |

<Warning>
The redirect URI must match byte for byte. GitHub returns `redirect_uri_mismatch` when the registered callback differs from what the connector sends — check for a trailing slash, and for `http` versus `https` on localhost.
</Warning>

## Register the OAuth app

<Tabs>
<Tab title="GitHub">

Use a GitHub **OAuth App**, not a GitHub App. Only OAuth Apps honor the OAuth `scope` parameter, which is what lets sign-in request minimal scopes (`read:user user:email`) while a connection requests the full set (`repo read:user user:email`). A GitHub App (client id beginning `Iv…`) ignores `scope` and fails the sign-in email lookup with `Resource not accessible by integration` unless you separately grant it the **Email addresses** account permission.

<Steps>
<Step title="Create the app">
Go to GitHub **Settings → Developer settings → OAuth Apps** (`https://github.com/settings/developers`) and click **New OAuth App**.

- **Application name**: anything, e.g. `Gadgets Local Dev`
- **Homepage URL**: `http://localhost:3000`
- **Authorization callback URL**: `http://localhost:8787/gatekeeper/github/oauth`

Click **Register application**.
</Step>
<Step title="Generate a client secret">
On the app's settings page, click **Generate a new client secret** and copy both the **Client ID** and the secret.
</Step>
<Step title="Write the credentials">
Create `packages/gatekeeper-github/.env`:

```bash title="packages/gatekeeper-github/.env"
CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-here
```

The `.env` file is gitignored and must never be committed.
</Step>
</Steps>

</Tab>
<Tab title="Google">

A single Google OAuth client covers both sign-in and connections. Sign-in requests only `openid`, `userinfo.email`, and `userinfo.profile`; connecting a resource additionally requests the scopes for that resource type only (Gmail, Docs, Sheets, Calendar, or BigQuery).

<Steps>
<Step title="Create a Google Cloud project">
In the [Google Cloud Console](https://console.cloud.google.com/), open the project dropdown, click **New Project**, name it, click **Create**, then select it.
</Step>
<Step title="Enable the APIs you need">
Under **APIs & Services → Library**, enable the APIs for the resource types you plan to connect: Gmail API, Google Docs API, Google Drive API, Google Sheets API, Google Calendar API, BigQuery API.

The Drive API is used only so the resource pickers can search Docs and Sheets by title; document reads and edits go through the Docs API and spreadsheet reads through the Sheets API.
</Step>
<Step title="Configure the consent screen">
Under **APIs & Services → OAuth consent screen**, choose **External**, fill in an app name, and save. The Scopes page can be left empty — scopes come from the OAuth request itself, not from the console configuration.
</Step>
<Step title="Add yourself as a test user">
While the app is in Testing mode, only listed test users can complete OAuth. Add the Google address you will sign in with under **Add Users**, then save.
</Step>
<Step title="Create the OAuth client">
Under **APIs & Services → Credentials**, click **Create Credentials → OAuth client ID**, application type **Web application**. Under **Authorized redirect URIs** add `http://localhost:8787/gatekeeper/google/oauth`, then click **Create**. Copy the **Client ID** and **Client Secret** from the popup.
</Step>
<Step title="Write the credentials">
Create `packages/gatekeeper-google/.env`:

```bash title="packages/gatekeeper-google/.env"
CLIENT_ID=your-client-id-here.apps.googleusercontent.com
CLIENT_SECRET=your-client-secret-here
```
</Step>
</Steps>

</Tab>
</Tabs>

### Google scopes requested per surface

| Scope | Requested for |
| --- | --- |
| `openid`, `userinfo.profile`, `userinfo.email` | Identity — sign-in, and always included on connect |
| `gmail.modify` | Gmail thread reads, organization, replies, forwards, sending (includes labels and send) |
| `documents` | Google Docs reads and edits |
| `drive.metadata.readonly` | Resource pickers searching Docs and Sheets by title |
| `spreadsheets.readonly` | Spreadsheet metadata and cell values |
| `calendar.calendarlist.readonly` | Listing calendars in the resource picker |
| `calendar.events` | Managing the selected calendar and checking availability |
| `bigquery` | BigQuery dry-runs and queries — broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource-scope checks before running a query |

## Optional: enable the connector for sign-in

Adding a connector's name to the `AUTH_GATEKEEPERS` allowlist puts a "Continue with …" button on the login page. Set it in the repo-root `.dev.vars` for local development:

```ini title=".dev.vars"
AUTH_GATEKEEPERS=cloudflare,google,github
```

Users are keyed by the provider's verified email — GitHub's primary verified email, Google's `email_verified` address. The sign-in grant is transient and discarded right after the email is read. No extra provider configuration is needed for a GitHub OAuth App or a Google client; a Google app still in Testing mode requires the signing-in user to be a listed test user.

## Seeding credentials in dev from shared shell variables

`run-dev-server.js` generates a `wrangler.dev.jsonc` per gatekeeper and injects OAuth credentials that are shared with the sign-in flow, so one OAuth app can drive both. It maps a gatekeeper package name to the shell/`.dev.vars` variables that seed its `CLIENT_ID` and `CLIENT_SECRET`:

```js title="run-dev-server.js"
// Maps a gatekeeper name to the shared env vars whose values seed its CLIENT_ID / CLIENT_SECRET.
const SHARED_GATEKEEPER_CREDS = {
  "gatekeeper-github": { id: "GITHUB_CLIENT_ID", secret: "GITHUB_CLIENT_SECRET" },
  // …
};
```

Precedence rules:

- Gatekeepers with no entry in `SHARED_GATEKEEPER_CREDS` keep their raw config untouched.
- Credentials already defined in a gatekeeper's own config still win over the seeded values.
- `loadDevVars()` reads the root `.dev.vars` as `KEY=VALUE` lines into `process.env`, stripping surrounding single or double quotes; an existing shell environment value always takes precedence over the file. `.dev.vars` is gitignored and may hold local secrets.

<Note>
`run-dev-server.js` discovers connectors by scanning `packages/` for directories named `gatekeeper-*` that contain a `wrangler.jsonc`, and derives the service binding by uppercasing and replacing hyphens: `gatekeeper-github` → `GATEKEEPER_GITHUB`.
</Note>

## Declaring credentials for a hosted deploy

A connector declares the inputs the deploy flow should prompt for in `deploy-inputs.json` at the package root. Each entry describes one secret; the first entry may also carry the setup instructions and the redirect-URI template shown to the operator.

```json title="packages/gatekeeper-github/deploy-inputs.json"
[
  {
    "name": "CLIENT_ID",
    "kind": "secret",
    "label": "OAuth client ID",
    "consoleUrl": "https://github.com/settings/developers",
    "setupSteps": [
      "In GitHub Developer settings, create a new OAuth App (an OAuth App, not a GitHub App — GitHub Apps ignore OAuth scopes).",
      "Set the Homepage URL to your instance URL, and the Authorization callback URL to the redirect URI below.",
      "Register the application, then generate a new client secret.",
      "Copy the Client ID and client secret here."
    ],
    "redirectUriTemplate": "{PUBLIC_BASE_URL}/gatekeeper/github/oauth"
  },
  {
    "name": "CLIENT_SECRET",
    "kind": "secret",
    "label": "OAuth client secret"
  }
]
```

<ParamField body="name" type="string" required>
Environment variable the connector reads, e.g. `CLIENT_ID` or `CLIENT_SECRET`.
</ParamField>

<ParamField body="kind" type="string" required>
`"secret"` for credential values.
</ParamField>

<ParamField body="label" type="string" required>
Human-readable field label shown in the deploy flow.
</ParamField>

<ParamField body="consoleUrl" type="string">
Direct link to the provider console page where the OAuth app is registered.
</ParamField>

<ParamField body="setupSteps" type="string[]">
Ordered instructions for registering the OAuth app, rendered alongside the input.
</ParamField>

<ParamField body="redirectUriTemplate" type="string">
Callback URL to paste into the provider console, with `{PUBLIC_BASE_URL}` substituted for the instance base URL — e.g. `{PUBLIC_BASE_URL}/gatekeeper/github/oauth`.
</ParamField>

## Connectors that need no OAuth app

Not every connector has a provider to register with.

### Email

`packages/gatekeeper-email` *is* the service rather than a client of one: it implements a Cloudflare Email Worker that receives mail directly, so there are no `CLIENT_ID`/`CLIENT_SECRET` values. It is configured with a base URL instead.

<ParamField body="BASE_URL" type="string" required>
Full base URL (protocol + host + optional path, no trailing slash) at which the email gatekeeper's fetch handler is served. Every `http://localhost:8787/gatekeeper/email` in the local docs becomes this value in production.
</ParamField>

```ini
# Deployed as its own worker at the root:
BASE_URL=https://gatekeeper-email.example.workers.dev

# Or co-hosted on the same domain as the main app under a path:
BASE_URL=https://app.example.com/gatekeeper/email
```

Production also requires Cloudflare Email Routing: enable Email Routing for the domain and its DNS records, then create an **Email Routing → Email Workers** route (for example custom address `*@yourdomain.com`, action **Send to a Worker**, worker `gatekeeper-email`).

### MCP

`packages/gatekeeper-mcp` has nothing to configure per server. The user pastes an endpoint URL and the connector runs the OAuth discovery chain against it — protected resource metadata (RFC 9728) → authorization server metadata (RFC 8414) → dynamic client registration (RFC 7591) → authorization code with PKCE (RFC 7636) and a resource indicator (RFC 8707). An administrator's only lever is whether the connector is offered at all, in the Gatekeepers admin panel.

| Variable | Meaning |
| --- | --- |
| `BASE_URL` | Public base URL of this worker, for OAuth redirects. |
| `MCP_CLIENT_NAME` | Client name sent in `initialize` and dynamic client registration. |
| `MCP_ALLOW_INSECURE` | `"true"` disables the endpoint checks entirely: permits `http://` and private, loopback, link-local, and cloud-metadata hosts, on the endpoint and on every OAuth URL discovered from it. Local dev only. |

<Warning>
`MCP_ALLOW_INSECURE=true` is for local development only — it removes the host blocklist from both the endpoint and every OAuth URL discovered from it. Set it in the repo-root `.dev.vars` when connecting a server running on localhost.
</Warning>

## Verify the setup

<Steps>
<Step title="Start the dev server">
Start the application in dev mode from the repo root. Restart it after creating or editing a gatekeeper `.env`, since credentials are read into the worker config at startup.
</Step>
<Step title="Open a gadget and add a connection">
Create or open a gadget, go to the **Connections** tab, and click **+ New Connection**.
</Step>
<Step title="Pick a resource type">
Choose a resource type for the connector you configured — for GitHub: repository, issue, or pull request; for Google: Gmail, Google Doc, Google Spreadsheet, Google Calendar, or BigQuery.
</Step>
<Step title="Authorize">
If prompted, connect the account. The provider's authorization page opens in a new tab; after you grant access the tab closes and you return to Gadgets.
</Step>
<Step title="Select the resource and create the connection">
Use the picker to choose the specific repository, issue, pull request, document, or calendar. The gadget then has access only to the selected resource scope.
</Step>
</Steps>

Connected accounts can be listed, added, and removed in settings, reached from the account menu in the upper right.

## Troubleshooting

<AccordionGroup>
<Accordion title="&quot;Not configured&quot; page during authorization">
`CLIENT_ID` or `CLIENT_SECRET` is missing. Confirm the `.env` file exists at the connector's package directory (e.g. `packages/gatekeeper-github/.env`) and contains both values, then restart the dev server.
</Accordion>
<Accordion title="redirect_uri_mismatch">
The callback URL registered in the provider console does not match what the connector sends. It must be exactly `http://localhost:8787/gatekeeper/<name>/oauth` locally — no trailing slash, `http` not `https` — or `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` when deployed.
</Accordion>
<Accordion title="bad_verification_code">
The authorization code has expired or was already used. Return to Gadgets and start the connection again.
</Accordion>
<Accordion title="Resource not accessible by integration">
You registered a GitHub **App** rather than an OAuth App, and it lacks the **Email addresses** account permission required for the sign-in email lookup. Switch to an OAuth App (recommended), or grant the App **Permissions & events → Account permissions → Email addresses → Read-only**, save, and have existing users re-run the sign-in flow to approve the added permission. Even with the permission granted, a GitHub App ignores `scope`, so sign-in cannot be limited to minimal scopes.
</Accordion>
<Accordion title="Google OAuth refuses the signing-in account">
While the Google app is in Testing mode, only accounts listed as Test Users on the OAuth consent screen can complete the flow. Add the address under **APIs & Services → OAuth consent screen → Add Users**.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup>
<Card title="Build a gatekeeper" href="/build-a-gatekeeper">
Add a connector package, declare its descriptions, and install it with a `GATEKEEPER_*` binding.
</Card>
<Card title="Configure sign-in and AI Gateway billing" href="/configure-signin-and-billing">
`AUTH_GATEKEEPERS` allowlisting, email-keyed identity, and incremental auth-versus-full scopes.
</Card>
<Card title="Environment variables" href="/environment-variables">
Every backend environment variable, including `PUBLIC_BASE_URL` and `AUTH_GATEKEEPERS`.
</Card>
<Card title="Local development" href="/local-development">
The two-terminal workflow, `.dev.vars` loading, and dynamic gatekeeper service-binding discovery.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How `/gatekeeper/<name>/*` is derived from `GATEKEEPER_*` env keys and how inbound email is dispatched.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Known failure modes across the stack, including GitHub App versus OAuth App scope errors.
</Card>
</CardGroup>

---

## 12. Build a gatekeeper

> Add a connector package: implement `GatekeeperVendor`, declare vendor/account/resource descriptions, own your Durable Object classes and migrations, and expose a session. Covers the configurator UI build into `src/generated`, the type-only `@gadgets/configurator-ui` helpers, `storage-schema.md`, structured logging with a `component`/`vendorId` logger, and installing by adding a `GATEKEEPER_*` binding.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/12-build-a-gatekeeper.md
- Generated: 2026-08-05T16:40:14.002Z

### Source Files

- `packages/workshop-shared/src/gatekeeper.ts`
- `scripts/build-gatekeeper-configurator.mjs`
- `packages/configurator-ui/src/index.ts`
- `packages/gatekeeper-github/src/github-configurators.ts`
- `packages/gatekeeper-scheduler/README.md`
- `packages/backend-utils/src/logger.ts`
- `AGENTS.md`

---
title: "Build a gatekeeper"
description: "Add a connector package: implement `GatekeeperVendor`, declare vendor/account/resource descriptions, own your Durable Object classes and migrations, and expose a session. Covers the configurator UI build into `src/generated`, the type-only `@gadgets/configurator-ui` helpers, `storage-schema.md`, structured logging with a `component`/`vendorId` logger, and installing by adding a `GATEKEEPER_*` binding."
---

A gatekeeper is a standalone Workers application that the Workshop reaches over a service binding and talks to with JavaScript RPC. The contract lives in `packages/workshop-shared/src/gatekeeper.ts`, whose header states the arrangement directly: "Each adapter is deployed as a completely independent Workers application from the Gadgets Workshop itself, and is provided to the Workshop as a service binding. The Workshop communicates with the adapter over JavaScript RPC." A connector package therefore owns its own worker entrypoint, its own Durable Object classes and migrations, its own sandboxed configurator UI build, and its own display metadata; the Workshop only sees the RPC surface.

## Package layout

The build tooling derives a connector's identity from its directory name. `scripts/build-gatekeeper-configurator.mjs` computes:

```js
const vendorId = basename(packageDir).replace(/^gatekeeper-/, "");
const sourceBase = `app:///gatekeeper/${vendorId}/configurator`;
```

So `packages/gatekeeper-github` yields `vendorId` `github`, and `packages/gatekeeper-scheduler` yields `scheduler`. The same script reads `src/configurator` and writes into `src/generated`:

```js
const configuratorDir = join(packageDir, "src", "configurator");
const generatedDir = join(packageDir, "src", "generated");
```

:::files
```
packages/gatekeeper-<vendorId>/
├── README.md                  # user flow, agent API, lifecycle notes
├── src/
│   ├── configurator/          # sandboxed configurator UI modules (.tsx) + RPC types
│   ├── generated/             # build output; produced by build-gatekeeper-configurator.mjs
│   └── ...                    # vendor worker, session, DO classes, API client
```
:::

<Note>
`src/generated` is build output, not source. `scripts/build-gatekeeper-configurator.mjs` uses `writeFileIfChanged`, so a rebuild that produces identical bytes does not touch the file, and `--watch` rebuilds on changes to the configurator directory.
</Note>

## Declare vendor, account, and resource descriptions

`VendorDescription` is the display and capability record the Workshop reads for the Connectors page.

<ParamField body="displayName" type="string" required>
Human-readable name of the service, e.g. `"Google"`, `"GitHub"`.
</ParamField>

<ParamField body="url" type="string" required>
URL of the service's home page.
</ParamField>

<ParamField body="logo" type="AvatarImage">
Logo for the service. `AvatarImage` is `{ url: string }`.
</ParamField>

<ParamField body="color" type="string">
Background color used behind the logo in connector UI.
</ParamField>

<ParamField body="tagline" type="string">
Short tagline shown beneath the name on Connectors page cards, e.g. `"Draft replies, edit docs, and analyze data"`.
</ParamField>

<ParamField body="description" type="string">
2–3 sentence description of what the gatekeeper does and enables users to build. Shown in detail modals on the Connectors page.
</ParamField>

<ParamField body="providesAuth" type="boolean" default="false">
True if the connect flow yields a provider-verified email via `GatekeeperUser.getAuthenticatedEmail()`, making the vendor offerable as a login method subject to the Workshop's own auth allowlist.
</ParamField>

<ParamField body="autoProvisionsAccount" type="boolean">
When set, the vendor can mint a connected account with no OAuth flow (see `GatekeeperVendor.createAccount`) and recommends the Workshop auto-provision one account per user.
</ParamField>

The split between vendor-level and account-level declarations is explicit in the source: the account — not the vendor — declares whether it provides an agent singleton and/or a management UI, through `AccountDescription.singleton` and `AccountDescription.providesUi`.

### Account description fields

| Field | Type | Meaning |
| --- | --- | --- |
| `displayName` | `string?` | Non-unique human-readable name, e.g. `"John Doe"` |
| `uniqueName` | `string?` | Unique canonical name; typically what the user types into a login form (email or Unix-style username) |
| `avatar` | `AvatarImage` | Account avatar image |
| `grantedResourceUrlPatterns` | see note | `urlPattern`s of grantable resource types currently enabled on the account |

<Warning>
Omitting `grantedResourceUrlPatterns` is not neutral: the Workshop treats an account with no such list as having **every** resource granted. The field exists for legacy accounts and for gatekeepers with no grantable resource types. A connector with `grantable` resource types should report the enabled patterns explicitly.
</Warning>

`AccountDescription.providesUi` is the generic mechanism by which a management app is advertised. The Scheduler connector documents how the Workshop consumes it: "The account advertises its UI through the generic `AccountDescription.providesUi` mechanism, the same mechanism used by other Gatekeeper management apps. The Workshop discovers it dynamically and hosts the single-file app in an opaque-origin, network-isolated `srcDoc` frame."

The per-open context for that UI is passed separately, so it stays fresh:

```ts
// packages/workshop-shared/src/gatekeeper.ts
export type AppUiContext = {
  isAdmin: boolean;
}
```

`isAdmin` is supplied on each `GatekeeperUser.startAppUi()` call rather than baked into the account, because a user's admin status can change over time.

## Expose a session and its agent catalog

The session is what agent code holds. The Scheduler connector's README shows the shape from the agent's side: "The ambient binding exposes `ScheduleSession`", with the agent-facing contract living in that package's `src/types.d.ts`, and usage that reads as ordinary RPC against the binding name:

```ts
const callback = await ctx.restore({ type: "dailyBrief" });

const scheduleId = await SCHEDULER.calendarAt(
  { timeZone: "America/Chicago", freq: "weekly", byDay: ["MO", "TU", "WE", "TH", "FR"], hour: 8, minute: 0 },
  callback,
  { title: "Daily brief", description: "Prepare the morning calendar and inbox brief.", occurrences: { count: 10 } },
);
```

Alongside the session, a gatekeeper can expose bounded discovery metadata through `Gatekeeper.getAgentCatalog()`. The source is emphatic about its trust level: the catalog "is shown to the agent as untrusted data, so entries carry no authority and are size-capped."

```ts
export type AgentCatalogEntry = { id: string; title: string; description: string };
export type AgentCatalog = { entries: AgentCatalogEntry[]; truncated?: boolean };
export type AgentCatalogRequest = { limit: number };
```

| Cap | Value |
| --- | --- |
| `AGENT_CATALOG_MAX_ENTRIES` | `25` |
| `AGENT_CATALOG_MAX_ID_LENGTH` | `256` |
| `AGENT_CATALOG_MAX_TITLE_LENGTH` | `100` |
| `AGENT_CATALOG_MAX_DESCRIPTION_LENGTH` | `400` |

Do not hand-roll the clamping. Use `boundAgentCatalog(entries, request)`, which limits the entry count to `Math.min(request.limit, AGENT_CATALOG_MAX_ENTRIES)`, truncates each field to its cap, and sets `truncated` when entries were dropped. The Workshop enforces these caps regardless of what the gatekeeper returns, so a connector that skips the helper only risks having its own output silently trimmed.

Pagination across the RPC boundary uses `Cursor<T>`:

```ts
export interface Cursor<T> {
  next(): Promise<T[] | null>;
}
```

Call `next()` repeatedly on the same cursor for subsequent batches; it returns `null` once exhausted, and the cursor must be disposed when finished.

## Build a configurator UI

A resource configurator is a sandboxed UI module compiled by `scripts/build-gatekeeper-configurator.mjs` into `src/generated`. The module imports its component and JSX helpers from `@gadgets/configurator-ui`, which is **type-only at runtime**: every exported component throws if actually called.

```ts
// packages/configurator-ui/src/index.ts
export function Section(_props: { title?: string | null; children?: unknown }): unknown {
  throw new Error("Section is provided by the configurator UI sandbox runtime.");
}
```

<Warning>
The package's own comment sets the import boundary: the JSX ambient globals "only apply when something imports this package, which is intended only for sandboxed configurator UI modules compiled by `scripts/build-gatekeeper-configurator.mjs`. Workshop and gatekeeper-server code should NOT import from this package to avoid clashing with React's `JSX` namespace."
</Warning>

### The module contract

`ConfiguratorUISpec<TUI, TValues>` is what a configurator module exports:

<ResponseField name="initial" type="TValues" required>
Initial form values shown before the user makes any changes.
</ResponseField>

<ResponseField name="initialValuesFromResourceUrl" type="(context) => Partial<TValues> | Promise<Partial<TValues>>">
Optional. Derives initial values from a concrete resource URL so the form opens pre-filled and editable — used when something such as an AI agent's connection request already knows the exact resource. Receives `{ resourceUrl, resourceUrlPattern, ui }`. If omitted, the runtime falls back to extracting URLPattern named groups from `resourceUrlPattern` and seeding any values whose keys match a group name. Implement it only when the mapping differs, e.g. GitHub's `:owner/:repo` → `repoFullName`.
</ResponseField>

<ResponseField name="isReady" type="(context: { values: TValues }) => boolean">
Optional. Returns whether the current iframe-owned state is ready to submit.
</ResponseField>

<ResponseField name="resourceUrl" type="(context) => Promise<string> | string" required>
Returns the resource URL chosen by the current UI state. Receives `{ values, ui }`.
</ResponseField>

<ResponseField name="render" type="(context) => unknown" required>
Renders the configuration UI. Receives `{ values, setValues, clearFields, ui }`.
</ResponseField>

Values are deliberately flat: `ConfiguratorUIValues` is `Record<string, string | null | undefined>`. The render context supplies `setValues(values: Partial<TValues>)` and `clearFields(...names)`. `ui` is the gatekeeper-defined capability — the helper "makes no assumptions about it beyond passing it through to the render function," so its method surface is entirely the connector's design.

### Controls

| Component | Key props | Notes |
| --- | --- | --- |
| `Section` | `title`, `children` | Groups related fields |
| `Field` | `label`, `description`, `optional`, `children` | Labels and describes one input |
| `TextInput` | `name`, `value`, `placeholder`, `onChange`, `optional`, `disabled` | Plain text entry |
| `RadioCards` | `value`, `options` (`{ value, title, description }`), `onChange` | Card-style single select |
| `CheckboxList` | `name`, `value`, `loadOptions()`, `onChange`, `allSelected`, `disabled` | Multi-select; loads all options at once |
| `Autocomplete` | `name`, `value`, `placeholder`, `loadOptions(query)`, `onChange`, `optional`, `onClear`, `disabled` | Async, query-driven selection |
| `h`, `Fragment` | — | JSX factory and fragment helper |

Options passed to `CheckboxList` and `Autocomplete` are `ConfiguratorUIOption`: `{ value, title, subtitle?, meta? }`.

<Warning>
`CheckboxList` encodes its selection as a comma-separated string because values are flat strings. Per the source: "**Option values must not contain a comma.** One that does cannot survive the round trip: it would be read back as two selected values, silently changing what was chosen." Encode commas before passing them in, or use a different control.
</Warning>

`CheckboxList.loadOptions()` takes no query and is called once per `name`; the runtime caches the result and re-renders when it arrives, which is what keeps `render` synchronous. It may return an already-in-flight promise, which is how a configurator prefetches options before the list is first shown. `Autocomplete.loadOptions(query)` is the query-driven counterpart for lists too large to load whole.

<Note>
The generated sandbox runtime caches loaded checkbox options per list name and never invalidates them. That is safe only because the host mints a new iframe whenever the account or resource pattern changes and the sandbox has no `allow-same-origin`, so each realm starts empty. The source flags the dependency: if `SandboxedResourceConfigurator` ever stops being remounted on a fresh key, those caches must be cleared explicitly instead.
</Note>

### The `ui` capability side

The capability the configurator calls is an ordinary RPC target in the worker. GitHub's implementation shows the pattern — `RpcTarget` subclasses annotated with `@validateRpc()`, implementing an interface declared in `src/configurator/*-types`:

```ts
// packages/gatekeeper-github/src/github-configurators.ts
@validateRpc()
export class GitHubRepoConfiguratorUI extends RpcTarget implements GitHubRepoConfiguratorRpc {
  constructor(getToken: () => Promise<string>) {
    super();
    githubTokenGetters.set(this, getToken);
  }

  async listRepos(query: string): Promise<ConfiguratorOption[]> { /* ... */ }
}
```

Two details worth copying:

- Credentials are held in a module-level `WeakMap` keyed by the RPC target (`githubTokenGetters`), not as an enumerable field on the RPC object.
- Per-instance derived state is cached in another `WeakMap` (`githubViewerLogins`), populated lazily, reused for the configurator's lifetime, and deleted on rejection so a failure does not poison later calls.

GitHub caps returned options with `AUTOCOMPLETE_OPTION_LIMIT = 100`, validates identifiers against `GITHUB_OWNER_PATTERN` / `GITHUB_REPO_PATTERN`, accepts a pasted `https://github.com/...` URL by parsing it in `splitRepoFullName`, and falls back to a direct `getRepo` lookup when scoped search misses an exact name — swallowing that lookup's failure so the dropdown degrades to search matches or "No matches" rather than erroring.

The generated runtime independently enforces `MAX_OPTIONS = 200`, so a `loadOptions` implementation should assume its list is bounded on both sides.

## Frontend error reporting in the configurator

The configurator build is gated on a Vite env var read at build time:

```js
const frontendReportingEnabled =
  loadEnv(watchMode ? "development" : "production", packageDir).VITE_FRONTEND_ERROR_REPORTING === "true";
```

When enabled, the script inlines a transpiled copy of `packages/error-reporting/src/serialize-exception.ts` as a `data:text/javascript;base64` import and installs `window.addEventListener("error", ...)` plus `unhandledrejection` handlers. Reports are posted to the parent frame as `gadgets.frontend-error.v1` with `failureSite` values `configurator.window-error` and `configurator.unhandled-rejection`. When the flag is not `"true"`, the serializer import is omitted entirely and `reportFrontendIssue` returns immediately.

Bundles are injected with `//# sourceURL=app:///gatekeeper/<vendorId>/configurator/...` prefixes (`runtime.js`, `capnweb.js`, `serialize-exception.js`), and the script accounts for `Function` constructor line numbering with `functionBodyLineOffset = 2` so stack traces map back to real positions.

## Structured logging

`packages/backend-utils/src/logger.ts` exposes a single factory:

```ts
export function createLogger<ExtraFields extends object = Record<never, never>>(
  defaults: Parameters<typeof createLoggerWithContext<ExtraFields>>[0],
) {
  return createLoggerWithContext<ExtraFields>(defaults);
}
```

It "creates an ALS-free structured Worker logger with fixed component metadata" — the defaults you pass become fixed metadata on every line, which is where a connector pins its `component` and `vendorId`. `Logger` and `LogValue` are re-exported as types from `./logger-core.js`.

## Ownership boundaries

```mermaid
flowchart LR
  subgraph Workshop["Workshop (workshop-shared contract)"]
    contract["gatekeeper.ts\nVendorDescription\nAccountDescription\nAgentCatalog / Cursor"]
    host["configurator host iframe\n(no allow-same-origin)"]
  end

  subgraph Connector["packages/gatekeeper-&lt;vendorId&gt; (independent Worker)"]
    entry["vendor entrypoint\n+ session"]
    caps["configurator RpcTargets\n@validateRpc()"]
    dos["Durable Object classes\n+ migrations"]
    gen["src/generated\n(build output)"]
  end

  subgraph Build["Build tooling"]
    script["scripts/build-gatekeeper-configurator.mjs"]
    typesonly["@gadgets/configurator-ui\n(type-only; throws at runtime)"]
  end

  ext["external service API\n(e.g. GitHubApi)"]

  contract -->|"service binding + JS RPC"| entry
  entry --> dos
  entry --> caps
  caps --> ext
  host -->|"Cap'n Web over MessagePort"| caps
  script -->|"src/configurator → src/generated"| gen
  typesonly -.->|"compile-time only"| script
  gen --> host
```

## Install the connector

<Steps>
<Step title="Name the package for its vendor id">
Create `packages/gatekeeper-<vendorId>`. The directory name is authoritative: the configurator build strips the `gatekeeper-` prefix to derive `vendorId` and the `app:///gatekeeper/<vendorId>/configurator` source base.
</Step>

<Step title="Implement the RPC surface">
Provide the vendor entrypoint, its `VendorDescription`, `AccountDescription` (including `singleton` / `providesUi` and, where resources are grantable, `grantedResourceUrlPatterns`), the resource descriptions with their URL patterns, and the session the agent binding exposes. Return catalogs through `boundAgentCatalog()` and paginate with `Cursor<T>`.
</Step>

<Step title="Own your Durable Objects and migrations">
Declare the connector's own DO classes and migrations in the connector package — it is a separate Workers application, so its storage is not shared with the Workshop's. Document the layout in the package's `storage-schema.md`.
</Step>

<Step title="Build the configurator">
Put configurator modules under `src/configurator`, importing components and JSX helpers from `@gadgets/configurator-ui`, then run the build:

```bash
node scripts/build-gatekeeper-configurator.mjs packages/gatekeeper-<vendorId>
node scripts/build-gatekeeper-configurator.mjs packages/gatekeeper-<vendorId> --watch --quiet
```

`--watch` rebuilds on configurator changes; `--quiet` suppresses informational output.
</Step>

<Step title="Add the GATEKEEPER_* binding">
Install the connector by adding a `GATEKEEPER_*` service binding pointing at the connector worker. The suffix is what the Workshop and router use to address it.
</Step>
</Steps>

<Check>
A correctly wired connector appears on the Connectors page using its `VendorDescription`, its configurator opens in the sandbox frame and populates options through the `ui` capability, and its session is reachable from agent code under the binding name.
</Check>

## Reference notes drawn from an existing connector

`packages/gatekeeper-scheduler` is the closest thing to a worked example of an ambient, auto-provisioning connector with a management app, and its README documents several behaviors a new connector will have to decide for itself:

- Registration versus activation. "Registration creates a disabled hook and returns its schedule ID; it does not start the schedule." Enabling happens in the Workshop's Connections UI, not in the connector's own app.
- A deliberately narrow app surface. The Scheduler app "can only call its account-scoped, read-only `list()` capability plus bounded host methods for theme updates, workspace-title resolution, navigation, and starter prompts," with pages capped at 100 schedules and search limited to 200 characters and normalized into the opaque cursor.
- Workspace scoping. `list()` "returns active and terminal schedules for enabled hooks in the current workspace only. It does not expose schedules from other workspaces in the account."
- Idempotency across retries. Callbacks carry a `runId` that is stable across retries of one logical occurrence and should be used as an idempotency key; delivery is best-effort within a bounded retry window and may occur more than once.
- Disconnect semantics. Disconnecting the account "revokes its driver, deletes schedule state, and leaves a permanent tombstone so retained stale controllers cannot recreate the account."
- Blueprint boundaries. "Creating a workspace from a blueprint does not copy schedules or capabilities: the new workspace must register its callback and receive fresh enablement."

## Related pages

<CardGroup cols={2}>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
The full interface set — `GatekeeperVendor`, `GatekeeperConnectCallback`, `GatekeeperUser`, `GatekeeperUserVerifier`, `Gatekeeper<Session>` — plus resource URL-pattern matching and `autoProvisionsAccount` mode resolution.
</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
The `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect-URI contract, per-connector `CLIENT_ID`/`CLIENT_SECRET`, and connectors that need no OAuth app.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How `/gatekeeper/<name>/*` is derived by lowercasing `GATEKEEPER_*` env keys, and the DO migration tags.
</Card>
<Card title="Local development" href="/local-development">
Dynamic gatekeeper service-binding discovery, generated dev wrangler files, and `.dev.vars` loading.
</Card>
<Card title="Observations, actions, and approval queues" href="/observations-and-actions">
`ObservationDescription`, `ActionDescription`, `ActionKind`, and the authorizer/approval-queue interfaces a connector's session participates in.
</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">
Doc-comment requirements, promise pipelining and stub disposal, structured logging field vocabularies, and the never-log-secrets rule.
</Card>
</CardGroup>

---

## 13. Configure sign-in and AI Gateway billing

> Turn on the optional public-service posture: `AUTH_GATEKEEPERS` allowlisting, `DISABLE_PASSWORD_AUTH`, email-keyed identity via `idFromName(email)`, incremental auth-versus-full scopes with transient login grants, and `ENABLE_CLOUDFLARE_LIMITS` free-tier plus credit top-up with the `$2` balance threshold and 5-minute balance cache.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/13-configure-sign-in-and-ai-gateway-billing.md
- Generated: 2026-08-05T16:40:23.824Z

### Source Files

- `docs/oauth-signin.md`
- `docs/ai-gateway-billing.md`
- `docs/public-server.md`
- `packages/workshop-backend/src/auth/config.ts`
- `packages/workshop-backend/src/auth/login-flow.ts`
- `packages/workshop-shared/src/limits.ts`
- `packages/workshop-backend/src/ai-gateway.ts`

---
title: "Configure sign-in and AI Gateway billing"
description: "Turn on the optional public-service posture: `AUTH_GATEKEEPERS` allowlisting, `DISABLE_PASSWORD_AUTH`, email-keyed identity via `idFromName(email)`, incremental auth-versus-full scopes with transient login grants, and `ENABLE_CLOUDFLARE_LIMITS` free-tier plus credit top-up with the `$2` balance threshold and 5-minute balance cache."
---

Two independent switches turn the Workshop from a self-hosted single-tenant deployment into a public, multi-user service: `AUTH_GATEKEEPERS` opts specific auth-capable gatekeepers into the sign-in UI, and `ENABLE_CLOUDFLARE_LIMITS` enables the free daily LLM allowance plus Cloudflare-credits top-up flow. Both are off by default — with no allowlist the Workshop keeps username/password (or Cloudflare Access), and with limits disabled AI usage is unlimited. There is no single master switch; each piece is configured separately.

## What each switch does

| Configure | Effect |
| --- | --- |
| `AUTH_GATEKEEPERS=cloudflare,google,github` | Allowlists which gatekeepers may sign users in. Each listed, auth-capable gatekeeper gets a "Continue with …" button alongside username/password. Order is button order. |
| Each gatekeeper's OAuth credentials (on the gatekeeper Worker) | Required for that gatekeeper to actually authenticate. |
| `ENABLE_CLOUDFLARE_LIMITS=true` | Enables the free daily LLM-call limit plus Cloudflare-credits top-up flow. |
| `DISABLE_PASSWORD_AUTH=true` | Hides username/password, leaving gatekeeper sign-in only. Ignored unless `AUTH_GATEKEEPERS` is non-empty. |

<Warning>
`DISABLE_PASSWORD_AUTH=true` is deliberately inert when the allowlist is empty. `isPasswordAuthEnabled()` in `packages/workshop-backend/src/auth/config.ts` returns `true` whenever `hasAuthGatekeepers(env)` is false, so a misconfigured deployment cannot lock every user out.
</Warning>

## Sign-in via authentication gatekeepers

Sign-in is provided by gatekeepers that advertise `providesAuth` and can return a provider-verified email. Each such gatekeeper uses a **single OAuth app** for both sign-in and later capability use — there is no separate "login" app per provider.

### Allowlist parsing

`getAuthGatekeeperAllowlist(env)` splits `AUTH_GATEKEEPERS` on commas, trims each entry, lowercases it, and drops empties. It returns `[]` when the variable is unset.

```ts
// packages/workshop-backend/src/auth/config.ts
export function getAuthGatekeeperAllowlist(env: Cloudflare.Env): string[] {
  const raw = (env as { AUTH_GATEKEEPERS?: string }).AUTH_GATEKEEPERS;
  if (!raw) return [];
  return raw.split(",").map(s => s.trim().toLowerCase()).filter(Boolean);
}
```

Allowlisting is necessary but not sufficient: a vendor must also actually advertise `providesAuth` to be offered as a sign-in option.

### Identity is keyed by verified email

The primary account key is always the user's verified email. The `UserDurableObject` is addressed by `idFromName(email)` — the same scheme Cloudflare Access uses — so signing in with any allowlisted gatekeeper that yields the same verified email resolves to the same account.

Each gatekeeper must only return an email the provider has verified:

| Vendor | Verified-email source |
| --- | --- |
| `google` | `email_verified` claim |
| `github` | primary + verified email |
| `cloudflare` | the Cloudflare account email |

If no verified email is available, the gatekeeper returns `null` and cannot be used to sign in. `LoginConnectCallbackImpl.complete()` then fails the attempt with `"This account has no verified email, so it can't be used to sign in."`

<Note>
Session tokens are `"<doName>:<secret>"`, and `PublicApi.authenticate()` routes via `idFromName` of the first part. Because the user DO is keyed by email, the token prefix must be the email — `complete()` delivers `` `${email}:${secret}` ``.
</Note>

### Incremental scopes: `auth` versus `full`

`GatekeeperVendor.connectAccount` takes `{ scopes: "auth" | "full" }`.

<ParamField body="scopes: &quot;auth&quot;" type="string">
Minimal scopes needed only to verify the user's email — e.g. GitHub `read:user user:email`, Google `openid email profile`, Cloudflare `offline_access user-details.read`. The resulting gatekeeper grant is **transient**: it self-destructs shortly after the email is read, so signing in never leaves a broad authorization lying around, and no connected account is persisted.
</ParamField>

<ParamField body="scopes: &quot;full&quot;" type="string" default="full">
The default for `connectAccount(vendorId)`. Requests the fuller capability scopes (repos, Gmail/Docs, AI Gateway billing) and persists a usable connected account.
</ParamField>

Capability access is therefore always a second, explicit step for a user who signed in with a non-Cloudflare provider.

### Sign-in flow

```mermaid
sequenceDiagram
    participant C as Browser client
    participant P as PublicApi
    participant DO as PendingLogin DO
    participant GK as Gatekeeper Worker
    participant CB as LoginConnectCallbackImpl
    participant U as UserDurableObject

    C->>P: startGatekeeperLogin(vendorId)
    P->>DO: create (random DO id)
    P->>GK: connect flow + LoginConnectCallbackImpl
    P-->>C: { url, attempt } (RpcStub, no login id)
    C->>GK: open url in pop-up
    C->>DO: attempt.wait() (blocks)
    GK->>CB: complete(account, expiresAt?)
    CB->>CB: account.getAuthenticatedEmail()
    CB->>U: get(idFromName(email))
    CB->>U: loginOrCreateViaGatekeeper(email, signupsEnabled)
    CB->>DO: deliver("<email>:<secret>")
    DO-->>C: resolves attempt.wait()
```

Steps as implemented in `packages/workshop-backend/src/auth/login-flow.ts`:

<Steps>
<Step title="Start the attempt">
`PublicApi.startGatekeeperLogin(vendorId)` creates a short-lived `PendingLogin` DO keyed by a random DO id, hands the gatekeeper a `LoginConnectCallbackImpl`, and returns the gatekeeper's OAuth `url` plus an `attempt` stub. The `attempt` is an `RpcStub` wrapping the DO, so the client awaits via a capability and no guessable login id is ever exposed.
</Step>
<Step title="Wait on the capability">
The client opens `url` in a pop-up (the gatekeeper's self-closing OAuth window) and calls `attempt.wait()`, which blocks on `PendingLogin.awaitResult()`.
</Step>
<Step title="Resolve identity">
When the gatekeeper finishes it calls `complete(user)`. The callback reads `user.getAuthenticatedEmail()`, resolves or creates the email-keyed `UserDurableObject`, and mints a session via `loginOrCreateViaGatekeeper(email, signupsEnabled)`. The email's local part seeds the initial display name.
</Step>
<Step title="Deliver the token">
`pending.deliver("<email>:<secret>")` resolves the awaiting RPC; the client stores the token and authenticates as usual.
</Step>
</Steps>

### Signup gating and failure outcomes

`complete()` reads `readAdminConfig(this.env).signupsEnabled` and passes it into `loginOrCreateViaGatekeeper`. A `null` secret means first-time account creation was blocked. Existing users signing in are unaffected.

Each terminal path emits a `gatekeeper.login.finished` log event with an `outcome` field:

| `outcome` | Cause | Message delivered to the client |
| --- | --- | --- |
| `ok` | Session minted | token `"<email>:<secret>"` |
| `no_email` | `getAuthenticatedEmail()` returned falsy | `This account has no verified email, so it can't be used to sign in.` |
| `signups_disabled` | `loginOrCreateViaGatekeeper` returned `null` | `New sign-ups are currently disabled on this deployment.` |
| `error` | Thrown exception (also logs `gatekeeper.login.failed`) | `Sign-in failed. Please try again.` |

### The Cloudflare special case

Cloudflare sign-in is the one vendor where login also persists a connected account, because the same grant funds billing. `startGatekeeperLogin` requests full (non-transient) scopes for `CLOUDFLARE_VENDOR_ID`, and `complete()` calls `userStub.linkConnectedAccountFromLogin(account, vendorId, expiresAt)` before handing back the session. All other providers use minimal, transient sign-in grants and persist nothing.

### Sign-in storage and bindings

`PendingLogin` is a Durable Object reached via `ctx.exports` (no explicit binding) and holds **no durable storage**. Awaiters live in an in-memory `#waiters` array, with a one-time-use `#result` stash for the rare case where `deliver()`/`fail()` arrives before `awaitResult()` registers. The in-flight `awaitResult()` request keeps the DO alive; if the attempt is abandoned the client disposes the `attempt` stub and the DO is simply evicted — no alarm or cleanup needed.

### Sign-in code layout

```text
auth/
├── config.ts          # AUTH_GATEKEEPERS allowlist; password-auth toggle
├── auth-vendors.ts    # GATEKEEPER_<NAME> binding lookup helpers
└── login-flow.ts      # PendingLogin DO + LoginConnectCallbackImpl
```

Client-side, `ServerConfigContext` exposes `authVendors` and `passwordAuthEnabled`; `components/auth/OAuthButtons` renders the sign-in options (pop-up plus `attempt.wait()`).

## AI Gateway billing

With `ENABLE_CLOUDFLARE_LIMITS=true`, each user gets a free allowance of LLM calls per UTC day (default 100), counted on the user's own `UserDurableObject` via `consumeDailyLlmCall` / `checkDailyLlmCount`. Before each user-initiated agent turn the overseer calls `checkUsageAndBalance`.

### Decision matrix

`canProceedWithRequest` in `packages/workshop-shared/src/limits.ts` is the pure decision function. It takes `{ withinLimits, hasUserToken, balance, minimumBalance? }` and returns `CanProceedResult`:

| Connected token | Balance | Within free tier | Result |
| --- | --- | --- | --- |
| yes | ≥ `$2` | either | `allowed: true`, `shouldUseByok: true` — billed to the user's own gateway even while free-tier allowance remains; the daily counter is left untouched |
| yes | < `$2` (incl. `$0`) | yes | `allowed: true`, `shouldUseByok: false` — platform-funded free tier |
| no | — | yes | `allowed: true`, `shouldUseByok: false` — platform-funded free tier |
| no | — | no | `allowed: false`, reason `LIMIT_ERROR_MESSAGES.NO_CLOUDFLARE_TOKEN` — prompt to connect |
| yes | < `$2` | no | `allowed: false`, reason `insufficientBalanceMessage(minimum)` — prompt to add credits |

```ts
// packages/workshop-shared/src/limits.ts
if (hasUserToken && hasMinimumBalance(balance, minimumBalance)) {
  return { allowed: true, shouldUseByok: true };
}
if (withinLimits) {
  return { allowed: true, shouldUseByok: false };
}
```

<Warning>
`shouldUseByok` is only meaningful when `allowed` is true — it selects whose credentials to use. When `allowed` is false the request never runs, so the value is a don't-care. Callers must check `allowed` first.
</Warning>

`hasMinimumBalance(balance, minimum)` returns `false` for both `null` and `undefined`, so an unknown balance never satisfies the threshold.

### Shared constants and messages

<ResponseField name="MINIMUM_CLOUDFLARE_BALANCE" type="number" default="2.0">
Minimum Cloudflare AI Gateway balance in USD required to proceed via BYOK. Overridable per deployment with `MINIMUM_CLOUDFLARE_BALANCE`.
</ResponseField>

<ResponseField name="DEFAULT_DAILY_LLM_CALL_LIMIT" type="number" default="100">
Free-tier LLM calls per user per calendar day. Overridable with `DAILY_LLM_CALL_LIMIT`.
</ResponseField>

<ResponseField name="LIMIT_ERROR_MESSAGES.USAGE_LIMIT_EXCEEDED" type="string">
`Free usage limit reached. Connect your Cloudflare account or use your own API keys to continue.`
</ResponseField>

<ResponseField name="LIMIT_ERROR_MESSAGES.NO_CLOUDFLARE_TOKEN" type="string">
`Free usage limit reached. Connect your Cloudflare account to continue.`
</ResponseField>

<ResponseField name="insufficientBalanceMessage(minimum)" type="() => string">
Returns `Cloudflare AI Gateway balance is below $<minimum>. Please add credits or use BYOK.`
</ResponseField>

<ResponseField name="LimitWindowKind" type="&quot;daily&quot; | &quot;rolling&quot;">
The window over which the free-tier limit is measured.
</ResponseField>

### Balance reads and the 5-minute cache

The balance shown to users is read live from the user's Cloudflare AI Gateway billing endpoint `/ai-gateway-billing/credit_balance` and **cached for 5 minutes**. Topping up means adding credits in the Cloudflare dashboard (`https://dash.cloudflare.com/?to=/:account/ai/ai-gateway`) — the platform never holds money.

### Connecting Cloudflare for billing

Billing is tied to the Cloudflare gatekeeper: OAuth tokens live in that gatekeeper's connection, and the billing flow obtains a usable token from it via `getUsableAccessToken()`. A user connects Cloudflare either by signing in with it, or — if they signed in another way — via the "Connect Cloudflare" button, which runs the normal `AuthenticatedApi.connectAccount("cloudflare")` flow.

The account to bill is auto-selected when the grant sees exactly one account; with several, the user is prompted to choose. Billing is account-level (Unified Billing): inference routes through the account's auto-created "default" AI Gateway.

### Billing storage and bindings

```text
UserDurableObject
├── daily LLM-call counter        (free tier; no separate binding)
├── selected Cloudflare account id
└── cached credit balance         (5-minute TTL)

Cloudflare gatekeeper account
└── OAuth tokens                  (never stored on the user DO)
```

### Billing code layout

Server code lives under `packages/workshop-backend/src/ai-gateway-billing/`:

```text
ai-gateway-billing/
├── config.ts                     # ENABLE_CLOUDFLARE_LIMITS / minimum-balance readers
├── limits/
│   ├── config.ts                 # daily-limit + calendar-day helpers + DailyQuotaResult
│   └── usage-checker.ts          # checkUsageAndBalance / getUsageInfo (counter lives on UserDurableObject)
└── cloudflare/
    ├── account-service.ts        # CF REST: accounts / balance
    └── connection-service.ts     # token (from CF gatekeeper), account selection, balance cache, BYOK routing
```

Client-side, `ServerConfigContext` exposes `cloudflareLimitsEnabled`; `components/billing/` (`UsageSettings`, `OutOfCreditsModal`, `AccountSelectionModal`) renders the usage, top-up, and account-selection UI.

## Configuration reference

<Tabs>
<Tab title="Sign-in only">

```bash
PUBLIC_BASE_URL=https://your-host
AUTH_GATEKEEPERS=cloudflare,google,github   # which gatekeepers may sign users in (order = button order)

# Optional: gatekeeper sign-in only (hide username/password).
DISABLE_PASSWORD_AUTH=true
```

</Tab>
<Tab title="Sign-in + billing">

```bash
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=https://your-host
AUTH_GATEKEEPERS=cloudflare       # allow Cloudflare sign-in/connect (plus any others)

# The Cloudflare gatekeeper's OAuth app (client id/secret live on the gatekeeper Worker):
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier:
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set (all inference goes over HTTPS with tokens):
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...

# To send Workers AI straight to its REST endpoint (no gateway, no cost logs):
CF_AI_GATEWAY_WAI_DIRECT=true
```

</Tab>
<Tab title="Local .dev.vars">

Set the variables in a root `.dev.vars` file (gitignored, `KEY=VALUE` per line); `pnpm run dev-server` loads it automatically.

```bash
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

# Each gatekeeper's OAuth app (client id/secret). In dev these seed the gatekeeper Workers:
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...
CF_AI_GATEWAY_WAI_DIRECT=true
```

`run-dev-server.js` seeds each gatekeeper's `CLIENT_ID`/`CLIENT_SECRET` from the `GOOGLE_*` / `GITHUB_*` / `CLOUDFLARE_OAUTH_*` shell vars.

</Tab>
</Tabs>

### Optional overrides

```bash
DAILY_LLM_CALL_LIMIT=100        # free-tier LLM calls per user per UTC day
MINIMUM_CLOUDFLARE_BALANCE=2    # min connected-account balance (USD) to proceed via BYOK
```

### Redirect URIs

Register each gatekeeper's OAuth app with its own redirect URI, substituting `PUBLIC_BASE_URL` for the host:

| Vendor | Redirect URI |
| --- | --- |
| `github` | `${PUBLIC_BASE_URL}/gatekeeper/github/oauth` |
| `google` | `${PUBLIC_BASE_URL}/gatekeeper/google/oauth` |
| `cloudflare` | `${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth` |

OAuth app credentials live on the **gatekeeper Workers**, not on the backend.

### Cloudflare gatekeeper OAuth endpoints

The Cloudflare dashboard endpoints and scopes are hardcoded in `packages/gatekeeper-cloudflare/src/oauth.ts`:

| Field | Value |
| --- | --- |
| auth | `https://dash.cloudflare.com/oauth2/auth` |
| token | `https://dash.cloudflare.com/oauth2/token` |
| scopes | `offline_access aig.read aig.run aig.write user-details.read account-settings.read` |

## AI Gateway routing details

Gateway mode always requires `CF_AI_GATEWAY_ACCOUNT_ID` and an API token with **AI Gateway Run and Read** permissions. Read access lets Gadgets retrieve each log's cost for user-visible accounting. The Gateway may live in the Worker's own account or a different one.

| Variable | Effect on Workers AI |
| --- | --- |
| (default) | Uses `CF_AI_GATEWAY` as its Gateway ID |
| `CF_AI_GATEWAY_WAI=<gateway>` | Routes Workers AI through a different Gateway in the same account |
| `CF_AI_GATEWAY_WAI_DIRECT=true` | Bypasses gateways and calls the Workers AI REST endpoint directly with the same account/token pair; produces no cost logs |

<Tip>
When using `CF_AI_GATEWAY*` in local development, start the server with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` tool's document-to-Markdown conversion still has a `WORKERS_AI` binding. Inference itself no longer uses the binding — it goes over HTTPS with the tokens above.
</Tip>

## Verification checklist

<Check>
With `AUTH_GATEKEEPERS` unset, no "Continue with …" buttons appear and username/password remains available regardless of `DISABLE_PASSWORD_AUTH`.
</Check>

<Check>
With `AUTH_GATEKEEPERS` set, `ServerConfigContext.authVendors` lists exactly the allowlisted vendors that also advertise `providesAuth`, in allowlist order.
</Check>

<Check>
Signing in with two different allowlisted gatekeepers that report the same verified email lands on one account — the same `idFromName(email)` DO.
</Check>

<Check>
With `ENABLE_CLOUDFLARE_LIMITS` unset, `ServerConfigContext.cloudflareLimitsEnabled` is false and no daily counter or balance check applies.
</Check>

<Check>
A connected Cloudflare account funded above `$2` routes through the user's own gateway and leaves the daily free-tier counter unchanged.
</Check>

## Troubleshooting

<AccordionGroup>
<Accordion title="DISABLE_PASSWORD_AUTH=true has no effect">
`isPasswordAuthEnabled()` short-circuits to `true` when the `AUTH_GATEKEEPERS` allowlist is empty. Set a non-empty allowlist first.
</Accordion>

<Accordion title="Sign-in fails with &quot;This account has no verified email&quot;">
The gatekeeper's `getAuthenticatedEmail()` returned no verified email. Confirm the provider marks the address verified (Google `email_verified`, a GitHub primary+verified email, the Cloudflare account email). Unverified addresses are rejected by design.
</Accordion>

<Accordion title="Sign-in fails with &quot;New sign-ups are currently disabled&quot;">
`readAdminConfig(env).signupsEnabled` is false, so `loginOrCreateViaGatekeeper` returned `null` and blocked first-time account creation. Existing users are unaffected.
</Accordion>

<Accordion title="A gatekeeper appears in the allowlist but has no button">
Allowlisting is not sufficient — the vendor must also advertise `providesAuth`, and its OAuth credentials must be present on the gatekeeper Worker for it to actually authenticate.
</Accordion>

<Accordion title="User is blocked despite having connected Cloudflare">
The connected balance is below the minimum and the free tier is exhausted, so `canProceedWithRequest` returns `allowed: false` with `insufficientBalanceMessage`. Add credits in the Cloudflare dashboard; the balance is cached for 5 minutes, so the change may take that long to surface.
</Accordion>

<Accordion title="Balance shows as blocked right after topping up">
`hasMinimumBalance` treats `null`/`undefined` as failing, and the live `/ai-gateway-billing/credit_balance` read is cached for 5 minutes. Wait out the cache window.
</Accordion>

<Accordion title="Workers AI calls fail in local dev with CF_AI_GATEWAY set">
Start with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` document-to-Markdown path still has a `WORKERS_AI` binding.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
Register the OAuth apps behind each auth gatekeeper and wire `CLIENT_ID`/`CLIENT_SECRET` onto the gatekeeper Workers.
</Card>
<Card title="Environment variables" href="/environment-variables">
Full backend variable list including the `CF_AI_GATEWAY*` family, `DAILY_LLM_CALL_LIMIT`, and `MINIMUM_CLOUDFLARE_BALANCE`.
</Card>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`providesAuth`, `AccountDescription`, and the connector interfaces that make a vendor sign-in capable.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`PublicApi`, `LoginAttempt`, `AuthenticatedApi`, and `ServerConfig` shapes used by the sign-in and billing UI.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
`signupsEnabled` and why auth config is deliberately excluded from `AdminConfig`.
</Card>
<Card title="Local development" href="/local-development">
`.dev.vars` loading, gatekeeper service-binding discovery, and the `--use-workers-ai-binding` flag.
</Card>
</CardGroup>

---

## 14. Manage bundled format blueprints

> Ship output formats as committed data. Covers the `.gadget` archive plus `.json` sidecar split, `FORMAT_BLUEPRINTS_DIR` for forks, generation into the gitignored `src/generated/format-blueprints.ts`, first-request installation into KV and R2, `pnpm import:format-blueprint` with `--new`, and why a deployed `blueprintId` must never be renamed.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/14-manage-bundled-format-blueprints.md
- Generated: 2026-08-05T16:40:42.018Z

### Source Files

- `packages/workshop-backend/format-blueprints/README.md`
- `packages/workshop-backend/scripts/build-format-blueprints.mjs`
- `packages/workshop-backend/scripts/import-format-blueprint.mjs`
- `packages/workshop-backend/src/format-blueprints.ts`
- `packages/workshop-backend/src/admin-config.ts`
- `packages/workshop-backend/package.json`

---
title: "Manage bundled format blueprints"
description: "Ship output formats as committed data. Covers the `.gadget` archive plus `.json` sidecar split, `FORMAT_BLUEPRINTS_DIR` for forks, generation into the gitignored `src/generated/format-blueprints.ts`, first-request installation into KV and R2, `pnpm import:format-blueprint` with `--new`, and why a deployed `blueprintId` must never be renamed."
---

A *format* is an ordinary blueprint the deployment has promoted through `AdminConfig.formats`. The bundled set is the one it promotes out of the box: `packages/workshop-backend/format-blueprints/` holds a `<name>.gadget` archive plus a `<name>.json` sidecar per blueprint, committed as data. `scripts/build-format-blueprints.mjs` validates every sidecar and emits base64 archives into `src/generated/format-blueprints.ts`; `src/format-blueprints.ts` installs them into the `BLUEPRINTS` KV namespace and the `BLUEPRINT_CONTENT` R2 bucket on the first `/api` request a deployment serves. Nothing wakes on deploy — a fresh deployment is provisioned by its first visitor.

## The two-file split

| Holds | File | Rationale |
| --- | --- | --- |
| The code and the `bindings` it needs | `<name>.gadget` | What the blueprint *does* |
| `blueprintId`, `title`, `description`, `output` (`id`/`noun`/`plural`/`icon`), `author`, `revision` | `<name>.json` | What a human *curates*, kept as reviewable text rather than fields inside a binary |

`installOne()` spreads the archive's parsed metadata and then overwrites `title`, `description`, `author`, and `output` with the sidecar's values, so an archive's own title and author are inert. The import script normalizes the archive's metadata anyway so the committed bytes don't contradict the sidecar.

```text
format-blueprints/                  src/generated/format-blueprints.ts (gitignored, generated)
├── README.md                       ┌─────────────────────────────────────┐
├── workspace-docs.gadget  ──────►  │ FORMAT_BLUEPRINTS: [                │
├── workspace-docs.json    ──────►  │   { blueprintId, title, description,│
├── <name>.gadget                   │     output, author, revision,       │
└── <name>.json                     │     archive /* base64 */ }          │
                                    │ ] : BundledFormatBlueprint[]        │
                                    └─────────────────────────────────────┘
```

### Sidecar schema

The build validates the sidecar rather than the runtime, so a typo fails the build of whoever made it. Unknown top-level keys, unknown `output` keys, and unknown `author` keys are all rejected — silently ignoring one looks exactly like the field not working. `$comment` is accepted and dropped.

<ParamField body="blueprintId" type="string" required>
The install key. Must match `^[a-zA-Z0-9._-]+$` and must not be a reserved blueprint key (`.featured`, `.adminConfig`). Two sidecars in one directory may not share an id.
</ParamField>

<ParamField body="title" type="string" required>
Non-empty. Written over the archive's title at install time.
</ParamField>

<ParamField body="description" type="string" required>
Non-empty.
</ParamField>

<ParamField body="output.id" type="string" required>
Grouping key on the Outputs page. Keep it generic (`document`, not `acme-brief`) so a "Contract" blueprint lists with the other documents instead of adding its own filter chip.
</ParamField>

<ParamField body="output.noun" type="string" required>
Singular noun, e.g. `Document`.
</ParamField>

<ParamField body="output.plural" type="string" required>
Plural noun, e.g. `Documents`.
</ParamField>

<ParamField body="output.icon" type="string" required>
One of `fileText`, `gridNine`, `presentation`, `appWindow`, `flowArrow`, `kanban`, `chartBar`, `table`, `notebook`, `listChecks`. The list is duplicated in the build script because it runs before (and without) a TypeScript build; the runtime validates against the real `OUTPUT_ICONS`, so drift costs a build that rejects an icon the Worker would have accepted.
</ParamField>

<ParamField body="author" type="object" required>
`{ type?: "user", name: string, id: string }`. `type` must be `"user"` when present.
</ParamField>

<ParamField body="revision" type="integer" required>
Positive integer. Bumped when the archive bytes change, to trigger a reinstall on deployments already holding an older copy.
</ParamField>

## Generation

`node scripts/build-format-blueprints.mjs` runs as part of `build`, `types:check`, `test`, `test:integration`, and `test:watch` in `@gadgets/workshop-backend`, and standalone as `pnpm build:format-blueprints`. It reads every `*.gadget` in the source directory (sorted), requires a matching `<name>.json`, and writes `src/generated/format-blueprints.ts` — a generated module, so it is not committed and a clean checkout must run a build before `tsc` succeeds.

```
Bundled 3 format blueprint(s) from /…/format-blueprints, 71 KiB raw -> /…/src/generated/format-blueprints.ts
```

<Warning>
A `.gadget` with no matching `.json` is a hard error (`<file> has no <name>.json describing it.`). An **empty** directory is only a warning — `No *.gadget archives in <dir>; the deployment will bundle no formats.` — because shipping no formats is supported. A mistyped `FORMAT_BLUEPRINTS_DIR` fails in `readdir()`, which is the case worth catching.
</Warning>

## Installation on first request

```mermaid
flowchart LR
  subgraph src["Build-time source"]
    dir["format-blueprints/<br/>&lt;name&gt;.gadget + &lt;name&gt;.json"]
    build["scripts/build-format-blueprints.mjs"]
    gen["src/generated/format-blueprints.ts<br/>FORMAT_BLUEPRINTS (base64)"]
  end
  subgraph worker["Worker runtime"]
    install["src/format-blueprints.ts<br/>installFormatBlueprints()"]
    parse["parseBlueprintArchive()<br/>(blueprint-archive.ts)"]
  end
  subgraph store["Storage"]
    r2[("BLUEPRINT_CONTENT R2<br/>&lt;blueprintId&gt;/&lt;version&gt;")]
    kv[("BLUEPRINTS KV<br/>&lt;blueprintId&gt; → BlueprintKvRecord")]
  end
  dir --> build --> gen --> install
  install --> parse
  install -->|"1. content"| r2
  install -->|"2. metadata"| kv
  install -->|"BlueprintPublicInfo[]"| featured["featured mirror"]
```

`installOne()` parses each base64 archive through the ordinary `parseBlueprintArchive()` reader, so a corrupt bundled file fails exactly as an uploaded one would instead of producing a half-installed blueprint. It buffers the content (R2 needs a known length, and the archive is already fully in memory from the Worker bundle) and throws if the declared `contentLength` disagrees with the bytes held. The archive's content section is already gzip-compressed, which is what R2 stores.

Write order is content first: metadata without its R2 object is a broken blueprint, while the reverse is only an orphaned object the next install overwrites.

- `BLUEPRINT_CONTENT.put(`${blueprintId}/${installed.version}`, contentBytes)`
- `BLUEPRINTS.put(blueprintId, JSON.stringify({metadata: installed}))`

`installFormatBlueprints(env)` loops the whole set, logs `formats.install.ok` per success and `formats.install.failed` per failure through the `workshop.formats` logger, and returns the `BlueprintPublicInfo[]` that installed. One bad archive must not deny the deployment the others; failure is tolerable, since a deployment with none installed simply has no standard formats.

<Info>
Installation writes an ordinary blueprint — metadata into `BLUEPRINTS`, the code snapshot into `BLUEPRINT_CONTENT` — exactly as publishing does. There is no reserved id prefix and no fallback branch in the read path, so nothing downstream knows these are special.
</Info>

### Reinstall fingerprint

`formatBlueprintsManifestVersion()` builds the identity of the installed set:

```ts
// packages/workshop-backend/src/format-blueprints.ts
return FORMAT_BLUEPRINTS
    .map(e => `${e.blueprintId}@${e.revision}+` +
        fingerprint(JSON.stringify([e.title, e.description, e.author, e.output])))
    .toSorted()
    .join(",");
```

Everything that ends up in the installed metadata contributes, not just `revision` — editing a description would otherwise build, deploy, and change nothing on a deployment that had already installed. `revision` covers the one input the fingerprint cannot see: the archive bytes. `fingerprint()` (from `src/admin-config.ts`) is FNV-1a as eight hex characters, compared only for equality.

## Changing a title, description or author

Edit the sidecar and rebuild. No archive rewrite and no `revision` bump: those fields are part of the fingerprint, so a reinstall follows on the next deploy.

## Updating a blueprint's code

<Steps>
<Step title="Build and export in a real Workshop">
Iterate on the blueprint in a running deployment and export it as a `.gadget` archive.
</Step>
<Step title="Import the export by blueprintId">
```bash
pnpm import:format-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document
```

The script resolves the id against every sidecar in the directory and writes to that pair's `.gadget` and `.json`. Nothing is written before the incoming archive is read and parsed, so a bad argument leaves the repo untouched. Invoked with no usable arguments it exits `2` and lists the formats it found:

```
usage: pnpm import:format-blueprint <export.gadget> <blueprintId>
       pnpm import:format-blueprint <export.gadget> --new <name>

formats in /…/format-blueprints:
  format.document      workspace-docs.gadget
```
</Step>
<Step title="Read the report">
```
Updated workspace-docs.gadget (format.document)
  code         23668 -> 24489 bytes (7c5413e5a482)
  bindings     (none)
  version      3 -> 4
  revision     2 -> 3  (workspace-docs.json)

  presented as "Workspace Docs" by Cloudflare, from workspace-docs.json
               [export called it "Gadgets Doc"]
```

The line worth reading is **`bindings`**, flagged `[CHANGED]` when the export needs something the old copy didn't — an instantiating user will now be asked for it.
</Step>
<Step title="Commit both files">
The archive rewrite, the bumped `revision`, and the regenerated `src/generated/format-blueprints.ts` all land together. `revision` is automated because forgetting it is invisible: everything builds and deploys, and the old blueprint quietly stays put.
</Step>
</Steps>

The importer re-implements the archive format because it runs as a plain Node script outside the Worker — `src/blueprint-archive.ts` is the authority. A 24-byte prefix carries magic `0xec2e2d3a2300e317`, `VERSION` 1, the metadata byte length, and the content byte length, followed by UTF-8 JSON metadata and a gzipped Yjs snapshot. `parseArchive()` rejects a short file, bad magic, an unsupported version, a content length that disagrees with the prefix, and metadata that is not valid JSON. After writing, the script round-trips the bytes it just wrote and re-checks the metadata and a content hash, because these are committed as data and a corrupt archive would otherwise first surface when a deployment tried to install it.

## Adding a new format

`--new` writes the sidecar for you, filling in what it can from the export:

```bash
pnpm import:format-blueprint ~/Downloads/Brief.gadget --new acme-brief
```

Scaffolded values:

| Field | Source |
| --- | --- |
| `blueprintId` | the `--new` name |
| `title` | `incoming.metadata.title`, else the name |
| `description` | `incoming.metadata.description`, else `TODO: say what a <title> is for.` |
| `output` | `{ id: <name>, noun: <title>, plural: "<title>s", icon: "appWindow" }` |
| `author` | the first sibling sidecar's `author`, else the export's `author` |
| `revision` | `1` |

The scaffolded sidecar is held rather than written until the archive proves usable. `--new <name>` must match `^[a-zA-Z0-9._-]+$` and must not collide with an existing sidecar name — import into an existing one by `blueprintId` instead. The script then prints the fields worth editing before deploy, chiefly `output` and especially `output.id`.

<Warning>
`blueprintId` defaults to the `--new` name and is the install key. Reimporting the same id updates that blueprint in place. **Changing it after a deployment has installed it** promotes the new id as a *second* format while the old one stays in the New menu, updated by nothing. Rename files freely; the id is the load-bearing part.
</Warning>

## Shipping your own formats

`packages/workshop-backend/format-blueprints/` is only the default. `FORMAT_BLUEPRINTS_DIR` (resolved relative to the package root) points both the build and the import script somewhere else:

```bash
FORMAT_BLUEPRINTS_DIR=../../acme-formats pnpm build
```

Whatever directory it names *is* the deployment's format set — it replaces the default rather than adding to it. Keep it in your own tree in the same `<name>.gadget` + `<name>.json` layout; nothing in it refers back to this repo. To keep one of the bundled formats, copy the pair across once and own it from then on.

This matters because this repo is usually a submodule: adding or deleting files in the default directory would conflict on every update, while pointing the build at your own directory touches nothing.

Two lighter options need no build change:

<AccordionGroup>
<Accordion title="Promote your own blueprints">
These are ordinary blueprints, and the standard set is admin curation (`AdminConfig.formats`). Publish a blueprint in your deployment and promote it in the admin Formats panel; disable the bundled ones you don't want. Nothing needs rebuilding — this is the mechanism the bundled set is a convenience on top of, not a special case beside it.
</Accordion>
<Accordion title="Ship no formats">
Point `FORMAT_BLUEPRINTS_DIR` at an empty directory. The build warns, and the deployment has no formats until an admin promotes something.
</Accordion>
</AccordionGroup>

## How a bundled blueprint becomes an offered format

`AdminConfig.formats` is a `FormatCuration[]`, and order is menu order:

<ResponseField name="blueprintId" type="string">
The promoted blueprint's id.
</ResponseField>

<ResponseField name="enabled" type="boolean">
Offered to users and the agent. Disabling keeps the entry and its overrides, so re-enabling doesn't lose the admin's edits. Parsing treats a missing value as enabled (`enabled !== false`).
</ResponseField>

<ResponseField name="agentHint" type="string">
One line telling the agent when to choose this format. Trimmed and truncated to `MAX_AGENT_HINT` (400) — every enabled format's hint goes into the system prompt on every turn, so this is a budget.
</ResponseField>

<ResponseField name="overrides" type="Partial<BlueprintOutput>">
Presentation the deployment substitutes for the blueprint's own, e.g. an org that calls its decks "Briefings". Absent fields fall back to the blueprint's declaration.
</ResponseField>

`parseFormats()` drops malformed entries and duplicate `blueprintId`s. `reorderFormats()` throws `Format order must list each promoted format exactly once.` unless the supplied id list is a permutation of what is promoted, so a stale client cannot silently drop a format. `defaultOutputFormatId()` supplies a stable grouping id for a promoted blueprint that declares no `output`, shortening ids longer than 40 characters to a 31-character prefix plus an eight-hex `fingerprint()`.

<Note>
The blueprint's own `BlueprintMetadata.output` is a declaration, not a promotion: any user can publish a blueprint calling itself a Document, but only `AdminConfig.formats` decides what the deployment offers.
</Note>

## Failure modes

| Symptom | Cause | Fix |
| --- | --- | --- |
| `<file> has no <name>.json describing it.` | Archive committed without a sidecar | Add the sidecar, or import with `--new` |
| `<name>.json: unknown keys: …` | Typo or an unsupported field | Remove it; the schema is closed |
| `<name>.json: output.icon must be one of: …` | Icon outside the build's `OUTPUT_ICONS` copy | Use a listed icon |
| `<a>.json and <b>.json share id <id>` | Two sidecars with one `blueprintId` | Two archives installing under one id would race and only one would survive — give them distinct ids |
| `<name>.json: blueprintId <id> is reserved` | Used `.featured` or `.adminConfig` | Pick another id |
| `no sidecar declares blueprintId "<id>"` | Wrong id, or a first import | Use the printed list, or `--new <name>` |
| `<name>.json already exists; import into it by blueprintId instead` | `--new` collided with an existing stem | Drop `--new` and pass the id |
| `<label>: not a .gadget archive (bad magic)` / `unsupported archive version N` / `content is N bytes, prefix claims M` | Truncated, wrong-format, or corrupt export | Re-export from the Workshop |
| `formats.install.failed` in Worker logs | A bundled archive failed to parse or install | The others still install; re-import the offending archive and redeploy |
| Missing `./generated/format-blueprints.js` on a clean checkout | Generated module not yet built | Run `pnpm build:format-blueprints` (or any `build`/`types:check`/`test` script) |
| New description deployed but the deployment shows the old one | Fingerprint unchanged, so no reinstall | Confirm the sidecar edit landed in the generated module; archive-only changes need a `revision` bump, which the importer does |

## Related pages

<CardGroup cols={2}>
<Card title="Blueprints" href="/blueprints">
What a blueprint captures, the three binding types, `.gadget` export/import, and KV propagation.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
The `AdminConfig` schema, its defaults, and the reserved `.adminConfig` KV key.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
Where `BLUEPRINTS` and `BLUEPRINT_CONTENT` are bound, and how `/api/*` reaches the backend.
</Card>
<Card title="Build, lint, and test" href="/build-lint-test">
Generator prerequisites and the recursive `build` / `types:check` ordering CI enforces.
</Card>
<Card title="Quickstart" href="/quickstart">
First prompts that exercise bundled format blueprints versus from-scratch gadget creation.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Missing generated modules on a clean checkout and other known failure modes.
</Card>
</CardGroup>

---

## 15. RPC API reference

> The Cap'n Web interfaces shared between client and backend: `PublicApi`, `LoginAttempt`, `AuthenticatedApi`, `AdminApi`, and `Overseer`, plus supporting types (`GadgetMetadata`, `UiBundle`, `CodeUpdate`, `ActionLogEntry`, `AgentSpawnerConfig`, `AiModelConfig`, `ServerConfig`). Documents `OPEN_GADGET_ERROR_CODES`, `validateBindingName`, observer-config callbacks, and stub-disposal and promise-pipelining constraints.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/15-rpc-api-reference.md
- Generated: 2026-08-05T16:44:47.752Z

### Source Files

- `packages/workshop-shared/src/api.ts`
- `packages/workshop-shared/src/gatekeeper.ts`
- `packages/workshop-shared/src/feature-flags.ts`
- `packages/workshop-shared/src/external-message-gateway.ts`
- `packages/workshop-backend/src/server.ts`
- `AGENTS.md`

---
title: "RPC API reference"
description: "The Cap'n Web interfaces shared between client and backend: `PublicApi`, `LoginAttempt`, `AuthenticatedApi`, `AdminApi`, and `Overseer`, plus supporting types (`GadgetMetadata`, `UiBundle`, `CodeUpdate`, `ActionLogEntry`, `AgentSpawnerConfig`, `AiModelConfig`, `ServerConfig`). Documents `OPEN_GADGET_ERROR_CODES`, `validateBindingName`, observer-config callbacks, and stub-disposal and promise-pipelining constraints."
---

The entire client/backend API lives in `packages/workshop-shared/src/api.ts` as TypeScript interfaces spoken over Cap'n Web (`capnweb`). There is no REST surface and no generated schema: the interfaces *are* the protocol. The backend serves one endpoint, `/api`, which the client upgrades to a WebSocket at startup and keeps open for the whole session, reconnecting with backoff on break. `packages/workshop-backend/src/server.ts` answers that path with `newWorkersRpcResponse(req, new PublicApiImpl(...))`; the client opens it with `newWebSocketRpcSession<PublicApi>(wsUrl)`.

Every entry point is reached by capability, not by URL. `PublicApi.authenticate()` returns an `AuthenticatedApi`; `AuthenticatedApi.openGadget()` returns an `Overseer`; `Overseer.getGadget()` returns a `GadgetClient`. Authorization checks happen once, when the capability is minted, so downstream methods do not re-check.

## Session bootstrap

```ts
// packages/workshop-frontend/src/main.tsx
const wsUrl = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + apiHost + '/api'
return newWebSocketRpcSession<PublicApi>(wsUrl)
```

The backend host defaults to `VITE_BACKEND_HOST`, falls back to `localhost:8787` when the page is served from `localhost` (the Vite dev server on port 3000), and otherwise uses the page's own host.

Two request-level gates apply before the RPC session starts:

| Condition | Behavior |
| --- | --- |
| `CF_ACCESS_AUD` set and `Origin` header ≠ request origin | `403 Cross-origin API access not allowed.` |
| `CF_ACCESS_AUD` set, CF Access JWT invalid | `403 Invalid CF access JWT.` |
| `CF_ACCESS_AUD` set, JWT carries no `email` | `403 Access JWT didn't specify email address.` |
| First `/api` request in an isolate | Fires `AdminSettings.ensureFormatBlueprintsInstalled()` via `ctx.waitUntil` (idempotent; retried on partial install) |

Session abort is implemented by closing the WebSocket, not by `ctx.abort()`; the backend passes an `abortSession` callback into `PublicApiImpl` and re-applies it if the abort races the `await`.

```mermaid
flowchart LR
  subgraph client["Browser (workshop-frontend)"]
    main["main.tsx<br/>newWebSocketRpcSession"]
    ui["GadgetUI.tsx<br/>newMessagePortRpcSession"]
    iframe["sandboxed gadget iframe"]
  end
  subgraph backend["workshop-backend"]
    pub["PublicApiImpl"]
    auth["AuthenticatedApiImpl"]
    admin["AdminApi"]
  end
  subgraph dos["Durable Objects"]
    ov["Overseer"]
    gc["GadgetClient"]
    gk["GatekeeperClient&lt;Session&gt;"]
    as["AdminSettings"]
  end
  main -- "WebSocket /api" --> pub
  pub -- "authenticate() / authenticateFromCfAccess() / login()" --> auth
  auth -- "getAdminApi()" --> admin
  admin --> as
  auth -- "openGadget() / newGadget()" --> ov
  ov -- "getGadget() / createGadget()" --> gc
  ov -- "newGatekeeper() / getGatekeeperById()" --> gk
  gc -- "getUiBundle() / connectToGadget()" --> ui
  ui -- "postMessage + MessagePort" --> iframe
```

## PublicApi

Exposed to the internet with no authentication. Extends `RpcTarget`.

<ResponseField name="getServerConfig()" type="Promise&lt;ServerConfig&gt;">
Deployment-level boot configuration. Contains no secrets.
</ResponseField>

<ResponseField name="startGatekeeperLogin(vendorId)" type="Promise&lt;{ url: string; attempt: RpcStub&lt;LoginAttempt&gt; }&gt;">
Begins sign-in via an auth-capable, allowlisted gatekeeper (see `ServerConfig.authVendors`); throws otherwise. The client opens `url` in a new tab. Disposing `attempt` abandons the sign-in and cancels the server-side wait.
</ResponseField>

<ResponseField name="authenticate(token)" type="Promise&lt;AuthenticatedApi&gt;">
Authenticates from a stored session token.
</ResponseField>

<ResponseField name="authenticateFromCfAccess()" type="Promise&lt;AuthenticatedApi&gt;">
Uses the existing Cloudflare Access session's credentials instead of a token.
</ResponseField>

<ResponseField name="login(username, passwordHash)" type="Promise&lt;string | null&gt;">
Returns a session token, or `null` for no-such-user / wrong password. May be disabled under SSO.
</ResponseField>

<ResponseField name="createAccount(username, displayName, passwordHash)" type="Promise&lt;string | null&gt;">
Returns a session token, or `null` if the username already exists. Other failures throw.
</ResponseField>

<ResponseField name="getBlueprint(id)" type="Promise&lt;BlueprintPublicInfo | null&gt;">
Unauthenticated: knowing the ID is sufficient, since a blueprint is just data.
</ResponseField>

<ResponseField name="downloadBlueprint(id)" type="Promise&lt;ReadableStream&lt;Uint8Array&gt;&gt;">
`.gadget` archive containing `BlueprintMetadata` plus the current code snapshot — not the full KV record.
</ResponseField>

### Password hashing contract

`login()` and `createAccount()` take a client-derived `passwordHash`, never a password. The client computes:

```ts
argon2id({
  password,
  salt: SERVICE_SALT + encode(username, 'utf8'),
  parallelism: 1,
  iterations: 3,
  memorySize: 64 MiB,
  hashLength: 32,
})
```

`SERVICE_SALT` is an exported 16-byte constant in `api.ts`. The server hashes these bytes again before storage, so the server never sees the password and the expensive KDF runs on the client. Because the server stores and compares the submitted bytes verbatim without re-deriving them, `packages/integration-tests` substitutes a SHA-256 stand-in to avoid 64 MiB of argon2 per test login.

### LoginAttempt

```ts
export interface LoginAttempt extends RpcTarget {
  wait(): Promise<string>;
}
```

Holding this stub *is* the capability to receive the resulting session token. `wait()` resolves once the gatekeeper popup completes, or rejects if the attempt fails or is abandoned; it is safe to call immediately after `startGatekeeperLogin()`. Sign-in completes entirely inside the gatekeeper Worker (the OAuth redirect lands on `/gatekeeper/<name>/oauth`), so the backend hosts no `/auth/*` callbacks — the result is bridged back through this stub.

## AuthenticatedApi

The post-authentication root capability. Selected groups:

| Group | Methods |
| --- | --- |
| Identity | `whoami`, `setOwnDisplayName`, `changePassword`, `hasPasswordLogin`, `setAvatar`, `getAvatar` |
| Models | `listModels`, `addModel`, `deleteModel`, `setQuickModel`, `getQuickModel`, `getAiConfig`, `getPreferredModel`, `setPreferredModel` |
| Onboarding / flags | `isOnboardingCompleted`, `completeOnboarding`, `getUiFeatureFlags` |
| Workspaces | `openGadget`, `newGadget`, `listGadgets`, `listOutputs`, `listOutputFormats`, `dismissSharedGadget` |
| Connections | `listGatekeeperVendors`, `connectAccount`, `ensureAccountResources`, `listAddableGatekeepers`, `provisionAmbientAccount`, `subscribeConnectedAccounts`, `disconnectAccount`, `reconnectAccount`, `startResourceConfigurator` |
| Blueprints | `listOwnBlueprints`, `getOwnBlueprint`, `listLibraryBlueprints`, `setBlueprintPinned`, `isBlueprintPinned`, `listFeaturedBlueprints`, `addBlueprintToLibrary`, `removeBlueprintFromLibrary`, `isBlueprintInLibrary`, `newGadgetFromBlueprint`, `deleteOrphanedBlueprint`, `importBlueprint` |
| Cloudflare limits | `getCloudflareUsage`, `listCloudflareAccounts`, `selectCloudflareAccount` |
| Gatekeeper apps | `listGatekeeperApps`, `getGatekeeperApp` |
| Admin | `amIAdmin`, `getAdminApi` |

`getAdminApi()` returns `RpcStub<AdminApi> | null` — `null` for non-admins. The check runs once at mint time.

`setAvatar()` expects a compressed JPEG/PNG, ideally under 50 KB; `getAvatar(userId)` accepts any user ID so other users' avatars can be rendered in chat.

### openGadget

```ts
openGadget(id: string, shareKey?: string,
           configureObservers?: RpcStub<ObserverConfigCallback>): Promise<RpcStub<Overseer>>;
```

- When `shareKey` is supplied, redemption happens before the open, adding the caller as a collaborator in the same round trip. An invalid or expired key throws.
- Missing gadgets **throw** rather than returning `null`, specifically so calls can be pipelined onto the returned stub.
- Expected missing/authorization failures carry a machine-readable `code`.

<Warning>
`openGadget()` throwing is load-bearing for pipelining. Do not "fix" it to return `null` — a nullable return would force an `await` before every downstream call and cost an extra round trip on the hot path.
</Warning>

#### OPEN_GADGET_ERROR_CODES

```ts
export const OPEN_GADGET_ERROR_CODES = {
  workspaceNotFound: "WORKSPACE_NOT_FOUND",
  workspaceAccessDenied: "WORKSPACE_ACCESS_DENIED",
} as const;
```

| Code | Message |
| --- | --- |
| `WORKSPACE_NOT_FOUND` | `Workspace not found.` |
| `WORKSPACE_ACCESS_DENIED` | `You don't have access to this workspace.` |

Three helpers travel with it: `createOpenGadgetError(code)` returns `Error & { code }`, `getOpenGadgetErrorCode(error)` reads the code back off an unknown value (returning `undefined` for anything else), and `OpenGadgetErrorCode` is the union type. Match on the code; never match on the message.

```ts
try {
  using overseer = await api.openGadget(id, shareKey)
} catch (err) {
  switch (getOpenGadgetErrorCode(err)) {
    case OPEN_GADGET_ERROR_CODES.workspaceNotFound:   /* 404 view */ break
    case OPEN_GADGET_ERROR_CODES.workspaceAccessDenied: /* request access */ break
    default: throw err   // unexpected: surface it
  }
}
```

## Observer configuration callbacks

A non-owner opening a shared gadget may have to supply their *own* connected accounts before they are allowed to observe it. The overseer drives that through a client-supplied callback.

```ts
export interface ObserverConfigCallback extends RpcTarget {
  configure(needs: ObserverBindingNeed[]): Promise<ObserverAccountChoice[]>;
}
```

<ParamField body="ObserverBindingNeed.gatekeeperId" type="WorkpieceId" required>
The overseer-assigned gatekeeper id, echoed back in the corresponding `ObserverAccountChoice`.
</ParamField>

<ParamField body="ObserverBindingNeed.vendorId" type="string" required>
The vendor the user must have a connected account for (e.g. `"google"`). The frontend filters the user's accounts by this to find candidates.
</ParamField>

<ParamField body="ObserverBindingNeed.resourceTitle" type="string" required>
Human-readable resource title for the configuration modal.
</ParamField>

<ParamField body="ObserverBindingNeed.resourceUrl" type="string">
Canonical resource URL, when known, for display.
</ParamField>

<ParamField body="ObserverBindingNeed.failure" type="ObserverBindingFailure">
Present only when this binding *was* configured but its account failed verification on this attempt (expired credentials, revoked grant, upstream outage, or a genuine denial). Absent for a never-configured binding.
</ParamField>

`ObserverBindingFailure` carries `accountId` (a `ConnectedAccountRecord` id in the *opening user's own* User DO — pre-select it and aim the re-authenticate affordance at it) and `reason`, free display text that **must not** be parsed or matched on. `ObserverAccountChoice` is `{ gatekeeperId, accountId }`.

Contract details that matter for client implementations:

- `configure()` is invoked **only** for a non-owner who has unconfigured bindings. Owners and already-configured observers never see it, so the common-case open stays a single pipelined round trip.
- `open()` does not resolve until `configure()` returns. Rejecting the callback denies the open.
- `configure()` may be called a **second time** within one open, for just the subset of bindings that failed verification, so a user can re-authenticate an expired account without leaving the flow.
- The overseer bounds re-prompts. A client that keeps resubmitting a failing account eventually gets a denial rather than an endless loop.
- `ObserverBindingNeed` deliberately carries no "credentials valid" flag: the client already has that live from `subscribeConnectedAccounts()`, and a wire copy would go stale while the modal is open across an OAuth round trip.

`Overseer.listObserverRequirements(role)` returns the same `ObserverBindingNeed[]` shape purely so a sharer can *preview* what sharing will cost the recipient. It grants nothing and mints no capability.

## validateBindingName

```ts
export function validateBindingName(name: string): void
```

One shared validator applied at every chokepoint that writes a binding name: gadget binding edges, the workspace default binding list, chat binding maps, spawner env configs, and the agent tools. Throws a descriptive `Error`; returns nothing on success.

| Rule | Rejects | Reason |
| --- | --- | --- |
| `/^[A-Za-z_][A-Za-z0-9_]*$/` | `2fa`, `my-name`, `$env`, non-ASCII | Names render as `env.NAME`; `$` is conventionally reserved for code generators |
| ECMAScript reserved words | `class`, `await`, `let`, `static`, `yield`, `interface`, `null`, `true`, … | Valid identifiers that cannot follow `.` in all contexts |
| `name === "prototype"` or `name in Object.prototype` | `__proto__`, `constructor`, `hasOwnProperty`, `toString`, `prototype` | Binding maps are plain objects; these collide with inherited members or mutate the prototype chain |

`ALL_CAPS_WITH_UNDERSCORES` is style guidance only — recommended in tool descriptions and used for generated names — and is **not** enforced here.

## Overseer

One workspace = one Overseer Durable Object. Workspace-level concerns live here: the gadget registry, code sync (a single Yjs doc for the whole workspace), chats, actions/hooks, sharing, and blueprint listing. Per-gadget operations live on `GadgetClient`.

<AccordionGroup>
<Accordion title="Metadata, presence, lifecycle">
`getMetadata()`, `subscribeToMetadata(callback)`, `subscribeToPresence(subscriber)`, `setTitle(title)`, `setPinned(pinned)`, `deleteSelf()`. After `deleteSelf()`, further calls fail. `subscribeToMetadata` takes an `RpcStub<(metadata: GadgetMetadata) => void>` and fires once immediately with current state, then on every change.
</Accordion>

<Accordion title="Workpieces">
`subscribeToWorkpieces(subscriber)` delivers one `entry()` per existing workpiece, then `ready()`, then incremental `entry()`/`removed()`. In v1 only gadget-type workpieces are delivered.

`createGadget(title, chatId?, bindingName?)` — `title` is required (gadgets have no default title); the new gadget starts with no files and no bindings. With `chatId`, creation is provisional to that chat and stays pending until the chat's changes are merged (reverting deletes the gadget). Without `bindingName`, the server derives one from the title using the quick model when configured, else a generic fallback. Gadget binding names are workspace-unique — throws if taken, *including* by a gadget still pending in another chat.

`getGadget(id)` throws when the id is unknown, again to preserve pipelining.
</Accordion>

<Accordion title="Code sync">
`subscribeToCode(subscriber, fromVersion?)` and `updateCode(update, chatId?)`. Code is a single Yjs doc for the whole workspace; each file-owning workpiece has its own root `Y.Map` (file name → `Y.Text`) named per `WorkpieceSummary.filesRoot`. Updates are whole-doc and may span workpieces. Omit `fromVersion` (or pass zero) to download from scratch. `updateCode()` without `chatId` writes committed mainline; with `chatId` it records a live draft edit on that chat's branch.
</Accordion>

<Accordion title="Gatekeepers">
`getGatekeeperById(id)` (throws on unknown id), `newGatekeeper(accountId, resourceUrl)` (returns `null` when the resource can't be connected), `newAiModelGatekeeper(modelId)`, `newAgentSpawnerGatekeeper(config)`. New gatekeepers are workspace-level workpieces and are **not** bound into any gadget's `env` by default — use `GadgetClient.bind()` or `bindWithSuggestedName()`.
</Accordion>

<Accordion title="Actions, hooks, auto-approval">
`listActions()`, `approveAction(id)`, `rejectAction(id)`, `subscribeToActions(subscriber, startAfter?)`, `listHooks()`, `enableHook(id)`, `disableHook(id)`, `deleteHook(id)`, `setAutoApprovedActionKind(gatekeeperId, actionKind)`, `removeAutoApprovedActionKind(gatekeeperId, tag)`, `listAutoApprovedActionKinds()`, `listPreApprovableActions()`, `acceptConnectionRequest(requestId, {gatekeeperId})`, `denyConnectionRequest(requestId)`.

Auto-approval rules are workspace-wide **per gatekeeper**: approving an action kind approves it regardless of which gadget invokes it, and applies immediately to matching already-pending actions. `denyConnectionRequest()` deliberately does *not* resume the agent — the turn stays ended.
</Accordion>

<Accordion title="Chats">
`listChats`, `listModels`, `getChatHistory`, `getChatMessage`, `subscribeToChat`, `listSlashCommands`, `newChat`, `sendChatMessage`, `uploadChatAttachment`, `getChatAttachmentContent`, `deleteChatAttachment`, `setChatTitle`, `mergeChanges`, `revertChanges`, `finalizeChatDraft`, `discardChatDraftChanges`, `deleteChat`, `stopAgent`, `retryAgent`, `subscribeToConsoleLogs`.

Console logs are not stored; the only way to see them is to be subscribed while they happen.
</Accordion>

<Accordion title="Blueprints and sharing">
`listBlueprints`, `updateBlueprint(blueprintId, options)`, `deleteBlueprint`, `retryBlueprintPublish`, `listObserverRequirements`, `listCollaborators`, `addCollaborator`, `removeCollaborator`, `previewRemoveCollaborator`, `createShareLink`, `newShareLinkKey`, `listShareLinks`, `updateShareLink`, `revokeShareLink`, `previewRevokeShareLink`.

`updateBlueprint()` requires at least one option and applies metadata plus code atomically in one propagation pass; `updateCode: true` snapshots committed code and bumps the version, `updateBindings: true` refreshes connection annotations without touching the snapshot. `retryBlueprintPublish()` exists for records whose `dirty` flag is set after a failed propagation.

`createShareLink()` generates a random 128-bit key, stores only its HMAC-SHA-256 hash, and returns the raw key once — it is never stored server-side. A link may back several keys (`newShareLinkKey()` mints more); revoking the link revokes all of them.
</Accordion>
</AccordionGroup>

### Per-workpiece sub-capabilities

`WorkpieceClient` is the shared base: `getId()`, `getTitle()`, `setTitle()`, `remove()`. Removing a gadget deletes its registry entry (including its binding map) and hooks and clears its files; gatekeepers it bound survive. Removing a *gatekeeper* destroys the connection itself — distinct from `GadgetClient.unbind()`.

`GadgetClient extends WorkpieceClient` adds `getUiBundle(chatId?)`, `connectToGadget(chatId?)`, `exportPdf(chatId?)`, plus binding management (`listBindings`, `getBinding`, `bind`, `bindWithSuggestedName`, `unbind`, `renameBinding`, `getBlueprintAnnotation`, `setBlueprintAnnotation`) and `createBlueprint(title?, description?, screenshot?)`. A blueprint is always owned by the workspace owner regardless of who calls `createBlueprint()`.

`GatekeeperClient<Session extends RpcCompatible<Session>> extends WorkpieceClient` adds `describe(): Promise<ResourceDescription>`, `openSession(): Promise<RpcStub<Session>>`, and `getCreationSpec(): Promise<GatekeeperCreationSpec>`. Binding-edge concerns (names, blueprint annotations) live on `GadgetClient` because one gatekeeper may be bound by several gadgets under different names.

## AdminApi

Obtained only via `AuthenticatedApi.getAdminApi()`. Covers branding, agent instructions, formats, and which connectors/resources are offered. Authentication config (sign-in providers, password login) is deliberately **excluded** and stays env-var driven. Every setter throws on invalid input.

| Method | Constraint |
| --- | --- |
| `getSettings()` | Returns the whole `AdminSettingsView` in one call |
| `setSignupsEnabled(enabled)` | Existing users can still log in while signups are closed |
| `setSiteName(name)` | `""` resets to `DEFAULT_SITE_NAME`; rejects over `MAX_SITE_NAME_LENGTH` (40) |
| `setSiteLogo(data)` | `null` restores the default mark; server enforces PNG header, size, dimensions |
| `setInstanceInstructions(text)` | `""` clears; rejects over `MAX_INSTANCE_INSTRUCTIONS_LENGTH` (8000) |
| `setResourceEnabled(vendorId, urlPattern, enabled)` | Soft enforcement — hides from connect UI, picker, and agent; does not revoke held capabilities |
| `setGatekeeperMode(vendorId, mode)` | Ambient gatekeepers accept all three modes; ordinary ones reject `'optional'` |
| `setAnnouncement(text)` | `""` clears; rejects over `MAX_ANNOUNCEMENT_LENGTH` (2000) |
| `setBanner(text, color)` | Empty text hides; rejects invalid `BannerColor` |
| `setAccentColor(color)` | `""` resets; rejects non-hex (validated by `isHexColor` before CSS interpolation) |
| `isBlueprintFeatured(id)` | `null` when the blueprint can't be featured |
| `setBlueprintFeatured(id, featured)` | — |
| `promoteFormat(id)` | Appends last; re-promoting preserves curation so a failed mirror write can be repaired by retry |
| `removeFormat(id)` | Refused for a bundled format — use `updateFormat({enabled: false})` |
| `updateFormat(id, patch)` | `agentHint: ""` clears; an `overrides` field set to `null` reverts to the blueprint's declaration |
| `setFormatOrder(blueprintIds)` | Must be a permutation of currently promoted ids |

Related exported constants and guards: `MAX_ANNOUNCEMENT_LENGTH`, `MAX_INSTANCE_INSTRUCTIONS_LENGTH`, `MAX_SITE_NAME_LENGTH`, `MAX_SITE_LOGO_BYTES` (256 KiB), `MAX_SITE_LOGO_DIMENSION` (512), `BANNER_COLORS`, `DEFAULT_BANNER_COLOR` (`'info'`), `DEFAULT_SITE_NAME` (`"Cloudflare OS"`), `AMBIENT_GATEKEEPER_MODES`, `isBannerColor`, `isHexColor`, `isAmbientGatekeeperMode`, `isOutputIcon`, `resolveSiteName`.

`AmbientGatekeeperMode` resolves as: `'disabled'` (not offered; existing account dormant), `'optional'` (users opt in from the Connectors page — the default), `'enabled'` (auto-provisioned for everyone; not removable).

## Supporting types

### ServerConfig

Returned by `getServerConfig()`; no secrets.

<ResponseField name="authVendors" type="AuthVendorInfo[]">
Auth-capable, allowlisted vendors offered as sign-in buttons. Empty means password-only. Each entry is `{ vendorId, displayName, logo?, color? }` built from the gatekeeper's `VendorDescription`.
</ResponseField>

<ResponseField name="passwordAuthEnabled" type="boolean">
Defaults true. `DISABLE_PASSWORD_AUTH` makes a deployment OAuth-only, but this is **forced true when no auth vendor is configured**, to avoid locking everyone out.
</ResponseField>

<ResponseField name="cloudflareLimitsEnabled" type="boolean">
When false (the default, e.g. self-hosted), usage is unlimited and the credits UI is hidden.
</ResponseField>

<ResponseField name="signupsEnabled" type="boolean">
Admin-configurable, default true. The signup page hides the create-account form when false.
</ResponseField>

<ResponseField name="siteName" type="string">
Empty falls back to `DEFAULT_SITE_NAME`. Resolve with `resolveSiteName()` so server and client agree.
</ResponseField>

<ResponseField name="siteLogo" type="AvatarImage | undefined">
Undefined uses the default Cloudflare OS mark.
</ResponseField>

<ResponseField name="announcement" type="string">
Top-bar notice. Empty when unset.
</ResponseField>

<ResponseField name="banner / bannerColor" type="string / BannerColor">
Full-width banner. Empty `banner` hides it.
</ResponseField>

<ResponseField name="accentColor" type="string">
Hex brand color, or `""` for the default theme. The client overrides brand CSS variables (and derived shades) at runtime.
</ResponseField>

### GadgetMetadata

Workspace metadata — one Overseer DO and everything in it.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | `string` | Random url-safe base64, used with `openGadget()` |
| `title` | `string` | Workspace title; per-gadget titles live on `WorkpieceSummary` |
| `totalCost` | `number?` | Total AI inference cost in dollars, if known |
| `pinned` | `boolean?` | Pinned to the top of the user's list |
| `owner` | `AiChatAuthorInfo?` | **Presence means the viewer is a collaborator, not the owner** |
| `role` | `CollaboratorRole?` | Absent implies `"build"` for backwards compatibility |
| `sharingProhibited` | `boolean?` | True once the gadget has observed share-prohibited data; no further sharing is possible |
| `defaultGadgetId` | `WorkpieceId?` | Fallback when an API object omits its `gadgetId` |

`GadgetMetadataWithTimestamps` adds `created` and `lastActive`. Those are available from `listGadgets()` (the user's own collection) but **not** from `Overseer.getMetadata()`, which does not track them.

### UiBundle

```ts
export type UiBundle = {
  jsCode: string;
};
```

Raw JS to execute in the gadget iframe. A commented-out content-addressed `url` field records the intended direction (HTTP-served, highly cacheable across gadgets sharing a blueprint), but today the code crosses the wire inline. The bundle runs in a sandbox whose only outside channel is `postMessage()` to the parent frame; the frontend establishes a `newMessagePortRpcSession` over a transferred `MessagePort` after a `"handshake"` message.

### CodeUpdate and CodeSubscriber

```ts
export type CodeUpdate = {
  version: number;      // version AFTER this update is applied
  timestamp: Date;
  update: Uint8Array;   // Yjs encoded update, always V2 format
};
```

<Warning>
`CodeSubscriber.update()` ordering is a hard constraint. When a subscriber is several versions behind, the server may send multiple incremental updates or one large one, and may make several calls in rapid succession **without waiting for previous calls to return**. Cap'n Web guarantees in-order delivery, so the subscriber must apply each update — or enqueue it — *synchronously*. An `async` handler that awaits before applying will reorder the doc.
</Warning>

`ready()` fires the first time the subscriber is up to date with the server's latest known version.

### ActionLogEntry

Common fields: `id` (sequential from workspace creation), `gatekeeperId?` (omitted for non-gatekeeper sources such as the `webFetch` tool), `resourceTitle`, `resourceUrl?`, `createdAt`, `appliedAt?`, `state`. `ActionState` is `"pending" | "approved" | "rejected"`.

The entry is a discriminated union on `type`:

| `type` | Extra fields | Notes |
| --- | --- | --- |
| `"action"` | `description: ActionDescription`, `resolvedBy?`, `autoApproved?` | `resolvedBy` is set when the action leaves `pending`; for an auto-approval it is the user who enabled the rule, since auto-approvals run under their authority. `autoApproved` only ever appears with `state: "approved"` — there is no automatic rejection. |
| `"observation"` | `description: ObservationDescription` | Read-only; needs no approval |
| `"bindHook"` | `description: HookDescription`, `hookId?`, `enabled` | **`state` is not meaningful for hooks.** They are enabled/disabled and freely toggled, not approved/rejected. `hookId` is `undefined` if the hook was later deleted. |

`ActionsSubscriber` is `{ entry(record: ActionLogEntry): void; ready(): void }`.

### AgentSpawnerConfig

```ts
export type AgentSpawnerConfig = {
  displayName: string;
  modelId: string | null;
  env: Record<string, WorkpieceId>;
};
```

Creates a binding that lets a gadget programmatically start new agent chat threads, which appear in the gadget's agent chat UI as new conversations. Spawned agents typically use `executeCode` against the gadget's bindings rather than editing gadget code.

- `modelId: null` creates a chat without running an agent; the chat is flagged as needing attention, the same as an agent chat where the agent never marked the task complete.
- `env` maps binding name → target workpiece. On spawn it is **snapshotted** into the spawned chat's seed binding layer, dropping entries whose targets no longer exist. The spawned agent sees only these bindings, never the workspace default binding list.
- `env` entries are deliberately **not** limited to bindings held by the owning gadget: a spawner may define its own names and targets.

Names in `env` go through `validateBindingName()`. The isolation is the point: a gadget that answers email can spawn one agent per message with a stub scoped to replying to *that* thread only, so prompt injection or leakage cannot cross threads.

### AiModelConfig and AiGatewayInfo

```ts
export type AiModelConfig = {
  provider: AiModelProvider;   // "openai" | "anthropic" | "google" | "cloudflare" | "ollama"
  model: string;
  apiToken: string;
  accountId?: string;          // required for provider "cloudflare" (account-scoped REST endpoint)
  apiUrl?: string;             // override for AI-gateway-style proxies or compatible providers
};
```

`apiUrl` is what keeps model access portable: any OpenAI-compatible endpoint, self-hosted `ollama`, or a gateway proxy can be pointed at without a code change. `getAiConfig()` returns `AiGatewayInfo`, either `{ enabled: true, enabledProviders }` or `{ enabled: false }`, and the frontend adjusts the model-management UI accordingly.

`SUGGESTED_MODELS: Record<AiModelProvider, Record<string, {name, contextWindow, outputLimit?}>>` populates the picker. `contextWindow` is the maximum tokens one request may total; `outputLimit`, when present, is both the requested response cap and the space reserved for it, leaving the remainder as the prompt budget that context compaction sizes against. `WORKERS_AI_OUTPUT_LIMIT` is `32768`, applied to every Cloudflare model because Workers AI adds the response cap to the prompt and rejects requests whose total exceeds the window.

### Feature flags

`packages/workshop-shared/src/feature-flags.ts` exports `UI_FEATURE_FLAGS` (currently a single `placeholder-flag`), the derived `UiFeatureFlagName` / `UiFeatureFlags` types, `DEV_UI_FEATURE_FLAGS` (local development values), and `DEFAULT_UI_FEATURE_FLAGS` (used when Flagship is unavailable or unconfigured). The backend resolves these for the frontend through `AuthenticatedApi.getUiFeatureFlags()`.

### ExternalMessageGateway

`packages/workshop-shared/src/external-message-gateway.ts` defines a **service-binding** RPC interface (importing `RpcStub`/`RpcTarget` from `cloudflare:workers`, not `capnweb`) — a separate surface from the browser API.

```ts
export interface ExternalMessageGateway {
  submitExternalMessage(input: SubmitExternalMessageInput): Promise<SubmitExternalMessageResult>;
}
```

<Warning>
`SubmitExternalMessageInput.callerEmail` is a trust delegation: "The backend trusts the gateway: supplying this email grants access as that account." Only bind this to a gateway worker you control.
</Warning>

`gadgetKey`, `chatKey`, and `messageKey` are idempotency keys selecting/creating the workspace and chat and deduplicating the originating message. The result is `{ accepted: true, chatPath }` or `{ accepted: false, message }`, where `message` is a user-facing explanation of an actionable rejection. Responses come back through `ChatGatewayRpcTarget.onGadgetResponse(response)`, whose implementations **must be idempotent** because delivery is at-least-once when acknowledgements fail.

## RPC usage constraints

These are enforced by review, not by the type checker, and each has a concrete failure mode.

### Promise pipelining

Cap'n Web pipelines promises. A method returning a stub does not need to be awaited — the promise is usable in place of the stub — and a promise for a future *value* can be passed as an argument, where it is replaced by its resolution on the server before delivery.

<CodeGroup>
```ts title="Pipelined — one round trip"
// No await: chain straight through the promise.
const overseer = api.openGadget(id)
const gadget = overseer.getGadget(gadgetId)
const bundle = await gadget.getUiBundle()
```

```ts title="Serialized — three round trips"
const overseer = await api.openGadget(id)
const gadget = await overseer.getGadget(gadgetId)
const bundle = await gadget.getUiBundle()
```
</CodeGroup>

This is also why `no-floating-promises` is not enabled in this repo: type-aware oxlint rules are off (the tsgo engine requires an explicit `rootDir` under declaration emit and drops `baseUrl`, incompatible with cross-package source imports), and pipelining intentionally leaves promises unawaited. Type safety still comes from `tsc` via `pnpm types:check` and `pnpm build`.

### Stub disposal

Every stub must be disposed or the server leaks the capability. Call `stub[Symbol.dispose]()`, or use a `using` declaration where possible. In React, a stub obtained in a `useEffect` must be disposed in that effect's cleanup function.

```ts
useEffect(() => {
  const sub = overseer.subscribeToActions(stubFor(subscriber))
  return () => { sub[Symbol.dispose]() }   // otherwise the subscription lives forever
}, [overseer])
```

Every subscription method (`subscribeToMetadata`, `subscribeToPresence`, `subscribeToWorkpieces`, `subscribeToCode`, `subscribeToActions`, `subscribeToChat`, `subscribeToConsoleLogs`, `subscribeConnectedAccounts`) returns `Promise<RpcStub<{}>>` whose sole purpose is disposal — disposing it cancels the subscription. Disposing a `LoginAttempt` abandons the sign-in.

### Stubs in React state

<Warning>
An `RpcStub` must never be a `useState` value directly. At runtime every stub appears callable, because the system cannot know whether the stub points at a server-side function. `useState`'s setter treats any callable — including a stub — as an updater function and *invokes it* to compute the state. Wrap the stub in an object and store that: `setGk({ stub })`.
</Warning>

### No hand-written mirror interfaces

Never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast. Derive from the real type instead, or rethink the design. `workshop-shared` and `workshop-backend` are reviewed line by line as the kernel, and every exported member of the `workshop-shared` public API — types, constants, and functions, not just interfaces — must carry a doc comment.

The one sanctioned cast lives in the integration-test helper `stubFor()`, and is documented there: a stub is only serializable by the `capnweb` instance owning the session, and a consumer that vendors this repo as a `public/` submodule ends up with two installs and two stores. The symptom is `Cannot serialize value: [object RpcStub]`, and it appears only once the installs are separate — so a single-install dev machine will not reproduce it but CI will. Always mint callback stubs through the helper rather than importing `RpcStub` directly.

### Capability-based security

A resource becomes "ambient" (auto-injected) only through user or admin configuration. A gatekeeper must never assert its own ambience.

## Related pages

<CardGroup cols={2}>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`GatekeeperVendor`, `VendorDescription`, `ResourceDescription`, and the session types these RPC interfaces hand out.
</Card>
<Card title="Observations, actions, and approval queues" href="/observations-and-actions">
`ObservationDescription`, `ActionDescription`, `ActionKind`, and the state machine behind `ActionLogEntry`.
</Card>
<Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
`CollaboratorRole`, the `use` allowlist, and how observer re-verification produces `ObserverBindingNeed`.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
The `AdminConfig` schema behind `AdminApi`, its defaults, and the KV mirror.
</Card>
<Card title="Integration testing" href="/integration-testing">
Driving these interfaces over the real `/api` WebSocket with `createTestHarness()`.
</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">
Kernel review standards, doc-comment requirements, and the full RPC rule set.
</Card>
</CardGroup>

---

## 16. Environment variables

> Every backend environment variable and its default: `ADMINS`, `PUBLIC_BASE_URL`, the `CF_AI_GATEWAY*` family and its required account/token pair, `CF_ACCESS_AUD`/`CF_ACCESS_ISS`, `AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, `ENABLE_CLOUDFLARE_LIMITS`, `DAILY_LLM_CALL_LIMIT`, `MINIMUM_CLOUDFLARE_BALANCE`, plus `VITE_FRONTEND_ERROR_REPORTING`, `MCP_PORTAL_URL`, and `MCP_PORTAL_TRUST_ANNOTATIONS`.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/16-environment-variables.md
- Generated: 2026-08-05T16:40:34.058Z

### Source Files

- `packages/workshop-backend/src/env.d.ts`
- `docs/public-server.md`
- `docs/ai-gateway-billing.md`
- `packages/workshop-backend/src/auth/config.ts`
- `packages/gatekeeper-mcp-portal/README.md`
- `run-dev-server.js`

---
title: "Environment variables"
description: "Every backend environment variable and its default: `ADMINS`, `PUBLIC_BASE_URL`, the `CF_AI_GATEWAY*` family and its required account/token pair, `CF_ACCESS_AUD`/`CF_ACCESS_ISS`, `AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, `ENABLE_CLOUDFLARE_LIMITS`, `DAILY_LLM_CALL_LIMIT`, `MINIMUM_CLOUDFLARE_BALANCE`, plus `VITE_FRONTEND_ERROR_REPORTING`, `MCP_PORTAL_URL`, and `MCP_PORTAL_TRUST_ANNOTATIONS`."
---

Backend environment variables are declared in `packages/workshop-backend/src/env.d.ts`, which augments the auto-generated `Cloudflare.Env` interface (`worker-configuration.d.ts` is produced by `wrangler types` and is not edited by hand). Nearly every variable is optional and every optional feature is off by default: a deployment with none of them set runs username/password auth with unlimited AI usage, which is the self-hosting posture. Gatekeeper service bindings (`GATEKEEPER_*`) are deliberately absent from that declaration file — core discovers them generically by scanning `env` for the `GATEKEEPER_` prefix (`buildGatekeeperVendorMap`), so no gatekeeper is named in the backend's type surface.

Values reach the Worker either through `wrangler.jsonc` vars, deployed secrets, or — in local development — a gitignored root `.dev.vars` file of `KEY=VALUE` lines that `run-dev-server.js` loads into `process.env` before launching Wrangler. Existing shell values take precedence over `.dev.vars`; surrounding single or double quotes are stripped and `#` comment lines are skipped.

## Variable inventory

| Variable | Type | Default | Purpose |
| --- | --- | --- | --- |
| `ADMINS` | `string[]` | unset | Deployment-wide admin usernames. |
| `PUBLIC_BASE_URL` | `string` | unset | Public base URL of the deployment; the base for every gatekeeper OAuth redirect URI. |
| `CF_AI_GATEWAY` | `string` | unset | Gateway name. Setting it enables AI Gateway mode. |
| `CF_AI_GATEWAY_PROVIDERS` | `string` | unset | Comma-separated providers routed through the Gateway: `anthropic,openai,google,cloudflare`. |
| `CF_AI_GATEWAY_ACCOUNT_ID` | `string` | unset | Gateway owner account ID. Required whenever `CF_AI_GATEWAY` is set. |
| `CF_AI_GATEWAY_API_TOKEN` | `string` | unset | Run + Read token for inference and cost-log reads. Required whenever `CF_AI_GATEWAY` is set. |
| `CF_AI_GATEWAY_WAI` | `string` | falls back to `CF_AI_GATEWAY` | Optional Workers AI gateway override. |
| `CF_AI_GATEWAY_WAI_DIRECT` | `"true"` | unset | Routes Workers AI to its plain REST endpoint — no gateway, no cost logs. |
| `CF_ACCESS_AUD` | `string` | unset | Cloudflare Access audience. When set, the deployment authenticates via Cloudflare Access (SSO). |
| `CF_ACCESS_ISS` | `string` | unset | Access team URL, e.g. `https://<team>.cloudflareaccess.com`. |
| `AUTH_GATEKEEPERS` | `string` | unset (empty allowlist) | Comma-separated gatekeeper vendor ids permitted to drive sign-in. |
| `DISABLE_PASSWORD_AUTH` | `"true"` | unset (password auth on) | Disables username/password login + signup. Only takes effect when at least one auth gatekeeper is allowlisted. |
| `ENABLE_CLOUDFLARE_LIMITS` | `"true"` | unset (unlimited usage) | Enables the free daily limit + Cloudflare-credits top-up flow. |
| `DAILY_LLM_CALL_LIMIT` | `string` | `DEFAULT_DAILY_LLM_CALL_LIMIT` (100) | Free-tier LLM calls per user per UTC day. |
| `MINIMUM_CLOUDFLARE_BALANCE` | `string` | `MINIMUM_CLOUDFLARE_BALANCE` (2 USD) | Minimum connected-account balance to proceed via BYOK. |

<Note>
Booleans in this surface are string-compared, not coerced. `DISABLE_PASSWORD_AUTH`, `ENABLE_CLOUDFLARE_LIMITS`, and `CF_AI_GATEWAY_WAI_DIRECT` are meaningful only as the exact string `"true"`.
</Note>

## Admin and deployment identity

<ParamField body="ADMINS" type="string[]">
Deployment-wide admin usernames. Declared as an array rather than a comma-separated string.
</ParamField>

<ParamField body="PUBLIC_BASE_URL" type="string">
Public base URL of the deployment. Every gatekeeper OAuth app must be registered against a redirect URI derived from it:

- GitHub: `${PUBLIC_BASE_URL}/gatekeeper/github/oauth`
- Google: `${PUBLIC_BASE_URL}/gatekeeper/google/oauth`
- Cloudflare: `${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth`

In local development this is typically `http://localhost:8787`.
</ParamField>

## AI Gateway mode

Setting `CF_AI_GATEWAY` puts supported providers behind Cloudflare AI Gateway with server-managed keys, so users do not need their own keys. All inference travels over HTTPS with tokens — there is no Workers-binding transport for it — which is why the account/token pair is mandatory in this mode.

```bash title="Gateway mode, minimum viable configuration"
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...
```

The API token needs **AI Gateway Run and Read** permissions. Run executes models; Read lets Gadgets retrieve each log's cost for user-visible accounting. The Gateway may live in the Worker's own account or a different one.

### Workers AI routing

Workers AI uses `CF_AI_GATEWAY` as its Gateway ID by default. Two overrides exist:

| Setting | Effect |
| --- | --- |
| `CF_AI_GATEWAY_WAI=<other-gateway>` | Routes Workers AI through a different Gateway in the same account. |
| `CF_AI_GATEWAY_WAI_DIRECT=true` | Bypasses gateways entirely and calls the Workers AI REST endpoint directly, using the same account/token pair. Produces no cost logs. |

<Warning>
Outside gateway mode, Workers AI (provider `cloudflare`) is BYOK like every other provider — the account ID and API token come from the user's model config, not from `env`.
</Warning>

<Tip>
When using `CF_AI_GATEWAY*` locally, start the dev server with `pnpm run dev-server -- --use-workers-ai-binding` so the `webFetch` tool's document-to-Markdown conversion still has a `WORKERS_AI` binding. Inference itself no longer uses that binding.
</Tip>

## Authentication

Three authentication surfaces coexist and turn on independently. There is no single switch.

```text
                       ┌──────────────────────────────────────┐
  CF_ACCESS_AUD  ────►  │ Cloudflare Access (SSO)              │
  CF_ACCESS_ISS         └──────────────────────────────────────┘
                       ┌──────────────────────────────────────┐
  (nothing set)  ────►  │ username / password  ← default ON    │
  DISABLE_PASSWORD_AUTH └──────────────────────────────────────┘
        │ "true"                        ▲
        └── suppressed unless ──────────┘
            AUTH_GATEKEEPERS non-empty
                       ┌──────────────────────────────────────┐
  AUTH_GATEKEEPERS ──►  │ "Continue with …" per allowlisted    │
                        │ gatekeeper that advertises           │
                        │ providesAuth                         │
                        └──────────────────────────────────────┘
```

<ParamField body="CF_ACCESS_AUD" type="string">
Cloudflare Access audience. When set, the deployment authenticates via Cloudflare Access. Also referenced through a local `Env` extension in `server.ts`.
</ParamField>

<ParamField body="CF_ACCESS_ISS" type="string">
Access team URL, e.g. `https://<team>.cloudflareaccess.com`.
</ParamField>

<ParamField body="AUTH_GATEKEEPERS" type="string">
Comma-separated allowlist of gatekeeper vendor ids permitted to drive sign-in, e.g. `google,github,cloudflare`. Parsed by `getAuthGatekeeperAllowlist(env)`, which splits on `,`, trims, lowercases, and drops empty entries; unset yields an empty list. A listed gatekeeper must also actually advertise `providesAuth` to be offered.

The primary account key is always the user's verified email: signing in with any allowlisted gatekeeper that yields the same verified email maps to the same account.
</ParamField>

<ParamField body="DISABLE_PASSWORD_AUTH" type="string">
`"true"` hides username/password login and signup, leaving gatekeeper sign-in only. `isPasswordAuthEnabled(env)` returns `true` for any value other than the exact string `"true"`, and also returns `true` when `hasAuthGatekeepers(env)` is false — the anti-lockout guard.
</ParamField>

```ts title="packages/workshop-backend/src/auth/config.ts"
export function isPasswordAuthEnabled(env: Cloudflare.Env): boolean {
  if (env.DISABLE_PASSWORD_AUTH !== "true") return true;
  return !hasAuthGatekeepers(env);
}
```

OAuth client id and secret for each connector live on the gatekeeper Workers, not in the backend env. In dev they are seeded into those Workers from shell variables by `run-dev-server.js` via its `SHARED_GATEKEEPER_CREDS` map (`GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET`, `GOOGLE_*`, `CLOUDFLARE_OAUTH_*`); credentials already defined in a gatekeeper's own config still win.

## Usage limits and billing

`ENABLE_CLOUDFLARE_LIMITS=true` turns on the free-tier allowance plus Cloudflare-credits top-up flow. Unset, usage is unlimited. The free-tier counter lives on each `UserDurableObject` (`consumeDailyLlmCall` / `checkDailyLlmCount`) — there is no separate binding. Before each user-initiated agent turn, the overseer calls `checkUsageAndBalance`.

| Situation | Outcome |
| --- | --- |
| Cloudflare connected, balance ≥ `$2` | Allowed and routed through the user's own account (BYOK), even while free-tier allowance remains. Their daily counter is left untouched. |
| Within the free tier | Allowed via the platform's configured AI Gateway. Includes connected users below `$2`, including `$0`. |
| Free tier exhausted, no Cloudflare account connected | Blocked, with a prompt to connect. |
| Free tier exhausted, connected but below `$2` | Blocked, with a prompt to add credits. |

<ParamField body="DAILY_LLM_CALL_LIMIT" type="string" default="100">
Free-tier LLM calls per user per UTC day. Falls back to `DEFAULT_DAILY_LLM_CALL_LIMIT`.
</ParamField>

<ParamField body="MINIMUM_CLOUDFLARE_BALANCE" type="string" default="2">
Minimum connected-account balance in USD required to proceed via BYOK. Falls back to the `MINIMUM_CLOUDFLARE_BALANCE` constant.
</ParamField>

Balance is read live from the user's Cloudflare AI Gateway billing (`/ai-gateway-billing/credit_balance`) and cached for 5 minutes. Top-ups happen in the Cloudflare dashboard; the platform never holds money. Readers for these variables live under `packages/workshop-backend/src/ai-gateway-billing/` (`config.ts`, `limits/config.ts`, `limits/usage-checker.ts`, `cloudflare/account-service.ts`, `cloudflare/connection-service.ts`). On the client, `ServerConfigContext` exposes `cloudflareLimitsEnabled`.

<RequestExample>
```bash title=".dev.vars — public multi-user service"
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

# Seeded into the gatekeeper Workers by run-dev-server.js
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...

# Optional overrides
DAILY_LLM_CALL_LIMIT=100
MINIMUM_CLOUDFLARE_BALANCE=2
```
</RequestExample>

## Optional bindings, not variables

Several capabilities are gated by the presence of a binding rather than by a string variable. They are declared alongside the variables in `env.d.ts`.

| Binding | Type | Behavior when absent |
| --- | --- | --- |
| `BLUEPRINTS` | `KVNamespace` | Required — blueprint metadata lookup. |
| `BLUEPRINT_CONTENT` | `R2Bucket` | Required — blueprint code snapshots. |
| `AVATARS` | `KVNamespace` | Required — user avatar images. |
| `PRODUCT_ANALYTICS` | `Pipeline<ProductAnalyticsRecord>` | Analytics no-op. Local/dev configs omit it. |
| `FRONTEND_ERROR_REPORTER` | `Service<ErrorReporter>` | No reports dispatch. |
| `FRONTEND_ERROR_RATE_LIMITER` | `RateLimit` | No reports dispatch. |
| `BROWSER` | `BrowserRun` | Gadget export rendering unavailable. Optional for self-hosted deployments. |

<Warning>
Browser error reporting requires **both** `FRONTEND_ERROR_REPORTER` and `FRONTEND_ERROR_RATE_LIMITER` to be present before reports dispatch. Binding only one is equivalent to binding neither.
</Warning>

## MCP portal gatekeeper variables

`packages/gatekeeper-mcp-portal` runs as its own Cloudflare Worker and is auto-discovered by the backend from its `GATEKEEPER_MCP_PORTAL` binding. Its configuration is read from that Worker's own env, not the backend's.

| Variable | Meaning |
| --- | --- |
| `MCP_PORTAL_URL` | The portal's MCP endpoint. Unset means the connector hides itself. |
| `MCP_PORTAL_NAME` | Display name in the connector list and every approval prompt. Defaults to `MCP Server Portal (<host>)`. |
| `MCP_PORTAL_AUTH` | `oauth` (default), `none`, or `token`. |
| `MCP_PORTAL_TOKEN` | Secret bearer token, for `MCP_PORTAL_AUTH: "token"`. |
| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default. |
| `MCP_ALLOW_INSECURE` | `"true"` disables endpoint checks entirely: permits `http://` and private, loopback, link-local, and cloud-metadata hosts, for the portal and every OAuth URL discovered from it. Local dev only. |

Only `MCP_ALLOW_INSECURE` is set in the package's committed `wrangler.jsonc`, pinned to `"false"` so the default is explicit rather than merely absent. None of the others is committed: a portal URL in the repo would become the default for every deployment and would send their users' OAuth flows to whichever host it named, so it belongs in the deployment's own configuration.

### `MCP_PORTAL_URL` validity and repointing

<AccordionGroup>
<Accordion title="Unconfigured or unusable URL">
Unconfigured, `getSupportedResources()` returns nothing and the Workshop drops the vendor. A `MCP_PORTAL_URL` that cannot be used — a non-`https` typo, or a URL containing `username:password` — is treated identically, so a misconfiguration hides the connector rather than producing one that fails on first use or copies URL credentials into account state and configurator fields.
</Accordion>
<Accordion title="Changing MCP_PORTAL_URL on a live deployment">
Changing the URL on a deployment that already has connected accounts is a **repoint**, and existing bindings fail closed immediately. The minting path checks facet props against current configuration, and an already-minted facet must name its endpoint when asking the account for credentials — which the account refuses after it has moved. Recovery is reconnecting; that is the one endpoint change an account will accept, and only because the new endpoint comes from the Worker's configuration rather than from a form.

Nothing held for the old portal survives: tokens, the transport session, and any in-progress authorization are dropped, so the user re-authorizes against the new host. The account advances a persisted generation before probing, so refreshes, expiry notifications, and session writes started under the old generation are ignored when they return. Always-approve action kinds also include the exact endpoint, so consent for the old portal does not carry over.
</Accordion>
</AccordionGroup>

### `MCP_PORTAL_TRUST_ANNOTATIONS` evaluation

Read at each point of use via `portalTrust(env)` and never persisted on an account or a binding's props. Consequences:

- Clearing it de-escalates every existing connection on the next call.
- Setting it does not retroactively auto-apply anything; the user must still enable a rule per action kind.

## Frontend variables

`VITE_FRONTEND_ERROR_REPORTING` and `VITE_BACKEND_HOST` are Vite-prefixed and therefore build/dev-time client configuration rather than Worker env.

<ParamField body="VITE_BACKEND_HOST" type="string">
Documented in `run-dev-server.js`. Setting `VITE_BACKEND_HOST=localhost:9000` also passes `--port 9000` to `wrangler dev`, keeping the client's target and the Wrangler listener in sync.
</ParamField>

<Info>
The supplied source evidence documents the two backend error-reporting bindings (`FRONTEND_ERROR_REPORTER`, `FRONTEND_ERROR_RATE_LIMITER`) and `VITE_BACKEND_HOST`, but does not contain the definition or default of `VITE_FRONTEND_ERROR_REPORTING`. Treat the backend bindings above as the authoritative gate on whether reports actually dispatch.
</Info>

## Local development loading order

<Steps>
<Step title="Write .dev.vars at the repository root">
One `KEY=VALUE` per line. Blank lines and lines starting with `#` are skipped; surrounding single or double quotes on the value are stripped. The file is gitignored and may hold local secrets.
</Step>
<Step title="Start the dev server">
`pnpm run dev-server`. `run-dev-server.js` calls `loadDevVars()` before anything else, assigning each key into `process.env` only when `process.env[key] === undefined` — so an exported shell variable always wins over `.dev.vars`.
</Step>
<Step title="Verify gatekeeper discovery">
The script scans `packages/` for directories named `gatekeeper-*` that contain a `wrangler.jsonc`, then writes `wrangler.dev.jsonc` with one service binding per discovered package, named by uppercasing the directory and replacing `-` with `_` (`gatekeeper-github` → `GATEKEEPER_GITHUB`). Confirm the generated file lists the connectors you expect.
</Step>
<Step title="Add flags when needed">
`--use-workers-ai-binding` includes the Workers AI binding in `workshop-backend` (requires Cloudflare login). `--serve-frontend-assets` makes the backend serve the pre-built frontend as static assets instead of relying on Vite on :3000.
</Step>
</Steps>

## Related pages

<CardGroup cols={2}>
<Card title="Configure sign-in and AI Gateway billing" href="/configure-signin-and-billing">
The `AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, and `ENABLE_CLOUDFLARE_LIMITS` posture end to end, including email-keyed identity and the credit top-up flow.
</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
The `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect contract and per-connector `CLIENT_ID`/`CLIENT_SECRET` secrets.
</Card>
<Card title="Local development" href="/local-development">
`.dev.vars` loading, generated dev wrangler files, and the `--use-workers-ai-binding` / `--serve-frontend-assets` flags.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How `GATEKEEPER_*` env keys become `/gatekeeper/<name>/*` routes, plus the full backend binding list.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
The `AdminConfig` schema and why auth configuration is deliberately excluded from it.
</Card>
<Card title="Troubleshooting" href="/troubleshooting">
Free-tier and balance blocks, a missing `WORKERS_AI` binding in gateway mode, and no-op error reporting without its bindings.
</Card>
</CardGroup>

---

## 17. Admin configuration reference

> The `AdminConfig` schema and its defaults: `signupsEnabled`, `siteName`, `instanceInstructions`, `announcement`, `banner`, `accentColor`, `disabledResources`, `disabledGatekeepers`, `ambientGatekeeperModes`, and `formats`. Covers length limits, the `AdminSettings` DO as sole writer mirroring to the reserved `.adminConfig` KV key, `readAdminConfig` hot-path reads, and why auth config is deliberately excluded.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/17-admin-configuration-reference.md
- Generated: 2026-08-05T16:41:09.886Z

### Source Files

- `packages/workshop-backend/src/admin-config.ts`
- `packages/workshop-backend/src/admin-settings.ts`
- `packages/workshop-backend/src/provisioning-policy.ts`
- `packages/workshop-shared/src/api.ts`
- `packages/workshop-backend/src/user.ts`
- `AGENTS.md`

---
title: "Admin configuration reference"
description: "The `AdminConfig` schema and its defaults: `signupsEnabled`, `siteName`, `instanceInstructions`, `announcement`, `banner`, `accentColor`, `disabledResources`, `disabledGatekeepers`, `ambientGatekeeperModes`, and `formats`. Covers length limits, the `AdminSettings` DO as sole writer mirroring to the reserved `.adminConfig` KV key, `readAdminConfig` hot-path reads, and why auth config is deliberately excluded."
---

`AdminConfig` is a single deployment-wide object declared in `packages/workshop-backend/src/admin-config.ts`, owned by the `AdminSettings` Durable Object and mirrored into one reserved `BLUEPRINTS` KV key (`ADMIN_CONFIG_KEY`). It carries only the "soft" deployment customizations — branding, agent instructions, and which gatekeeper connectors/resources and output formats are offered. Every field is enabled by default; the admin UI opts things *out*. Hot paths (`getServerConfig()` on each (re)connect, and the agent) resolve it with a single cheap KV get via `readAdminConfig` rather than touching the singleton DO.

## Schema

```ts
// packages/workshop-backend/src/admin-config.ts
export type AdminConfig = {
  signupsEnabled: boolean;
  siteName: string;
  siteLogoConfigured: boolean;
  instanceInstructions: string;
  announcement: string;
  banner: BannerConfig;
  accentColor: string;
  disabledResources: Record<string, string[]>;
  disabledGatekeepers: string[];
  ambientGatekeeperModes: Record<string, AmbientGatekeeperMode>;
  formats: FormatCuration[];
};
```

<ParamField body="signupsEnabled" type="boolean" required>
Whether new account signups are allowed. An access toggle only — which auth providers exist and whether password login is on stay env-driven.
</ParamField>

<ParamField body="siteName" type="string" required>
Site name shown next to the top-bar logo, or `""` to fall back to `DEFAULT_SITE_NAME`. Resolve for display with `resolveSiteName()`. Bounded by `MAX_SITE_NAME_LENGTH`.
</ParamField>

<ParamField body="siteLogoConfigured" type="boolean" required>
Whether this deployment has a custom site logo. The image bytes are stored separately (R2, `SITE_LOGO_R2_KEY`), not in the config object.
</ParamField>

<ParamField body="instanceInstructions" type="string" required>
Extra instructions appended to the agent system prompt. Bounded by `MAX_INSTANCE_INSTRUCTIONS_LENGTH`.
</ParamField>

<ParamField body="announcement" type="string" required>
Centered top-bar notice, rendered as Markdown. Bounded by `MAX_ANNOUNCEMENT_LENGTH`.
</ParamField>

<ParamField body="banner" type="BannerConfig" required>
Full-width banner: `{ text, color }`. Colors are validated with `isBannerColor`; the default is `DEFAULT_BANNER_COLOR`.
</ParamField>

<ParamField body="accentColor" type="string" required>
Accent (brand) color as a hex string, validated with `isHexColor`, or `""` for the default theme.
</ParamField>

<ParamField body="disabledResources" type="Record<string, string[]>" required>
Disabled gatekeeper resources, keyed by `vendorId` to the list of disabled resource `urlPattern`s. Read back through `isResourceDisabled` / `filterEnabledResources`.
</ParamField>

<ParamField body="disabledGatekeepers" type="string[]" required>
Vendor ids that are fully disabled for the deployment.
</ParamField>

<ParamField body="ambientGatekeeperModes" type="Record<string, AmbientGatekeeperMode>" required>
Per-vendor provisioning mode for auto-provisioning ("ambient") gatekeepers. Keys are lowercased vendor ids. An absent entry means the default. Only meaningful for vendors declaring `VendorDescription.autoProvisionsAccount`.
</ParamField>

<ParamField body="formats" type="FormatCuration[]" required>
The blueprints offered as this deployment's standard output formats — what a user gets from "New Slides", and what the agent is told to prefer. Array order is menu order.
</ParamField>

### Defaults

`DEFAULT_ADMIN_CONFIG` is the value the `adminConfig` storage singleton starts at, so a deployment that has never been configured behaves as fully permissive:

| Field | Default |
|---|---|
| `signupsEnabled` | `true` |
| `siteName` | `""` |
| `siteLogoConfigured` | `false` |
| `instanceInstructions` | `""` |
| `announcement` | `""` |
| `banner` | `{ text: "", color: DEFAULT_BANNER_COLOR }` |
| `accentColor` | `""` |
| `disabledResources` | `{}` |
| `disabledGatekeepers` | `[]` |
| `ambientGatekeeperModes` | `{}` |
| `formats` | `[]` |

### Length limits

| Constant | Applies to | Defined in |
|---|---|---|
| `MAX_SITE_NAME_LENGTH` | `siteName` | `@gadgets/workshop-shared/api` |
| `MAX_ANNOUNCEMENT_LENGTH` | `announcement` | `@gadgets/workshop-shared/api` |
| `MAX_INSTANCE_INSTRUCTIONS_LENGTH` | `instanceInstructions` | `@gadgets/workshop-shared/api` |
| `MAX_AGENT_HINT` (`400`) | `FormatCuration.agentHint` | `admin-config.ts` |

<Note>
`MAX_AGENT_HINT` is a prompt budget, not a validation limit: every enabled format's `agentHint` goes into the system prompt on every turn. `parseFormats` trims and truncates with `.slice(0, MAX_AGENT_HINT)` rather than rejecting the entry.
</Note>

## Format curation

```ts
export type FormatCuration = {
  blueprintId: string;
  enabled: boolean;
  agentHint?: string;
  overrides?: Partial<BlueprintOutput>;
};
```

The blueprint itself supplies the noun, plural, and icon, so improving a blueprint improves every deployment that has not overridden it. `overrides` substitutes the deployment's own presentation (e.g. an org that calls its decks "Briefings"); absent fields fall back to the blueprint's declaration and are cleaned by `sanitizeOutputOverrides` (which returns `undefined` when no field survives).

Disabling a format keeps the entry and its overrides around, so re-enabling does not lose an admin's edits. `formats` is separate from a blueprint's own `BlueprintMetadata.output` declaration: any user can publish a blueprint calling itself a Document, but only this list decides what the deployment offers.

### Parsing and reordering

- `parseFormats(value)` accepts only well-formed entries: non-object entries, entries without a non-empty string `blueprintId`, and duplicate ids are skipped; `enabled` defaults to `true` unless it is exactly `false`.
- `reorderFormats(formats, blueprintIds)` returns `formats` rearranged into the given order and throws `"Format order must list each promoted format exactly once."` unless `blueprintIds` is a permutation of what is promoted. Uniqueness is checked separately from length because the lookup `Map` dedupes — `[A, A]` against promoted `[A, B]` would otherwise pass both a length and a membership test, drop `B`, and leave a duplicate that makes every later reorder throw.
- `defaultOutputFormatId(blueprintId)` gives a stable grouping id for a promoted blueprint that declares no output: ids of 40 characters or fewer pass through unchanged, longer ones become `` `${blueprintId.slice(0, 31)}-${fingerprint(blueprintId)}` ``.
- `fingerprint(text)` is FNV-1a rendered as eight hex characters — synchronous (so callers need not be async, unlike `crypto.subtle.digest()`) and compared only for equality, so nothing depends on collision resistance.

## Ambient gatekeeper modes

`provisioning-policy.ts` is the single chokepoint for resolving `ambientGatekeeperModes`. `UserDurableObject` reads `AdminConfig` and calls these helpers when provisioning, listing, and surfacing ambient accounts.

| Mode | Behavior |
|---|---|
| `disabled` | Not available; no account is provisioned, and any existing one stays dormant. |
| `optional` | **Default.** Users opt in from the Connectors page; not forced on anyone. |
| `enabled` | Auto-provisioned for every user (forced); hidden from the Connectors list and not user-removable. |

```ts
// packages/workshop-backend/src/provisioning-policy.ts
export const DEFAULT_AMBIENT_GATEKEEPER_MODE: AmbientGatekeeperMode = "optional";

export function ambientGatekeeperMode(config: AdminConfig, vendorId: string): AmbientGatekeeperMode {
  return config.ambientGatekeeperModes?.[vendorId.toLowerCase()] ?? DEFAULT_AMBIENT_GATEKEEPER_MODE;
}

export function shouldAutoProvisionAccount(config: AdminConfig, vendorId: string): boolean {
  return ambientGatekeeperMode(config, vendorId) === "enabled";
}
```

`ambientGatekeeperMode` uses optional chaining on `ambientGatekeeperModes` deliberately: it tolerates a config persisted before that field existed. The default is `optional` because ambient authority is not imposed on every user unless an admin explicitly turns it on — consistent with the kernel rule that a resource becomes ambient only by user/admin configuration and never by a gatekeeper asserting its own ambience.

## Storage: one writer, one KV mirror

```mermaid
flowchart LR
  subgraph admin["Admin surface"]
    api["AdminApi (Cap'n Web)<br/>admin-settings.ts"]
  end

  subgraph do["AdminSettings DO — getByName(\"\")"]
    single["typed-storage singletons:<br/>adminConfig / installedFormatBlueprints /<br/>promotedFormatBlueprints"]
    coll["collection featuredBlueprints"]
    tail["adminConfigMutationTail<br/>siteLogoMutationTail"]
  end

  subgraph stores["Shared stores"]
    kv["BLUEPRINTS KV<br/>ADMIN_CONFIG_KEY (.adminConfig)<br/>FEATURED_BLUEPRINTS_KEY"]
    r2["R2 — SITE_LOGO_R2_KEY"]
  end

  subgraph hot["Hot paths (read-only)"]
    cfg["getServerConfig() / login"]
    user["UserDurableObject<br/>provisioning + resource listing"]
    agent["agent system prompt"]
  end

  api --> tail --> single
  single -->|serializeAdminConfig| kv
  api --> r2
  coll --> kv
  kv -->|readAdminConfig| cfg
  kv -->|readAdminConfig| user
  kv -->|readAdminConfig| agent
```

The `AdminSettings` Durable Object is always addressed as `getByName("")`. It holds settings only admins may modify, and it publishes them to KV so ordinary user requests never touch the singleton DO (which they could otherwise overload). Having exactly one DO write the KV key also avoids races when updating it.

Within the DO, every config setter writes the same authoritative `adminConfig` singleton plus its KV mirror, so the full read/modify/write is serialized on `adminConfigMutationTail` — otherwise external KV I/O could let concurrent setters lose updates. Site logo changes are serialized separately on `siteLogoMutationTail`, because R2 and the config are distinct stores and reset/upload must not interleave while switching whether the fixed public object is enabled.

<Warning>
`ADMIN_CONFIG_KEY` (`.adminConfig`) and `FEATURED_BLUEPRINTS_KEY` are reserved keys in the `BLUEPRINTS` namespace, screened by `isReservedBlueprintKey`. Do not write them from anywhere but `AdminSettings`, and do not treat them as blueprint records.
</Warning>

### Reading on the hot path

`readAdminConfig` (exported from `admin-config.ts`, taking a `BlueprintKvEnv`) is the read entry point; consumers import it rather than reaching for the DO. `user.ts` imports `readAdminConfig`, `filterEnabledResources`, and `isResourceDisabled` together, resolving admin policy inside `UserDurableObject` with a single KV get.

## Bundled format installation and one-shot promotion

`AdminSettings.ensureFormatBlueprintsInstalled()` installs the format blueprints bundled with the deployment. It is idempotent and cheap — an up-to-date deployment does one string comparison against the `installedFormatBlueprints` singleton (`formatBlueprintsManifestVersion()`) and returns. Callers are coalesced onto one in-flight run (`#installInFlight`), because two isolates racing on a fresh deployment would otherwise both promote the same blueprints, and a duplicated id makes `setFormatOrder()` reject every reordering.

<Steps>
<Step title="Compare the manifest stamp">
If `installedFormatBlueprints` already equals `formatBlueprintsManifestVersion()`, skip installation.
</Step>
<Step title="Install and mirror featured records">
`installFormatBlueprints(env)` returns the installed `BlueprintPublicInfo` records. They are written straight into the `featuredBlueprints` mirror rather than through `setBlueprintFeatured()`, whose authoritative bit lives in the publishing user's DO — these have no owning user. Then `#writeFeaturedSnapshot()` publishes the snapshot.
</Step>
<Step title="Stamp only on a complete install">
`complete` is `installed.length === FORMAT_BLUEPRINTS.length`. The stamp is written only when complete, so a crash or one bad archive retries next time; recording a partial install as complete would strand the failed entries until the manifest changed again. A partial install resolves `false` instead of throwing — the caller has nothing to handle but does need to know to ask again.
</Step>
<Step title="Check promotion every run">
`#promoteBundledFormats()` runs on every pass, not just after an install, so a deployment that installed before curation existed still ends up offering its bundled formats.
</Step>
</Steps>

Promotion is tracked in the separate `promotedFormatBlueprints` singleton so each blueprint is offered into `AdminConfig.formats` exactly once, ever. Re-deriving the list from the manifest would undo an admin's removal on every startup; tracking it separately means an admin who removes a format keeps it removed, while an updated archive still refreshes the blueprint without resetting the deployment's curation.

## Why auth config is excluded

Authentication and authorization configuration — which sign-in providers exist, whether password login is on — is deliberately **not** in `AdminConfig`. It stays environment-variable driven (see `auth/config.ts`) so it cannot be changed by a compromised admin session. `signupsEnabled` sits in `AdminConfig` only because it is an access toggle, not authentication config.

<Info>
Because the config is opt-out, the safe posture for a public deployment is not "leave the defaults" — the defaults offer every connector and resource and allow signups. Set `signupsEnabled`, `disabledGatekeepers`, `disabledResources`, and `ambientGatekeeperModes` explicitly.
</Info>

## Related pages

<CardGroup cols={2}>
<Card title="Environment variables" href="/environment-variables">
The env-var surface that owns auth and billing config, including `ADMINS`, `AUTH_GATEKEEPERS`, and `DISABLE_PASSWORD_AUTH`.
</Card>
<Card title="Configure sign-in and AI Gateway billing" href="/configure-signin-and-billing">
Why sign-in posture is env-driven and how allowlisting and identity keying work.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`AdminApi` and the `ServerConfig` shape returned to clients at boot.
</Card>
<Card title="Manage bundled format blueprints" href="/bundled-format-blueprints">
The `.gadget` + `.json` sidecar split, `FORMAT_BLUEPRINTS_DIR`, and first-request installation.
</Card>
<Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
`VendorDescription.autoProvisionsAccount`, resource URL-pattern matching, and ambient mode resolution.
</Card>
<Card title="Blueprints" href="/blueprints">
Blueprint ids, `BlueprintOutput`, and the propagation path behind promoted formats.
</Card>
</CardGroup>

---

## 18. Routing and worker bindings

> How the router resolves requests: `/api/*` and `/blueprint-screenshot/*` to the backend, `/gatekeeper/<name>/*` derived by lowercasing `GATEKEEPER_*` env keys, everything else to `ASSETS` or the dev fallback, and inbound email dispatch to `GATEKEEPER_EMAIL`. Lists backend bindings (`BLUEPRINTS`, `BLUEPRINT_CONTENT`, `AVATARS`, `LOADER`, `BROWSER`, optional reporter and rate limiter) and DO migration tags.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/18-routing-and-worker-bindings.md
- Generated: 2026-08-05T16:41:08.530Z

### Source Files

- `packages/router/src/index.ts`
- `packages/workshop-backend/wrangler.jsonc`
- `wrangler.jsonc`
- `packages/workshop-backend/src/env.d.ts`
- `packages/gatekeeper-email/README.md`
- `packages/workshop-backend/src/client-errors.ts`

---
title: "Routing and worker bindings"
description: "How the router resolves requests: `/api/*` and `/blueprint-screenshot/*` to the backend, `/gatekeeper/<name>/*` derived by lowercasing `GATEKEEPER_*` env keys, everything else to `ASSETS` or the dev fallback, and inbound email dispatch to `GATEKEEPER_EMAIL`. Lists backend bindings (`BLUEPRINTS`, `BLUEPRINT_CONTENT`, `AVATARS`, `LOADER`, `BROWSER`, optional reporter and rate limiter) and DO migration tags."
---

`packages/router/src/index.ts` is the public origin of a Cloudflare OS instance. It exports a single `ExportedHandler<Env>` with a `fetch` handler that dispatches by path prefix and an `email` handler that forwards inbound mail. Routing configuration *is* the binding set: gatekeeper prefixes are derived at request time by scanning `env` for keys starting with `GATEKEEPER_`, so installing a gatekeeper only requires re-deploying the router with one more service binding — no router code changes.

## Router env shape

```ts
// packages/router/src/index.ts
export interface Env {
  WORKSHOP_BACKEND: Fetcher;
  // Present in production (wrangler.jsonc assets stanza); absent in dev.
  ASSETS?: Fetcher;
  // Dormant until custom domains + Email Routing exist; the handler ships anyway.
  GATEKEEPER_EMAIL?: Service<EmailEntrypoint>;
  [key: string]: unknown;
}
```

`EmailEntrypoint` is `CloudflareWorkersModule.WorkerEntrypoint` narrowed with `Required<Pick<…, "email">>`, so `GATEKEEPER_EMAIL` is typed as a service whose optional `email()` handler is known to be present.

<ParamField body="WORKSHOP_BACKEND" type="Fetcher" required>
Service binding to the `workshop-backend` worker. Receives `/api*` and `/blueprint-screenshot*`, plus everything else when `ASSETS` is unbound.
</ParamField>

<ParamField body="ASSETS" type="Fetcher">
Static-asset fetcher. Bound in production via the wrangler `assets` stanza; deliberately absent in dev so frontend requests fall through to the backend.
</ParamField>

<ParamField body="GATEKEEPER_EMAIL" type="Service<EmailEntrypoint>">
Optional. Target of the `email()` handler. Absent means inbound mail is rejected.
</ParamField>

<ParamField body="[key: string]" type="unknown">
Index signature. Any additional `GATEKEEPER_*` service binding is discovered generically by the prefix scan.
</ParamField>

## Resolution order

The `fetch` handler evaluates in a fixed order; the first match wins.

```mermaid
flowchart TD
  REQ["fetch(req, env)"] --> GK{"any GATEKEEPER_* key whose\nderived prefix matches pathname?"}
  GK -- yes --> GKW["env[key].fetch(req)\n(gatekeeper Worker)"]
  GK -- no --> API{"/api, /api/*,\n/blueprint-screenshot,\n/blueprint-screenshot/*"}
  API -- yes --> BE["env.WORKSHOP_BACKEND.fetch(req)"]
  API -- no --> ASSETS{"env.ASSETS bound?"}
  ASSETS -- "yes (prod)" --> A["env.ASSETS.fetch(req)"]
  ASSETS -- "no (dev)" --> BE

  subgraph Origin["router worker"]
    REQ; GK; API; ASSETS
  end
  subgraph Downstream["service bindings"]
    GKW; BE; A
  end
```

| Order | Match | Target |
| --- | --- | --- |
| 1 | `pathname === /gatekeeper/<suffix>` or starts with `/gatekeeper/<suffix>/` | `env[GATEKEEPER_*]` |
| 2 | `/api`, `/api/*`, `/blueprint-screenshot`, `/blueprint-screenshot/*` | `env.WORKSHOP_BACKEND` |
| 3 | anything else, `ASSETS` bound | `env.ASSETS` |
| 4 | anything else, `ASSETS` unbound | `env.WORKSHOP_BACKEND` |

<Note>
Gatekeeper matching runs before the `/api` check, so a gatekeeper binding named such that its prefix collides with an API path would shadow the backend. The exact-or-slash test (`pathname === prefix || pathname.startsWith(prefix + "/")`) prevents a prefix like `/gatekeeper/git` from swallowing `/gatekeeper/github`.
</Note>

## Gatekeeper prefix derivation

Each `env` key is transformed into a path prefix:

```ts
const suffix = key.slice("GATEKEEPER_".length).toLowerCase().replaceAll("_", "-");
const prefix = `/gatekeeper/${suffix}`;
```

| Env key | Derived prefix | Matches |
| --- | --- | --- |
| `GATEKEEPER_EMAIL` | `/gatekeeper/email` | `/gatekeeper/email`, `/gatekeeper/email/mailbox/myinbox` |
| `GATEKEEPER_GITHUB` | `/gatekeeper/github` | `/gatekeeper/github`, `/gatekeeper/github/oauth` |
| `GATEKEEPER_GOOGLE_DRIVE` | `/gatekeeper/google-drive` | `/gatekeeper/google-drive/*` |

The matched request is forwarded unmodified: `return (env[key] as Fetcher).fetch(req)` — the router does not strip the prefix, so gatekeeper Workers see the full path.

<Warning>
Gatekeeper OAuth redirects land on the gatekeeper Workers themselves at `/gatekeeper/<name>/oauth`, handled by this same prefix loop. There are no backend `/auth` callback routes.
</Warning>

## Frontend and dev fallback

When `ASSETS` is bound (the production wrangler `assets` stanza), every non-API, non-gatekeeper request goes to the asset fetcher. When it is absent, the request goes to `WORKSHOP_BACKEND` instead. That fallback is what makes one worker serve both roles:

- **`run-local` mode** — the backend has its own static `assets` binding with `run_worker_first` for the API routes, so it serves the pre-built single-page app for frontend requests arriving through the fallback.
- **normal dev mode** — the backend has no assets and frontend requests are not expected on the router; run `pnpm dev-client` and open `localhost:3000` directly. The router deliberately does not proxy to `localhost:3000` because Vite's HMR socket drops every time wrangler restarts workerd.

The repo-root `wrangler.jsonc` defines the dev router:

```jsonc
// wrangler.jsonc (repo root)
{
  "name": "dev-router",
  "main": "packages/router/src/index.ts",
  "compatibility_date": "2025-11-01",
  "compatibility_flags": ["enable_ctx_exports"],
  "services": [
    { "binding": "WORKSHOP_BACKEND", "service": "workshop-backend" }
  ]
}
```

Gatekeeper service bindings are added to this file dynamically by `run-dev-server.js`; no `ASSETS` binding is configured here, which is what activates the dev fallback branch.

## Inbound email dispatch

```ts
async email(message, env) {
  if (!env.GATEKEEPER_EMAIL) {
    message.setReject("No email gatekeeper is installed on this instance.");
    return;
  }
  await env.GATEKEEPER_EMAIL.email(message);
}
```

The router performs no parsing or addressing of its own; it hands the raw `message` to the email gatekeeper's `email()` entrypoint. Inside `packages/gatekeeper-email`, the recipient local part selects an `EmailAddress` Durable Object, which loads the stored hook `Fetcher` from its KV storage and invokes the Gadget's `receiveEmail()` with a postal-mime–parsed email.

```
Internet email → Cloudflare Email Routing → router.email()
                                               │
                                               ▼
                                     GATEKEEPER_EMAIL.email(message)
                                               │
                                               ├── email() handler parses recipient
                                               ▼
                                     EmailAddress DO (per username)
                                               │
                                               ├── loads stored hook Fetcher
                                               ▼
                                     Gadget's hook entrypoint
                                     (via Overseer loopback)
```

If no hook is configured for the address, the email is rejected. Mailbox local parts are canonicalized to lowercase and may contain letters, numbers, dots, underscores, plus signs, or hyphens; they cannot start or end with a dot or contain consecutive dots.

<Info>
The email gatekeeper's own `BASE_URL` must be the full base URL (protocol + host + optional path, no trailing slash) at which its `fetch` handler is served — e.g. `https://app.example.com/gatekeeper/email` when co-hosted behind this router, matching the prefix derived from `GATEKEEPER_EMAIL`.
</Info>

### Simulating inbound mail locally

Wrangler exposes `/cdn-cgi/handler/email` in local dev. With `pnpm run dev-server` running:

<RequestExample>
```bash Send a test email
curl -X POST 'http://localhost:8787/cdn-cgi/handler/email' \
  --url-query 'from=sender@example.com' \
  --url-query 'to=myinbox@example.com' \
  --header 'Content-Type: application/json' \
  --data-raw 'From: "Alice" <sender@example.com>
To: myinbox@example.com
Subject: Hello from local dev
Content-Type: text/plain; charset="utf-8"
Date: Mon, 16 Feb 2026 12:00:00 +0000
Message-ID: <test-123@example.com>

This is a test email body.'
```
</RequestExample>

Real SMTP delivery is not supported locally. In production, Cloudflare Email Routing must be enabled with an Email Workers route (for example a catch-all `*@yourdomain.com`) whose action sends to the deployed `gatekeeper-email` worker.

## Backend worker bindings

`packages/workshop-backend/wrangler.jsonc` declares the storage and runtime bindings the backend receives.

| Binding | Type | Config | Purpose |
| --- | --- | --- | --- |
| `BLUEPRINTS` | `KVNamespace` | `preview_id: gadgets-blueprint-metadata` | Blueprint metadata lookup |
| `BLUEPRINT_CONTENT` | `R2Bucket` | `bucket_name: gadgets-blueprint-content` | Blueprint code snapshots |
| `AVATARS` | `KVNamespace` | `preview_id: gadgets-avatars` | User avatar images |
| `LOADER` | worker loader | `worker_loaders[].binding` | Loads Dynamic Workers |
| `BROWSER` | `BrowserRun` | `browser.binding` | Renders Gadget exports; optional for self-hosted |
| `PRODUCT_ANALYTICS` | `Pipeline<ProductAnalyticsRecord>` | not in wrangler.jsonc | Optional analytics stream; no-ops when unbound |
| `FRONTEND_ERROR_REPORTER` | `Service<ErrorReporter>` | not in wrangler.jsonc | Optional browser error forwarding |
| `FRONTEND_ERROR_RATE_LIMITER` | `RateLimit` | not in wrangler.jsonc | Optional per-key limit on browser reports |

Gatekeeper service bindings and the Workers AI binding are **not** declared in `packages/workshop-backend/wrangler.jsonc`; they are added dynamically by `run-dev-server.js` (dev) and `generate-wrangler-prod.js` (production). `GATEKEEPER_*` bindings are also deliberately absent from `packages/workshop-backend/src/env.d.ts` — the backend discovers them generically by prefix scan (`buildGatekeeperVendorMap`) and never references a specific gatekeeper by name.

### Optional error-reporting pair

`FRONTEND_ERROR_REPORTER` and `FRONTEND_ERROR_RATE_LIMITER` must both be present before any browser report dispatches. `handleClientErrorRequest` in `packages/workshop-backend/src/client-errors.ts` short-circuits otherwise:

```ts
const reporter = env.FRONTEND_ERROR_REPORTER;
const limiter = env.FRONTEND_ERROR_RATE_LIMITER;
if (!reporter || !limiter) return new Response(null, { status: 204 });
```

Behavior of that endpoint:

| Condition | Response |
| --- | --- |
| Method is not `POST` | `405` with `allow: POST` |
| `origin` header ≠ request origin | `403 Cross-origin API access not allowed.` |
| `content-type` is not `application/json` | `415 Expected application/json.` |
| Reporter or limiter unbound | `204` |
| `CF_ACCESS_AUD` set and JWT invalid | `403 Invalid CF access JWT.` |
| Access JWT carries no user identity | `403 Access JWT didn't specify a user identity.` |
| Rate limit not `success`, or limiter throws | `204` |
| Body exceeds `MAX_BODY_BYTES` (128 KiB) | `413 Payload Too Large` |
| Body is unreadable or not JSON | `400 Invalid JSON` |
| `normalizeFrontendErrorReport` rejects the payload | `400 Invalid frontend error report` |
| Accepted | `204`, dispatch via `ctx.waitUntil` |

The rate-limit key is the Access-derived identity when `CF_ACCESS_AUD` is set, otherwise the `cf-connecting-ip` header, falling back to the literal `"unknown"`. Dispatch failures are logged at debug level under `component: "workshop.client-errors"` and never affect the UI response.

## Compatibility flags

`packages/workshop-backend/wrangler.jsonc` sets `compatibility_date: "2026-02-02"` and these flags:

| Flag | Reason |
| --- | --- |
| `allow_irrevocable_stub_storage` | RPC stub storage |
| `enhanced_error_serialization` | Error propagation |
| `global_fetch_strictly_public` | SSRF protection for the global `fetch()`, notably the `webFetch` agent tool |
| `nodejs_compat` | Provider SDKs (`@anthropic-ai/sdk`, `openai`, `@google/genai`) and Puppeteer for PDF exports |

<Warning>
`wrangler dev` intentionally reconfigures its global outbound to permit fetching any address so local services stay reachable, so `global_fetch_strictly_public` effectively only takes effect in production or when running `workerd` stand-alone. Under stand-alone `workerd`, blocking private-network addresses is already the default.
</Warning>

The router's own `compatibility_flags` are `["enable_ctx_exports"]` at `compatibility_date: "2025-11-01"`.

## Durable Object migration tags

All backend DO classes are reached via `ctx.exports` and need no explicit `durable_objects` binding — only migration entries.

:::updates

@update v0 - Initial SQLite classes: `UserDurableObject`, `OverseerDurableObject`.

@update v1 - Adds `AdminSettings`.

@update v2 - Adds `PendingLogin`: sign-in via authentication gatekeepers uses a short-lived DO to bridge each gatekeeper login back to the waiting browser.

:::

```jsonc
// packages/workshop-backend/wrangler.jsonc
"migrations": [
  { "tag": "v0", "new_sqlite_classes": [ "UserDurableObject", "OverseerDurableObject" ] },
  { "tag": "v1", "new_sqlite_classes": [ "AdminSettings" ] },
  { "tag": "v2", "new_sqlite_classes": [ "PendingLogin" ] }
]
```

## Production asset hosting

The backend can host the frontend itself rather than relying on a router `ASSETS` binding. The commented stanza in `packages/workshop-backend/wrangler.jsonc` shows the shape, with `run_worker_first` reserving the backend-owned routes:

```jsonc
// "assets": {
//   "directory": "../workshop-frontend/dist",
//   "not_found_handling": "single-page-application",
//   "run_worker_first": ["/api", "/api/*", "/blueprint-screenshot/*"]
// },
```

<Check>
The `run_worker_first` list mirrors the router's backend-routed prefixes (`/api*` and `/blueprint-screenshot/*`). Keep the two in sync when adding a backend-owned path, or the SPA handler will intercept it.
</Check>

Observability on the backend is enabled with `head_sampling_rate: 1` and `logs.invocation_logs: false`.

## Related pages

<CardGroup cols={2}>
  <Card title="Environment variables" href="/environment-variables">
    Every backend environment variable and its default, including `PUBLIC_BASE_URL`, the `CF_AI_GATEWAY*` family, and `CF_ACCESS_AUD`/`CF_ACCESS_ISS`.
  </Card>
  <Card title="Local development" href="/local-development">
    The two-terminal workflow, generated dev wrangler files, and dynamic gatekeeper service-binding discovery.
  </Card>
  <Card title="Build a gatekeeper" href="/build-a-gatekeeper">
    Add a connector package and install it by adding a `GATEKEEPER_*` binding.
  </Card>
  <Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
    The `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` redirect-URI contract and per-connector secrets.
  </Card>
  <Card title="Gadgets and sandboxing" href="/gadgets-and-sandboxing">
    How the `LOADER` binding and `global_fetch_strictly_public` isolate a gadget.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    No-op error reporting without its bindings, and other known failure modes.
  </Card>
</CardGroup>

---

## 19. Build, lint, and test

> The commands CI enforces and their ordering: `pnpm build` (recursive), `pnpm lint` composed of `lint:check` (oxlint) and `types:check` (recursive `tsc --noEmit`), and `pnpm test` (`node --test scripts/*.test.js` plus per-package suites). Documents generator prerequisites, the oxlint rule posture, and why type-aware rules are disabled in this monorepo.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/19-build-lint-and-test.md
- Generated: 2026-08-05T16:41:40.342Z

### Source Files

- `package.json`
- `.oxlintrc.json`
- `.gitlab-ci.yml`
- `packages/workshop-backend/package.json`
- `tsconfig.json`
- `AGENTS.md`

---
title: "Build, lint, and test"
description: "The commands CI enforces and their ordering: `pnpm build` (recursive), `pnpm lint` composed of `lint:check` (oxlint) and `types:check` (recursive `tsc --noEmit`), and `pnpm test` (`node --test scripts/*.test.js` plus per-package suites). Documents generator prerequisites, the oxlint rule posture, and why type-aware rules are disabled in this monorepo."
---

The repository root is a private pnpm workspace named `gadgets` whose `package.json` scripts fan out recursively to packages. CI runs two blocking jobs on merge requests and on the default branch: a `lint` job that runs `pnpm lint`, and a `test` job that runs `pnpm build` followed by `pnpm test`. Both jobs share a `before_script` that downloads a pinned Node 22.14.0 tarball, verifies its SHA-256, enables pnpm via `corepack enable`, initializes git submodules, and installs with `pnpm install --frozen-lockfile`.

## Root scripts

| Script | Command |
| --- | --- |
| `build` | `pnpm run --recursive build` |
| `test` | `node --test scripts/*.test.js && pnpm run --recursive --if-present test` |
| `lint` | `pnpm run lint:check && pnpm run types:check` |
| `lint:check` | `oxlint` |
| `lint:fix` | `oxlint --fix` |
| `types:check` | `pnpm run --recursive --if-present types:check` |
| `clean` | `pnpm run --recursive clean` |
| `run-local` | `node scripts/run-local.mjs` |
| `dev-server` | `node run-dev-server.js` |
| `dev-client` | `cd packages/workshop-frontend && pnpm run dev` |

Root dev dependencies pin the toolchain: `oxlint ^1.76.0`, `typescript ^5.9.3`, `vite ^7.3.6`, `wrangler ^4.115.0`, plus `aws4fetch` and `jsonc-parser`.

<Note>
`test` and `types:check` use `--if-present`, so a package without that script is skipped rather than failing the run. `build` is recursive without `--if-present`.
</Note>

## What CI enforces, in order

<Steps>
<Step title="Environment setup (both jobs)">
Pinned Node is fetched and checksum-verified, `corepack enable` provides pnpm, `git submodule update --init` populates submodules, then `pnpm install --frozen-lockfile`. A lockfile that does not match `package.json` fails install before any script runs.
</Step>
<Step title="lint job">
`pnpm lint` → `oxlint` across the workspace, then recursive `tsc --noEmit` via `types:check`. Lint is blocking.
</Step>
<Step title="test job">
`pnpm build` first, then `pnpm test`. The build step is part of the test job's script, so a broken build fails the test job.
</Step>
<Step title="review stage (merge requests only)">
An included CI component (`cloudflare/ci/ai/opencode`) runs AI code review in the `review` stage with `runOnMR: true` and `ASSIGN_REVIEWERS: false`. It is not part of build/lint/test.
</Step>
</Steps>

The two `test`-stage jobs run on the `vm-linux-x86-4cpu-8gb` runner tag under shared rules: merge request events and the default branch.

```text
install (pinned node + corepack + submodules + frozen lockfile)
   │
   ├── lint job ──► pnpm lint ──► oxlint ──► types:check (recursive tsc --noEmit)
   │
   └── test job ──► pnpm build (recursive) ──► pnpm test
                                                 ├── node --test scripts/*.test.js
                                                 └── recursive per-package test
```

## Generator prerequisites

Several packages generate source before the compiler or test runner sees it, so generators are wired into each script rather than into a single global prebuild step. In `packages/workshop-backend`, `build`, `types:check`, `test`, `test:integration`, and `test:watch` all run `node build-browser-runtime.mjs` and `node scripts/build-format-blueprints.mjs` first:

```json
{
  "build": "node build-browser-runtime.mjs && node scripts/build-format-blueprints.mjs && tsc",
  "types:check": "node build-browser-runtime.mjs && node scripts/build-format-blueprints.mjs && tsc --noEmit",
  "test": "node build-browser-runtime.mjs && node scripts/build-format-blueprints.mjs && vitest run && vitest run --config vitest.integration.config.ts"
}
```

`scripts/build-format-blueprints.mjs` globs `format-blueprints/` (each blueprint is a `<name>.gadget` archive plus a `<name>.json` sidecar) into the gitignored `src/generated/format-blueprints.ts`. `FORMAT_BLUEPRINTS_DIR` overrides the source directory so a fork can ship its own set. Because the output is gitignored, `build`, `types:check`, and `test` each run the generator so a clean checkout compiles.

Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.mjs` as part of package builds; `packages/gatekeeper-context` bundles its `app/` SPA with `build-app.mjs` into `src/generated/app.txt`.

<Warning>
Do not run `tsc --noEmit` directly in a package with generated inputs on a fresh checkout — go through the package's `types:check` script so the generators run first. Missing generated modules on a clean checkout is a known failure mode.
</Warning>

Backend package scripts that are not part of the root pipeline:

| Script | Purpose |
| --- | --- |
| `build:worker` | `node build-browser-runtime.mjs && pnpm exec capnweb-validate build --out .wrangler/validate` |
| `build:format-blueprints` | Run the format-blueprint generator alone |
| `import:format-blueprint` | `node --experimental-strip-types --no-warnings scripts/import-format-blueprint.mjs` |
| `test:integration` | Generators, then `vitest run --config vitest.integration.config.ts` |
| `test:watch` | Generators, then `vitest` in watch mode |
| `dev` | Errors out with a pointer to run `pnpm dev-server` at the root |
| `clean` | `rm -rf dist` |

## Test layers

`pnpm test` runs two layers:

- `node --test scripts/*.test.js` — the root Node test runner over repository scripts.
- `pnpm run --recursive --if-present test` — per-package suites. `workshop-backend` runs Vitest twice: the default config, then `vitest.integration.config.ts`.

Vitest in the backend uses `@cloudflare/vitest-pool-workers` and `vitest ^4.1.10`.

## Type checking

Type safety is enforced by real `tsc`, not by the linter. The root `tsconfig.json` sets the shared compiler posture and is not itself a project that compiles files:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ESNext"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "allowJs": true,
    "strict": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": [],
  "exclude": ["node_modules", "dist"]
}
```

`include: []` means the root config contributes options only; each package's own `types:check` drives the actual check.

## oxlint posture

`.oxlintrc.json` enables the `correctness` and `suspicious` categories at `error`, with base plugins `typescript`, `unicorn`, `oxc`, and `import`, under `env.es2024`.

### Rules turned off, and why

| Rule | Setting | Reason |
| --- | --- | --- |
| `import/default` | `off` | Gatekeepers import `.txt` files as bundled text assets; the resolver reports them as having no default export. |
| `import/no-unassigned-import` | `off` | Side-effect imports are deliberate: CSS (`./styles.css`) and the `cloudflare:workers` runtime registration import. |
| `unicorn/no-empty-file` | `off` | Comment-only placeholder/reference modules are kept intentionally (for example `App.tsx`, `gatekeeper-cloudflare/src/types.d.ts`). |
| `no-underscore-dangle` | `off` | Conflicts with the convention of prefixing intentionally-unused bindings with `_`. |

### Rules kept as warnings

`no-shadow`, `typescript/no-this-alias`, `typescript/no-extraneous-class`, and `unicorn/consistent-function-scoping` are set to `warn`: considered genuine improvements but too churny to block CI during initial rollout, left visible for incremental cleanup.

### Unused-variable policy

```json
"no-unused-vars": [
  "error",
  {
    "args": "none",
    "caughtErrors": "none",
    "varsIgnorePattern": "^_",
    "ignoreRestSiblings": true
  }
]
```

Unused callback/interface parameters and catch bindings are not flagged; unused imports and local variables still are, and a `_` prefix opts a variable out.

### Ignored paths

```text
**/dist/**
**/generated/**
**/*.gen.ts
**/node_modules/**
**/.wrangler/**
**/worker-configuration.d.ts
```

Generated output is linted nowhere: `**/generated/**` and `**/*.gen.ts` cover the format-blueprint and configurator-UI outputs.

### Per-area overrides

| Files | Added plugins | Env |
| --- | --- | --- |
| `packages/workshop-frontend/**/*.{ts,tsx}` | `react`, `jsx-a11y` | `browser`, `es2024` |
| `packages/gatekeeper-*/**/*.tsx` | `react` | `browser`, `es2024` |
| `packages/workshop-backend/**/*.ts`, `packages/router/**/*.ts`, `packages/gatekeeper-*/src/**/*.ts`, `packages/workshop-shared/**/*.ts`, `packages/typed-storage/**/*.ts` | — | `serviceworker`, `es2024` |
| `**/*.test.ts`, `**/*.test.tsx`, `**/vitest.config.ts` | `vitest` | `vitest`, `es2024` |
| `scripts/**/*.mjs`, `*.js`, `*.mjs` | — | `node`, `es2024` |

Gatekeeper configurator UIs get the `react` plugin but not `jsx-a11y`, because they use a classic JSX runtime with the `h` pragma rather than the automatic react-jsx runtime.

### The capnweb import restriction

`packages/integration-tests/**/*.ts` forbids value imports of `capnweb`:

```json
"no-restricted-imports": [
  "error",
  {
    "paths": [
      {
        "name": "capnweb",
        "message": "Mint stubs via stubFor() from rpc-client: a consumer repo can hold two capnweb copies, and a stub from the wrong one fails to serialise. `import type` is fine.",
        "allowTypeImports": true
      }
    ]
  }
]
```

A repository that vendors this one as a submodule installs both workspaces, ending up with two copies of `capnweb`; a stub minted by one copy is unserializable by the other's session — a failure that only appears once installs are split, i.e. in CI. Value imports are therefore confined to `packages/integration-tests/src/rpc-client.ts`, which wraps stub minting in `stubFor()` and is the single file where the rule is turned back `off`. `import type` remains allowed everywhere.

## Why type-aware linting is disabled

The config comment states the constraint directly: the type-aware engine uses tsgo (TypeScript 7), which requires an explicit `rootDir` when emitting declarations and has dropped `baseUrl`. This monorepo emits declarations while importing sibling-package *source* files via `paths`, so any `rootDir` that satisfies tsgo would break the real `tsc` build with TS6059. Type-aware rules stay off and full type safety comes from `tsc` through `types:check`.

```text
sibling source imports via `paths` + declaration emit
        │
        ├── real tsc build: OK (no rootDir constraint)
        └── tsgo type-aware lint: needs rootDir ──► TS6059 in the real build
                                                   ⇒ type-aware rules off
```

## Local usage

<CodeGroup>
```bash title="Full CI-equivalent sweep"
pnpm install --frozen-lockfile
pnpm lint
pnpm build
pnpm test
```

```bash title="Lint only"
pnpm lint:check      # oxlint
pnpm lint:fix        # oxlint --fix
pnpm types:check     # recursive tsc --noEmit (runs generators)
```

```bash title="Backend package only"
pnpm --filter @gadgets/workshop-backend run types:check
pnpm --filter @gadgets/workshop-backend run test
pnpm --filter @gadgets/workshop-backend run test:integration
```
</CodeGroup>

<Check>
CI order is the safe local order too: `pnpm lint` catches oxlint and type errors without needing build artifacts, then `pnpm build` before `pnpm test` matches the test job exactly.
</Check>

<Warning>
This project is pnpm-only, and CI installs with `--frozen-lockfile`. Using npm or yarn locally will desynchronize the lockfile and fail the CI install step.
</Warning>

## Troubleshooting

<AccordionGroup>
<Accordion title="Cannot find module './generated/format-blueprints'">
The file is gitignored and produced by `scripts/build-format-blueprints.mjs`. Run the package's `build`, `types:check`, or `test` script (each runs the generator), or run `pnpm --filter @gadgets/workshop-backend run build:format-blueprints` directly.
</Accordion>
<Accordion title="oxlint reports 'no default export' on a .txt import">
That is the known false positive from bundled text assets; `import/default` is already `off` in `.oxlintrc.json`. If it still fires, confirm the file is not being linted under an override that reintroduces the rule.
</Accordion>
<Accordion title="Errors flagged only in CI for integration tests">
Check for a value `import ... from 'capnweb'` in `packages/integration-tests`. Convert it to `import type`, or mint stubs through `stubFor()` in `rpc-client.ts`. The duplicate-`capnweb` failure only materializes when the workspace is installed as a submodule alongside a consumer workspace.
</Accordion>
<Accordion title="Type error that oxlint did not catch">
Expected: type-aware linting is intentionally disabled. Run `pnpm types:check` (recursive `tsc --noEmit`) — that is the only type gate.
</Accordion>
<Accordion title="pnpm install fails in CI">
`--frozen-lockfile` rejects a lockfile that does not match the manifests, and the job also runs `git submodule update --init` beforehand — a missing submodule or a stale lockfile both fail before any script executes.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Installation" href="/installation">Prerequisites, pinned Node 22.14.0, and the install paths including `pnpm run-local`.</Card>
<Card title="Local development" href="/local-development">The two-terminal `pnpm dev-server` / `pnpm dev-client` workflow.</Card>
<Card title="Integration testing" href="/integration-testing">How `packages/integration-tests` boots real workers in workerd and speaks Cap'n Web.</Card>
<Card title="Developer conventions and contributing" href="/conventions-and-contributing">pnpm-only rules, kernel review standards, and RPC conventions.</Card>
<Card title="Manage bundled format blueprints" href="/bundled-format-blueprints">`FORMAT_BLUEPRINTS_DIR`, the generated module, and `pnpm import:format-blueprint`.</Card>
<Card title="Troubleshooting" href="/troubleshooting">Missing generated modules and other known failure modes.</Card>
</CardGroup>

---

## 20. Release pipeline

> Building and publishing a release: `build-release.mjs` byte-identical worker bundles and manifest generation, the placeholder contract (`$ACCOUNT_ID`, `$WORKER_NAME(...)`, `$SECRET(...)`, `$PUBLIC_BASE_URL`), `upload-release.mjs --candidate` staging, and the all-or-nothing manifest copy in `promote-release.mjs`. Covers required R2 variables, `NO_DEFAULT_CRED_INPUTS`, promote serialization, and golden-file regeneration with `UPDATE_GOLDEN=1`.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/20-release-pipeline.md
- Generated: 2026-08-05T16:41:50.448Z

### Source Files

- `scripts/release/build-release.mjs`
- `scripts/release/manifest-lib.mjs`
- `scripts/release/upload-release.mjs`
- `scripts/release/promote-release.mjs`
- `scripts/release-manifest.test.js`
- `scripts/release-promote.test.js`
- `AGENTS.md`

---
title: "Release pipeline"
description: "Building and publishing a release: `build-release.mjs` byte-identical worker bundles and manifest generation, the placeholder contract (`$ACCOUNT_ID`, `$WORKER_NAME(...)`, `$SECRET(...)`, `$PUBLIC_BASE_URL`), `upload-release.mjs --candidate` staging, and the all-or-nothing manifest copy in `promote-release.mjs`. Covers required R2 variables, `NO_DEFAULT_CRED_INPUTS`, promote serialization, and golden-file regeneration with `UPDATE_GOLDEN=1`."
---

A release is an immutable, content-addressed bundle set plus one manifest. `scripts/release/build-release.mjs` builds every deployable worker exactly as `wrangler deploy` would upload it (`--dry-run --outdir`, using the repo's pinned wrangler), writes each module and static asset under its content hash, and emits `manifest.json` describing the whole set. `scripts/release/upload-release.mjs` mirrors that directory to R2 over the S3 API, blobs first and manifest last. `scripts/release/promote-release.mjs` publishes a candidate by copying a single key: `candidates/<id>/manifest.json` → `releases/<id>/manifest.json`. The manifest is the contract between this repo's CI and the deploy service, which PUTs the bundles into customer accounts via the Workers script-upload API.

## Pipeline stages

| Stage | Command | Effect |
| --- | --- | --- |
| Build | `node scripts/release/build-release.mjs --out <dir> [--release-id <id>]` | Writes `<out>/modules/<sha256>`, `<out>/assets/<cfHash>`, then `<out>/manifest.json` |
| Stage | `node scripts/release/upload-release.mjs --release <dir> --candidate` | Uploads blobs, then manifest to `candidates/<id>/manifest.json` |
| Publish | `node scripts/release/promote-release.mjs --release-id <id>` | Copies the candidate manifest to `releases/<id>/manifest.json` |
| Direct publish | `node scripts/release/upload-release.mjs --release <dir>` | Uploads blobs, then manifest straight to `releases/<id>/manifest.json` |

```text
build-release.mjs                 R2 bucket
┌──────────────────────┐          ┌──────────────────────────────────┐
│ frontend (Access)    │  upload  │ blobs/modules/<sha256>           │  content-addressed,
│ wrangler --dry-run   │ ───────► │ blobs/assets/<cfHash>            │  deduped across releases
│ manifest.json        │          ├──────────────────────────────────┤
└──────────────────────┘          │ candidates/<id>/manifest.json    │  invisible to deploy
        │ --candidate                                                    service
        │                         │              │ promote-release.mjs
        └────────────────────────►│              ▼
                                  │ releases/<id>/manifest.json      │  scanned by deploy
                                  └──────────────────────────────────┘     service
```

<Info>
The manifest-last protocol is what makes a release atomic: the deploy service scans only `releases/`, so a crashed blob upload never leaves a manifest pointing at missing blobs.
</Info>

## Building a release

`build-release.mjs` runs in a fixed order because the router's `wrangler.jsonc` points its assets directory at `workshop-frontend/dist`.

<Steps>
<Step title="Resolve release identity">
The commit comes from `CI_COMMIT_SHA` or `git rev-parse HEAD`. When `--release-id` is omitted, the id is derived: `r<CI_PIPELINE_IID padded to 6>-<short sha>` in CI, otherwise `dev-<base36 unix seconds>`. `CI_PIPELINE_IID` (per-project, monotonic) is used deliberately, not `CI_PIPELINE_ID` — run numbers must form one monotonic sequence because `promote-release.mjs` compares them. Ids stay short because downstream worker version tags (`gd:<id>:<fp8>`) have a hard 25-character cap.
</Step>
<Step title="Build the Access-mode frontend">
`pnpm run build` runs in `packages/workshop-frontend` with `VITE_CF_ACCESS_MODE=true` (a build-time flag read by `workshop-frontend/src/useAuth.ts`). Its `dist/` output is collected as the `access` asset variant — the one variant every release carries — and each blob is written to `<out>/assets/<hash>`.
</Step>
<Step title="Bundle every deployable worker">
For each package returned by `findDeployablePackages(packages/)`, the script runs `pnpm exec wrangler deploy --dry-run --outdir <tmp>/<pkg>` **from the package directory**, so custom build commands (`capnweb-validate`) resolve their bins. Modules are written to `<out>/modules/<sha256>`.
</Step>
<Step title="Generate the manifest">
`generateManifest({ releaseId, commit, createdAt, wranglerVersion, workers, assetVariants })` produces the manifest, stringified with `stableStringify` and written last — mirroring the R2 upload order. `wranglerVersion` is read from `node_modules/wrangler/package.json`, so the recorded version is the pinned one.
</Step>
</Steps>

The final log line reports worker count, module count, and unique asset blob count.

<ParamField body="--out" type="path" required>
Output directory. Removed recursively before the build, then recreated with `modules/` and `assets/` subdirectories.
</ParamField>

<ParamField body="--release-id" type="string">
Overrides the derived release id. Any other argument throws `unknown argument: <arg>`.
</ParamField>

## The placeholder contract

`scripts/release/manifest-lib.mjs` parses each package's `wrangler.jsonc` and emits binding *templates*: every account-specific value is replaced by a placeholder the deploy service resolves from instance state. The list is closed — the deploy-side renderer fails on any `$` token it does not recognize, so `manifest-lib.mjs` and the renderer must evolve together. `MANIFEST_VERSION` (currently `1`) guards that coupling.

| Placeholder | Resolves to |
| --- | --- |
| `$ACCOUNT_ID` | The user's account tag |
| `$KV_<BINDING>_ID` | A KV namespace provisioned at deploy time |
| `$R2_<BINDING>_NAME` | An R2 bucket provisioned at deploy time |
| `$WORKER_NAME(<pkg>)` | The instance's chosen name for another worker in this release |
| `$SECRET(<name>)` | A user-supplied secret, passed through as `secret_text` |
| `$PUBLIC_BASE_URL` | The instance's public origin (the router's URL) |

Examples produced by the generator and asserted by the golden tests:

```json
{ "type": "kv_namespace", "name": "BLUEPRINTS", "namespace_id": "$KV_BLUEPRINTS_ID" }
{ "type": "r2_bucket", "name": "BLUEPRINT_CONTENT", "bucket_name": "$R2_BLUEPRINT_CONTENT_NAME" }
{ "type": "service", "name": "WORKSHOP_BACKEND", "service": "$WORKER_NAME(workshop-backend)" }
{ "type": "secret_text", "name": "CLIENT_SECRET", "text": "$SECRET(CLIENT_SECRET)" }
```

Placeholder-free bindings pass through as `{ type, name }` only: `worker_loader` (`LOADER`), `ai` (`WORKERS_AI`, which always ships because `webFetch`'s `toMarkdown` conversion depends on it), `browser` (Browser Rendering is generally available, and wrangler's dev-only `remote` flag is dropped), and `assets` (binding name defaults to `ASSETS`).

Var templating is per worker kind: the backend carries `PUBLIC_BASE_URL: "$PUBLIC_BASE_URL"`, and each gatekeeper carries `BASE_URL: "$PUBLIC_BASE_URL/gatekeeper/<shortName>"` where `shortName` is the package name minus the `gatekeeper-` prefix and matches the router's path scan.

<Warning>
`HANDLED_CONFIG_KEYS` fails closed. A `wrangler.jsonc` key the generator does not know throws `<pkg>/wrangler.jsonc has key(s) this generator doesn't handle: ...`, forcing an explicit decision about how customer instances receive it. Similarly, an `artifacts` binding outside `ARTIFACTS_CUT_ALLOWED` (`gatekeeper-context` only) is a hard error; `gatekeeper-context`'s closed-beta Artifacts binding is dropped from customer manifests and the gatekeeper degrades gracefully.
</Warning>

## Worker kinds, inputs, and installability

`workerKind()` classifies each deployable package: `workshop-backend` → `backend`, `router` → `router`, anything starting with `gatekeeper-` → `gatekeeper`. An unclassifiable package throws `cannot classify deployable package: <name>`.

Installable gatekeepers default to the two `DEFAULT_CRED_INPUTS` secret inputs, which materialize as `$SECRET(...)` bindings:

```js
// scripts/release/manifest-lib.mjs
export const DEFAULT_CRED_INPUTS = [
  { name: "CLIENT_ID",     kind: "secret", label: "OAuth client ID" },
  { name: "CLIENT_SECRET", kind: "secret", label: "OAuth client secret" },
];
```

`NO_DEFAULT_CRED_INPUTS` names the installable gatekeepers that take no third-party OAuth app credentials:

| Package | Reason |
| --- | --- |
| `gatekeeper-context` | No third-party service; uses its own storage |
| `gatekeeper-homeassistant` | Users connect their own Home Assistant URL + token in-app |
| `gatekeeper-scheduler` | Auto-provisioned; no third-party OAuth app |
| `gatekeeper-mcp` | MCP OAuth uses dynamic client registration, not a static app |
| `gatekeeper-mcp-portal` | Same MCP OAuth chain as `gatekeeper-mcp` |

`NOT_INSTALLABLE` contains `gatekeeper-email`: Email Routing needs a zone, which workers.dev-hosted instances don't have. Its bundle still ships in the release so the entry stays auditable, with `installable: false` and `inputs: []`. Per-package `deploy-inputs.json` (read by `readDeployInputs`, `undefined` when absent) overrides the default input set.

The backend entry also carries `gatekeeperBindingExpansion.entrypoint === "GatekeeperVendor"` and its full ordered `migrations` history verbatim from `wrangler.jsonc` (tag `v0` onward, including `new_sqlite_classes` such as `UserDurableObject`).

## Staging with `--candidate`

`upload-release.mjs` reads `<dir>/manifest.json`, enumerates `modules/` and `assets/`, and maps each file to its R2 key via `moduleR2Key(sha256)` and `assetR2Key(hash)`. Eight concurrent workers (`UPLOAD_CONCURRENCY = 8`) drain a shared queue; each key is `HEAD`'d first and skipped when it already exists, so unchanged blobs dedupe across releases. Any HEAD status other than `200`/`404` is a hard error, as is a failed PUT.

The manifest PUT is last, and `--candidate` only changes its key:

```text
default:     releases/<manifest.releaseId>/manifest.json
--candidate: candidates/<manifest.releaseId>/manifest.json
```

Blob handling is identical either way. A candidate manifest is invisible to the deploy service, which scans only `releases/`, until promotion. The script logs `candidate uploaded (not yet visible to the deploy service): <key>` or `release complete: <key>`.

<RequestExample>
```bash Stage a candidate
export R2_ENDPOINT="https://<account>.r2.cloudflarestorage.com"
export R2_BUCKET="<bucket>"
export R2_ACCESS_KEY_ID="<key-id>"
export R2_SECRET_ACCESS_KEY="<secret>"

node scripts/release/build-release.mjs --out ./release-out
node scripts/release/upload-release.mjs --release ./release-out --candidate
```
</RequestExample>

<ResponseExample>
```text Output
blobs: 41 uploaded, 128 already present
candidate uploaded (not yet visible to the deploy service): candidates/r000123-abc1234/manifest.json
```
</ResponseExample>

### Required R2 variables

Both `upload-release.mjs` and `promote-release.mjs` call `requireEnv` for the same four variables and throw `missing required environment variable: <name>` when one is absent. Requests are signed with `aws4fetch` (`service: "s3"`, `region: "auto"`).

<ParamField body="R2_ENDPOINT" type="string" required>
`https://<account>.r2.cloudflarestorage.com`. A trailing slash is stripped.
</ParamField>

<ParamField body="R2_BUCKET" type="string" required>
Bucket holding `blobs/`, `candidates/`, and `releases/`.
</ParamField>

<ParamField body="R2_ACCESS_KEY_ID" type="string" required>
S3-compatible access key id.
</ParamField>

<ParamField body="R2_SECRET_ACCESS_KEY" type="string" required>
S3-compatible secret access key.
</ParamField>

## Promotion

Because blobs are already in place — content-addressed and uploaded before the candidate's manifest PUT — publishing is a single manifest copy. `promote-release.mjs` prefers a server-side `CopyObject` (`x-amz-copy-source: /<bucket>/candidates/<id>/manifest.json`), avoiding a second body transfer. S3 can answer `200` with an error document, so success requires the response body to contain `<CopyObjectResult`; otherwise the script logs `CopyObject unavailable (status ...); falling back to PUT` and PUTs the already-buffered candidate body.

```mermaid
stateDiagram-v2
    [*] --> HeadPublished: promote-release.mjs --release-id <id>
    HeadPublished --> AlreadyPromoted: 200
    HeadPublished --> GetCandidate: 404
    HeadPublished --> HardError: other status
    GetCandidate --> HardError: 404 candidate not found
    GetCandidate --> SupersedeCheck: 200 (body buffered)
    SupersedeCheck --> Superseded: higher CI run published
    SupersedeCheck --> CopyObject: no superseder
    CopyObject --> Promoted: 200 + CopyObjectResult
    CopyObject --> PutFallback: copy unavailable
    PutFallback --> Promoted: PUT ok
    PutFallback --> HardError: PUT failed
    AlreadyPromoted --> [*]: exit 0
    Superseded --> [*]: exit 0 (warning)
    Promoted --> [*]: exit 0
    HardError --> [*]: throw
```

### Exit-0 guards versus hard errors

| Condition | Behavior |
| --- | --- |
| `releases/<id>/manifest.json` already exists (HEAD 200) | Logs `already promoted: <key>`, exits 0 — idempotent re-runs |
| A CI-format id with a **higher** run number already published | Warns and returns without copying, exits 0 |
| `candidates/<id>/manifest.json` missing (GET 404) | Throws `candidate not found: <key> — was the release uploaded with --candidate?` |
| Unexpected HEAD status on the published key | Throws `HEAD <key>: unexpected status <n>` |
| Failed fallback PUT | Throws `PUT <key>: <status> <body>` |

A missing candidate manifest is deliberately a hard error: the caller asked to promote something never uploaded (or since cleaned up), and silently succeeding would report a phantom publish.

### Ordering guard

"Latest" is decided by manifest upload time in the deploy service (`release.ts`), so promoting an older candidate after a newer one shipped would **roll production back** — the stale PUT would carry the newest timestamp. Two exported pure functions encode the guard:

```js
ciRunNumber("r000123-abc1234") // 123
ciRunNumber("r1-abc1234")      // 1
ciRunNumber("dev-mdxk3f2a")    // null
ciRunNumber("release-42")      // null
ciRunNumber("r-abc1234")       // null

supersededBy("r000123-abc1234", ["r000122-aaaaaaa", "r000124-bbbbbbb"]) // "r000124-bbbbbbb"
supersededBy("r000123-abc1234", ["r000123-abc1234"])                    // null
supersededBy("r000123-abc1234", ["dev-zzzzzzzz"])                       // null
supersededBy("dev-mdxk3f2a", ["r999999-abc1234"])                       // null
```

Only `r<run#>-<sha>` ids participate in ordering. `dev-<ts>` ids carry no ordering claim on either side: a dev candidate is never superseded, and a published dev release never supersedes a CI candidate. Published ids are discovered by paginated `ListObjectsV2` over `prefix=releases/` (1000 keys per round trip, `<NextContinuationToken>` followed); only key names are parsed, manifest bodies are never fetched.

<Warning>
Promotion is all-or-nothing but **not** isolated. The newer-release guard is check-then-act, so concurrent promotions can interleave between the LIST and the copy. The caller must serialize runs of this script — `gadgets-internal`'s CI runs it in a resource group — and the guard then catches the remaining hazard: a promote that starts after a newer release has already published.
</Warning>

## Golden manifest test

`scripts/release-manifest.test.js` runs the manifest generator against the repo's **real** `wrangler.jsonc` files, substituting fixture bundles and assets so no compilation is needed. It pins deterministic inputs (`releaseId: "r000000-fixture"`, an all-zero commit, `createdAt: "2026-01-01T00:00:00.000Z"`, `wranglerVersion: "0.0.0-fixture"`) and compares `stableStringify(manifest)` against `scripts/testdata/golden-manifest.json`.

<Tabs>
<Tab title="Run">
```bash
node --test scripts/release-manifest.test.js
```
</Tab>
<Tab title="Regenerate">
```bash
UPDATE_GOLDEN=1 node --test scripts/release-manifest.test.js
```
</Tab>
</Tabs>

With `UPDATE_GOLDEN` set, the test writes the rendered manifest to the golden path and returns without asserting. Without it, a missing golden file fails with `golden manifest missing; run with UPDATE_GOLDEN=1`.

This is a deliberate tripwire: changing any deployable package's `wrangler.jsonc` fails the test until the golden file is regenerated, forcing a conscious decision about how the change reaches customer instances. The failure message is explicit — verify the deploy service handles the change, then regenerate.

Adding a new deployable package also requires a fixture: the test asserts `scripts/testdata/fixture-bundles/<pkg>/` exists, with the message `missing fixture bundle for new deployable package: add scripts/testdata/fixture-bundles/<pkg>/ with a single .js module`.

Two further tests in the same file lock the deploy contract:

- **Placeholder syntax.** Every `$`-token found in each worker's `bindings`, `vars`, and `gatekeeperBindingExpansion` must match the closed placeholder regex `^\$(ACCOUNT_ID|PUBLIC_BASE_URL|KV_[A-Z0-9_]+_ID|R2_[A-Z0-9_]+_NAME|WORKER_NAME\([a-z0-9-]+\)|SECRET\([A-Z0-9_]+\))`, recursing through arrays and objects.
- **Contract shape.** Worker kinds (`workshop-backend` → `backend`, `router` → `router`, `gatekeeper-google` → `gatekeeper`), the backend's provisioned-resource placeholders and migration history, `gatekeeper-google`'s `shortName`/`BASE_URL`/`CLIENT_ID`+`CLIENT_SECRET` inputs, `gatekeeper-email`'s `installable: false` with empty `inputs`, and the router's `assetsConfig` (`run_worker_first` includes `/gatekeeper/*`, `not_found_handling === "single-page-application"`, variants exactly `["access"]`). Every asset hash referenced by a variant manifest must exist in `manifest.assets` with `r2Key === "blobs/assets/<hash>"`.

`scripts/release-promote.test.js` covers the promote guard's pure logic only (`ciRunNumber`, `supersededBy`); the R2 round trip is exercised by the deploy e2e pipeline, not by unit tests.

```bash
node --test scripts/release-promote.test.js
```

## Troubleshooting

<AccordionGroup>
<Accordion title="manifest changed. If the wrangler.jsonc change is intentional...">
The golden manifest test detected a config drift. Confirm the deploy service handles the new shape, then run `UPDATE_GOLDEN=1 node --test scripts/release-manifest.test.js` and commit the regenerated `scripts/testdata/golden-manifest.json`.
</Accordion>
<Accordion title="<pkg>/wrangler.jsonc has key(s) this generator doesn't handle">
The key is outside `HANDLED_CONFIG_KEYS` in `scripts/release/manifest-lib.mjs`. Decide how customer instances should receive that config, add explicit handling (and a placeholder if the value is account-specific), then regenerate the golden manifest.
</Accordion>
<Accordion title="candidate not found: candidates/<id>/manifest.json — was the release uploaded with --candidate?">
Promotion requires a staged candidate. Re-run `upload-release.mjs --release <dir> --candidate`, or confirm the candidate was not cleaned up. Promotion never publishes without a candidate body.
</Accordion>
<Accordion title="WARNING: not promoting <id> — a newer release (<id>) is already published">
Expected exit-0 behavior, not a failure. A higher CI run number already shipped; promoting now would roll production back because "latest" is decided by manifest upload time. Ship a new build instead.
</Accordion>
<Accordion title="missing required environment variable: R2_*">
`upload-release.mjs` and `promote-release.mjs` both require `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, and `R2_SECRET_ACCESS_KEY`. `build-release.mjs` requires none of them.
</Accordion>
<Accordion title="missing fixture bundle for new deployable package">
A new package with a `wrangler.jsonc` was discovered by `findDeployablePackages`. Add `scripts/testdata/fixture-bundles/<pkg>/` containing a single `.js` module, then regenerate the golden manifest.
</Accordion>
<Accordion title="cannot classify deployable package: <name>">
`workerKind()` only recognizes `workshop-backend`, `router`, and `gatekeeper-*` prefixes. Rename the package or extend the classifier and the manifest contract together.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Build, lint, and test" href="/build-lint-test">
The commands CI enforces and their ordering, including `node --test scripts/*.test.js`.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
The bindings and DO migration tags the manifest templates, and how the router resolves `/gatekeeper/<name>/*`.
</Card>
<Card title="Environment variables" href="/environment-variables">
Backend environment variables behind `PUBLIC_BASE_URL` and the rest of the instance config.
</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
How `CLIENT_ID`/`CLIENT_SECRET` inputs and `deploy-inputs.json` overrides are consumed.
</Card>
</CardGroup>

---

## 21. Integration testing

> How `packages/integration-tests` boots the backend and gatekeepers as real workers in workerd via `createTestHarness()` and speaks Cap'n Web over the same `/api` WebSocket the browser uses. Documents why fake timers cannot work out-of-process, why a fixture gatekeeper covers overseer logic, the ~3s `server.reset()` cost and convention-based isolation, and the pluggable network interceptor.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/21-integration-testing.md
- Generated: 2026-08-05T16:42:11.167Z

### Source Files

- `docs/integration-testing.md`
- `packages/integration-tests/src/harness.ts`
- `packages/integration-tests/src/network-interceptor.ts`
- `packages/integration-tests/src/rpc-client.ts`
- `packages/integration-tests/fixtures/gatekeeper-test/src/test-gatekeeper.ts`
- `packages/integration-tests/__tests__/observer-reverification.test.ts`

---
title: "Integration testing"
description: "How `packages/integration-tests` boots the backend and gatekeepers as real workers in workerd via `createTestHarness()` and speaks Cap'n Web over the same `/api` WebSocket the browser uses. Documents why fake timers cannot work out-of-process, why a fixture gatekeeper covers overseer logic, the ~3s `server.reset()` cost and convention-based isolation, and the pluggable network interceptor."
---

`packages/integration-tests` runs `workshop-backend` and one or more gatekeepers as real Workers in workerd, booted through wrangler's `createTestHarness()` with their checked-in `wrangler.jsonc` patched in memory. Tests connect a Cap'n Web session over a WebSocket to `/api` — the same transport the browser uses — and serve an `ObserverConfigCallback` the overseer calls back into. Nothing is stubbed except outbound HTTP, which means the code under test runs in another process; most of the suite's design follows from that.

## Two kinds of suite

| | this repo's `packages/integration-tests` | a consumer repo's per-vendor suite |
|---|---|---|
| Runs | `pnpm test` (part of CI's normal test job) | its own CI step |
| Gatekeeper | a fixture Worker whose verification outcome the tests set | a real vendor gatekeeper, unmodified |
| Covers | the overseer's observer logic | a genuinely expired credential, end to end |
| Owns | the harness, interceptor, and RPC client | that vendor's handlers and token minting |

A consumer repo vendors this repo as a `public/` submodule and consumes the toolkit as a workspace dependency (`public/packages/integration-tests` in its `pnpm-workspace.yaml`). No such suite lives in this repo, and nothing here depends on one existing — but the harness takes a *list* of gatekeepers and the interceptor takes *pluggable* handler modules precisely so that suite can be added outside this repo without forking either.

## Harness topology

```text
  vitest process (Node)                     workerd (createTestHarness)
  ─────────────────────                     ───────────────────────────
  rpc-client.ts ── ws://…/api ────────────▶ workshop-backend   (primary)
    connect() / stubFor()                     │ GATEKEEPER_TEST
    ObserverConfigRecorder  ◀── callback ─────┤ (service binding,
                                              │  entrypoint GatekeeperVendor)
  harness.fetchWorker(…) ─────────────────▶ gatekeeper-test    (fixture)
                                              │ /control/verify-outcome
  network-interceptor.ts                      │ /control/ambient-verification-count
    globalThis.fetch patched ◀── outbound fetch() routed back to Node
```

### `startHarness()`

`src/harness.ts` reads each checked-in `wrangler.jsonc` with `jsonc-parser`, validates the fields it touches against a loose Zod schema (`WORKER_CONFIG`), and hands the result to `createTestHarness()` as an inline config.

<ParamField body="gatekeepers" type="GatekeeperSpec[]" required>
  Each spec is `{ binding, dir, patch? }`. `binding` is the service-binding suffix: `GATEKEEPER_<binding>` is what the Workshop scans for, and it lowercases the suffix into the vendor id — `"JIRA"` here is vendor id `"jira"` in every RPC. `dir` holds the `wrangler.jsonc` to boot. `patch` adjusts the parsed config before boot.
</ParamField>

<ParamField body="patchWorkshop" type="(config: WorkerConfig) => void">
  Adjust `workshop-backend`'s config after the harness's own rewrites.
</ParamField>

<ParamField body="root" type="string" default="this repo's root">
  Harness `root`. Override when a gatekeeper lives outside this repo.
</ParamField>

`startTestGatekeeperHarness()` is the one-liner used by the in-repo suites: it boots the Workshop with only the bundled fixture gatekeeper bound.

```ts
// packages/integration-tests/src/harness.ts
export function startTestGatekeeperHarness(): Promise<Harness> {
  return startHarness({
    gatekeepers: [{ binding: TEST_GATEKEEPER_BINDING, dir: TEST_GATEKEEPER_DIR }],
  });
}
```

Exported constants:

| Constant | Value / meaning |
|---|---|
| `TEST_GATEKEEPER_DIR` | `fixtures/gatekeeper-test` |
| `TEST_GATEKEEPER_WORKER` | `"gatekeeper-test"` |
| `TEST_GATEKEEPER_BINDING` | `"TEST"` |
| `TEST_VENDOR_ID` | `"test"` (lowercased binding) |
| `ADMIN_USERNAME` | `"admin"` — the username `vars.ADMINS` grants deployment-admin rights to, mirroring `run-dev-server.js` |

### Config rewrites the harness performs

<AccordionGroup>
<Accordion title="Per-worker config normalisation (readWorkerConfig)">
`build.cwd` is pinned to the worker's own directory — a worker whose `main` is generated (capnweb-validate) otherwise emits output in the wrong place, the same reason `run-dev-server.js` pins it. `main` is then made absolute: an inline config has no file path of its own, so wrangler would resolve a relative `main` against the harness `root` rather than the worker directory.
</Accordion>
<Accordion title="workshop-backend specifics (workshopConfig)">
- `services` is replaced with exactly one entry per requested gatekeeper: `{ binding: GATEKEEPER_<binding>, service: <worker name>, entrypoint: "GatekeeperVendor" }`. The checked-in config declares none, so `buildGatekeeperVendorMap()` discovers exactly the vendors the suite asked for and the observer-config prompt has no surprise rows.
- `vars.ADMINS` is set to `[ADMIN_USERNAME]`. No `CF_ACCESS_AUD` is set, so `/api` takes the unauthenticated path and password signup is available.
- `worker_loaders` is deleted. Gadget code is never executed here — a gatekeeper is in observer scope purely by having a `vendorId` — so the Worker Loader is not required to start.
</Accordion>
</AccordionGroup>

### `Harness`

<ResponseField name="server" type="TestHarness">
The wrangler harness itself.
</ResponseField>
<ResponseField name="url" type="URL">
Base URL of the running server, e.g. `http://127.0.0.1:1234`.
</ResponseField>
<ResponseField name="fetchWorker" type="(name, ...args) => Promise<Response>">
Dispatches a request to a named worker's own HTTP entrypoint. The host is never resolved — the request goes straight to that worker — so no `routes` config is needed, but the path must still match what the worker expects. Typed as the harness's own dispatch signature, because this package sees both Node and Workers global types.
</ResponseField>

## Driving the API over Cap'n Web

`src/rpc-client.ts` speaks the real WebSocket API.

```ts
export function connect(baseUrl: URL): RpcStub<PublicApi> {
  const wsUrl = new URL("/api", baseUrl);
  wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
  return newWebSocketRpcSession<PublicApi>(wsUrl.toString());
}
```

| Helper | Purpose |
|---|---|
| `connect(baseUrl)` | Open an `RpcStub<PublicApi>` session against `/api`. |
| `stubFor(target)` | Mint a callback stub. Always use this instead of importing `RpcStub` directly. |
| `signUp(api, username)` | `createAccount` + `authenticate`, returning `RpcStub<AuthenticatedApi>`. |
| `nextUsernames(...prefixes)` | Fresh per-test usernames: `nextUsernames("alice", "bob")` → `["alice7", "bob7"]`. |
| `listConnectedAccounts(api)` | Drives `subscribeConnectedAccounts()` to its `ready()` call and returns `ConnectedAccount[]`. |
| `accountLabel(account)` | `uniqueName || displayName || \`account ${id}\`` — mirrors the overseer's `#describeObserverFailures` precedence. |
| `waitFor(what, attempt, timeoutMs = 30_000)` | Polls `attempt` every 25 ms until it returns non-null; throws on timeout. |
| `ObserverConfigRecorder` | Records every `configure()` call and answers from a scripted queue. |
| `MAX_OBSERVER_PROMPTS` | `2` — the initial prompt plus at most one re-prompt (`MAX_CONFIG_REPROMPTS` in the overseer is 1). The product does not export it. |

Passwords are not hashed the way the frontend does. The server stores and compares the submitted bytes verbatim and never re-derives them, so the tests substitute a deterministic `sha256("integration-test:" + username)` rather than paying argon2id's 64 MiB per call.

`ObserverConfigRecorder.alwaysChoose(accountId, times)` takes an explicit `times` on purpose: `configure()` throws once the response queue is empty, so an unexpected extra prompt fails the test instead of being silently answered.

## Findings that shape the design

### Fake timers cannot work here

`vi.useFakeTimers()` patches the test process's clock. The code under test reads workerd's clock, out of process, so a faked clock is invisible to it. `isTokenExpired()`'s 30-second skew lives in `gatekeeper-shared` and is evaluated inside the Worker.

<Note>
Fake timers *do* work for in-isolate unit tests under `vitest-pool-workers`, where the test runs inside the same isolate.
</Note>

### A fixture gatekeeper, not a real one, for the overseer's own logic

The overseer cases need a gatekeeper that refuses an observer on command. Every shipping public gatekeeper can do that only at a cost that would dominate the test:

- **OAuth gatekeepers** need a whole vendor auth surface mocked before an account exists at all.
- **The Context Library** only refuses once an observation has been *recorded*, which takes a gadget read session (so a Worker Loader), a slash-command invocation, or an AI-chat catalog snapshot. It is also a singleton, so it can never produce the two simultaneously-failing bindings one of these cases needs.

Adding a test hook to those workers was considered and rejected: a "mark observed" hook would stub the very state the tracker maintains, making the test circular.

So `fixtures/gatekeeper-test/` is a real Worker speaking the real protocol, whose verification outcome the tests set over an HTTP control route.

<Warning>
The fixture is scoped to overseer logic, not a long-term substitute for per-vendor coverage. Testing actual gatekeepers is the expected trajectory — which is why the harness takes a list of gatekeepers and the interceptor takes pluggable handler modules. A future `gatekeeper-google` suite is "add `google-handlers.ts`, point the harness at the package", with production code unmodified.
</Warning>

### Storage isolation is by convention, because the alternative is worse

`server.reset()` exists, and measuring it settles the question: **~3 s per call**, which is more than an entire suite run. It also restarts the server — `server.url` becomes undefined and every open WebSocket RPC session dies with `WebSocket connection failed`. It is not a storage wipe you can use between tests; it is a teardown.

Storage therefore persists for the harness's lifetime and **no test may assume a clean slate**. Tests stay independent by taking fresh identities:

- `nextUsernames()` from the toolkit
- per-test resource URLs
- account labels allocated by the connect/provision helper rather than chosen by the caller

One corollary that is easy to get wrong: the "nothing escaped to the internet" assertion belongs in `afterAll`, not `afterEach`. With `it.concurrent`, an `afterEach` fires while siblings are still running, so it would inspect and clear state they are still using — and could discard an escape a sibling was about to be blamed for.

### wrangler and workerd versions are coupled

The public repo pins `workerd` through a root `overrides` entry, which collapses every transitive request to one version. A newer `wrangler` brings a newer `miniflare` that demands a newer `workerd` than the override yields, and the harness then fails to boot:

```text
The Workers runtime failed to start ... requires compatibility date "2026-07-08",
but the newest date supported by this server binary is "2026-06-30".
```

The public package therefore pins `wrangler` to `~4.104.0` — the release whose bundled `workerd` matches the override. Bumping it means bumping the override in step.

### A consumer in another repo can end up with two copies of capnweb

A consumer repo installs its own workspace *and* the `public/` submodule's, as two separate pnpm stores. `capnweb` then resolves to two different copies: the toolkit's `rpc-client` gets the submodule's, while anything importing `capnweb` from one of the consumer's own packages gets the other. A stub is only serialisable by the instance that owns the session, so mixing them fails:

```text
TypeError: Cannot serialize value: [object RpcStub]
```

The trap is that a dev machine where a single `pnpm install` deduped both will not show this. It first appeared in CI, which runs `pnpm install` and `pnpm --dir public install` separately; reproduce locally by doing the same.

The toolkit therefore owns the capnweb boundary: mint callback stubs with `stubFor()` from `rpc-client`, never with an imported `RpcStub`. Importing `RpcStub` as a *type* is fine. This is enforced structurally in this repo — `.oxlintrc.json` restricts `capnweb` value imports within this package to `rpc-client.ts` (`allowTypeImports` leaves type imports alone). A consumer repo without a linter should treat the rule as a convention its test files follow via `stubFor()`.

### Worker entry modules may export only classes and the default handler

workerd treats every named export of the entry module as an entrypoint. Exporting a plain string constant from the fixture produced:

```text
Incorrect type for map entry 'THING_URL_PATTERN': the provided value is not of
type 'function or ExportedHandler'.
```

Type-only exports are fine (they erase). Anything else has to stay module-private.

## Network interception

`src/network-interceptor.ts` is mechanism only. `createTestHarness` routes a Worker's outbound `fetch()` back through the Node process, so patching `globalThis.fetch` is enough — no interception library is needed.

```ts
export type Handler =
    (url: URL, method: string, headers: Headers) => Response | null | Promise<Response | null>;
```

A handler answers one request or returns `null` to decline and let the next handler try. Handlers may be async, which is load-bearing rather than a convenience: a handler sometimes has to wait for the test to say what to answer with, because the thing that identifies the request only comes into existence once the Worker has started making it.

Dispatch rules:

1. Requests to `localhost`, `127.0.0.1`, or `[::1]` pass straight through to the real `fetch` — that is the harness's own loopback traffic.
2. Otherwise a `Request` is constructed to normalise method and headers, and the method is upper-cased (`Request` normalises only the methods the fetch spec lists, so `patch` would otherwise reach handlers lowercased). Constructing the `Request` can transfer the body's stream, which is why it happens after the loopback return.
3. Handlers are tried in order; the first non-null `Response` wins.
4. An unmatched request is recorded in `#unmockedCalls` and throws `Unmocked outbound request: <METHOD> <url>`, so an unmocked call fails the test instead of silently reaching the internet.

| Method | Behavior |
|---|---|
| `install()` | Swap in the patched `globalThis.fetch`. Idempotent. |
| `uninstall()` | Restore the real `fetch`. |
| `getUnmockedCalls()` | Copy of the URLs that were neither handled nor local. |
| `takeUnmockedCalls(substring)` | Remove and return matching entries, for the one test that provokes an unmocked request deliberately. Taking just its own entry rather than resetting means a concurrently running sibling's escape is still caught. |
| `reset()` | Clear the recorded unmocked calls. |

What a given vendor's endpoints return lives in a handler module passed to the constructor, so a suite for another gatekeeper is a new handler module rather than a fork of this file.

## The fixture gatekeeper

`fixtures/gatekeeper-test/src/test-gatekeeper.ts` is a real Worker implementing the real gatekeeper protocol against vendor host `gadgets-test.example`.

```mermaid
classDiagram
    class GatekeeperVendor {
        +describe() VendorDescription
        +createAccount() Fetcher~GatekeeperUser~
        +getSupportedResources() SupportedResource[]
        +getTypeScriptTypes() string
        +connectAccount() throws
    }
    class TestAccount {
        +describe() AccountDescription
        +getSingletonGatekeeperClass()
        +getSupportedResources()
        +getGatekeeperClassFor(url)
    }
    class TestControl {
        +setVerifyOutcome(label, outcome)
        +getVerifyOutcome(label) VerifyOutcome
        +recordAmbientVerification(label)
        +getAmbientVerificationCount(label) number
    }
    class TestGatekeeper {
        props: label, resourceUrl, ambient?
    }
    GatekeeperVendor --> TestAccount : createAccount()
    TestAccount --> TestGatekeeper : getGatekeeperClassFor()
    TestGatekeeper ..> TestControl : reads verify outcome
```

Design points that matter when reading the tests:

- `autoProvisionsAccount: true` in `describe()`. Accounts are minted on request with no auth flow, which is what keeps these tests about the overseer rather than about somebody's OAuth dance. `connectAccount()` is required by the interface but unreachable, and throws.
- `createAccount()` mints a distinct account per call — a random `test-<12 hex>@gadgets-test.example` label — so two users, or two concurrent tests, never share one.
- `AccountDescription.uniqueName` is that label. It is what the overseer names in a verification-failure message, and the same string the Workshop shows the user, so control state is keyed on it: a test that read a label off `description.uniqueName` can aim an outcome at it without learning any internal id.
- `TestControl` is a Durable Object storing `outcome:<label>` and `ambient-verifications:<label>` in KV storage. `getVerifyOutcome()` defaults to `{ allow: true }`, because a collaborator's first open has to be able to succeed.
- `SUPPORTED_RESOURCES` declares one `Test Thing` at `https://gadgets-test.example/things/*`; the avatar is an inline 1×1 transparent GIF data URL so nothing here reaches for a network asset.
- The fixture deliberately does not model the difference between a settled denial ("you may not read this") and an operational failure ("the credential expired"). Both reach the overseer identically as a thrown error, and the overseer cannot tell them apart — by design, since it treats every failure as repairable. There is one control knob, `allow`, and the `reason` string carries the distinction to the user.

### Control routes

Both are reached with `harness.fetchWorker(TEST_GATEKEEPER_WORKER, …)`; the host is not resolved, so any host works.

:::endpoint POST http://gatekeeper-test.test/control/verify-outcome Set what the gatekeeper does the next time it is asked to admit `label` as an observer

Request body is `{ label, allow: true }` or `{ label, allow: false, reason }`.

Returns `204` on success. A rejected body answers `400` with a reason, which test helpers surface rather than leaving a bare status to be puzzled over.
:::

:::endpoint POST http://gatekeeper-test.test/control/ambient-verification-count Read how many times the ambient binding verified `label`

Request body is `{ label }`. Returns `200` with `{ "count": number }`.
:::

## Test file conventions

`__tests__/observer-reverification.test.ts` is the worked example. Its shape is the pattern to copy.

<Steps>
<Step title="beforeAll: install the interceptor, then boot the harness">
```ts
interceptor = new NetworkInterceptor();   // no handlers: every outbound call is a failure
interceptor.install();
harness = await startTestGatekeeperHarness();
```
</Step>
<Step title="Per test: take a fresh session and fresh identities">
Wrap each test body so a disposal in one session cannot disturb another running alongside:

```ts
async function withSession<T>(body: (api: RpcStub<PublicApi>) => Promise<T>): Promise<T> {
  const publicApi = connect(harness.url);
  try {
    return await body(publicApi);
  } finally {
    publicApi[Symbol.dispose]();
  }
}
```

Allocate users with `nextUsernames("alice", "bob")` and per-test resource URLs (`https://gadgets-test.example/things/<name>`). Provision accounts through `provisionAmbientAccount(TEST_VENDOR_ID)` followed by `waitFor(...)` on `listConnectedAccounts` — the account's appearance is only observable through the API's eventual state.
</Step>
<Step title="Set the failure you want, then open">
`setVerifyOutcome(bobLabel, { allow: false, reason })` puts the gadget into the state the bug lives in. Open with an `ObserverConfigRecorder` wrapped by `stubFor()`, and dispose the callback stub in a `finally`.
</Step>
<Step title="afterAll: capture escapes, close, then assert">
```ts
const unmocked = interceptor.getUnmockedCalls();
await harness?.server.close();
interceptor.uninstall();
interceptor.reset();
expect(unmocked).toEqual([]);
```
Asserted once for the whole file rather than per test, because the tests run concurrently.
</Step>
</Steps>

<Check>
`interceptor.getUnmockedCalls()` returning `[]` in `afterAll` is the signal that no test reached for the real internet. A test that deliberately provokes an unmocked request removes just its own entry with `takeUnmockedCalls(substring)`.
</Check>

## Related pages

<CardGroup cols={2}>
  <Card title="Build, lint, and test" href="/build-lint-test">
    Where `pnpm test` sits in the command ordering CI enforces.
  </Card>
  <Card title="Gatekeeper protocol" href="/gatekeeper-protocol">
    `GatekeeperVendor`, `GatekeeperUser`, `AccountDescription`, and `autoProvisionsAccount`.
  </Card>
  <Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
    The overseer behavior these suites exercise.
  </Card>
  <Card title="RPC API reference" href="/rpc-api-reference">
    `PublicApi`, `AuthenticatedApi`, `Overseer`, and stub-disposal constraints.
  </Card>
  <Card title="Build a gatekeeper" href="/build-a-gatekeeper">
    Adding a connector package a per-vendor suite would then point the harness at.
  </Card>
  <Card title="Developer conventions and contributing" href="/conventions-and-contributing">
    Promise pipelining, stub disposal, and the rest of the rules a change must satisfy.
  </Card>
</CardGroup>

---

## 22. Troubleshooting

> Known failure modes and their fixes: `OPEN_GADGET_ERROR_CODES` and observer binding failures, GitHub App versus OAuth App scope errors, missing generated modules on a clean checkout, gadget lockdown from `prohibitAllSharing`, free-tier and balance blocks, missing `WORKERS_AI` binding in gateway mode, no-op error reporting without its bindings, and RPC stub leaks or `useState` misuse.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/22-troubleshooting.md
- Generated: 2026-08-05T16:42:08.820Z

### Source Files

- `packages/workshop-shared/src/api.ts`
- `packages/gatekeeper-github/README.md`
- `docs/ai-gateway-billing.md`
- `docs/public-server.md`
- `packages/backend-utils/src/error-reporting.ts`
- `packages/workshop-backend/src/client-errors.ts`
- `AGENTS.md`

---
title: "Troubleshooting"
description: "Known failure modes and their fixes: `OPEN_GADGET_ERROR_CODES` and observer binding failures, GitHub App versus OAuth App scope errors, missing generated modules on a clean checkout, gadget lockdown from `prohibitAllSharing`, free-tier and balance blocks, missing `WORKERS_AI` binding in gateway mode, no-op error reporting without its bindings, and RPC stub leaks or `useState` misuse."
---

Most failures in this stack come from a missing optional binding, an OAuth app registered in the wrong GitHub product, or an environment variable pair that must be set together. The optional-binding cases are deliberate no-ops rather than errors: `reportIssue()` returns immediately when `ERROR_REPORTER` is absent, and `handleClientErrorRequest()` returns `204` when either `FRONTEND_ERROR_REPORTER` or `FRONTEND_ERROR_RATE_LIMITER` is unbound. Silence is the expected symptom, not a bug.

## Sign-in and gatekeeper OAuth

### `Resource not accessible by integration`

The deployment is configured with a **GitHub App** (client id beginning `Iv…`) rather than a GitHub **OAuth App**. GitHub Apps ignore the OAuth `scope` parameter entirely, so the `user:email` read that sign-in depends on is forbidden and sign-in is rejected.

<Warning>
Use a GitHub **OAuth App**, not a GitHub **App**. Only OAuth Apps honor the `scope` parameter, which is what makes minimal-scope login (`read:user user:email`) and full-scope connect (`repo read:user user:email`) behave differently.
</Warning>

Two fixes:

<Tabs>
<Tab title="Switch to an OAuth App (recommended)">
Register at GitHub Settings → Developer settings → **OAuth Apps** → **New OAuth App**, with authorization callback URL `http://localhost:8787/gatekeeper/github/oauth` (replace the host with `PUBLIC_BASE_URL` when not local). No extra permission setup is needed — the `user:email` scope is requested automatically.
</Tab>
<Tab title="Keep the GitHub App">
Grant the App the **Email addresses** account permission: App settings → **Permissions & events** → **Account permissions** → **Email addresses → Read-only** → save. Existing users must then re-run the sign-in flow to approve the added permission. Login still cannot be minimal-scope, because the App's fixed permissions apply to every authorization.
</Tab>
</Tabs>

### `redirect_uri_mismatch`

The callback URL registered on the OAuth app does not match what the gatekeeper sends. The contract is `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth`, lowercase and exact:

| Gatekeeper | Redirect URI |
| --- | --- |
| GitHub | `${PUBLIC_BASE_URL}/gatekeeper/github/oauth` |
| Google | `${PUBLIC_BASE_URL}/gatekeeper/google/oauth` |
| Cloudflare | `${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth` |

For local development that is `http://localhost:8787/gatekeeper/github/oauth` — no trailing slash, `http` not `https`.

### `bad_verification_code`

The authorization code expired or was already redeemed. Return to the app and start the connect flow again.

### "Not configured" page during authorization

The gatekeeper Worker has no `CLIENT_ID` / `CLIENT_SECRET`. For the GitHub connector, create `packages/gatekeeper-github/.env` with both values and restart the dev server:

```bash title="packages/gatekeeper-github/.env"
CLIENT_ID=your-client-id-here
CLIENT_SECRET=your-client-secret-here
```

The file is gitignored and must never be committed. In dev, gatekeeper credentials are seeded from shell variables (`GITHUB_*`, `GOOGLE_*`, `CLOUDFLARE_OAUTH_*`) by `run-dev-server.js`.

### "Continue with …" button missing on the login page

`AUTH_GATEKEEPERS` is the allowlist that decides which connected gatekeepers may be used to sign in. Without the vendor listed, `PublicApi.startGatekeeperLogin(vendorId)` throws — the vendor must be both auth-capable and allowlisted (see `ServerConfig.authVendors`).

```
AUTH_GATEKEEPERS=cloudflare,google,github
```

<Note>
`DISABLE_PASSWORD_AUTH=true` is ignored unless `AUTH_GATEKEEPERS` is non-empty. That guard exists specifically to avoid locking every user out of a deployment with no working sign-in path.
</Note>

Because the primary account key is always the user's **verified email**, signing in through any allowlisted gatekeeper that yields the same verified email lands on the same account. A user who "lost their account" after switching sign-in providers usually has a different verified email on the second provider.

## Free-tier and balance blocks

These only occur when `ENABLE_CLOUDFLARE_LIMITS=true`. Unset, usage is unlimited, which is the self-hosted default. Before each user-initiated agent turn the overseer calls `checkUsageAndBalance`, which resolves to one of four outcomes:

| User state | Outcome | Routing |
| --- | --- | --- |
| Connected, balance ≥ `$2` | Allowed | The user's own account; bills their Cloudflare credits; daily free-tier counter untouched |
| Within free tier (incl. connected with balance below `$2`, or `$0`) | Allowed | The platform's configured AI Gateway |
| Free tier exhausted, no Cloudflare account connected | Blocked | Prompt to connect |
| Free tier exhausted, connected but balance below `$2` | Blocked | Prompt to add credits |

<Steps>
<Step title="Confirm which block you hit">
"Connect" prompt means no Cloudflare gatekeeper connection exists. "Add credits" means the connection exists but the live balance is under the minimum.
</Step>
<Step title="Connect Cloudflare">
Sign in with Cloudflare, or use the "Connect Cloudflare" button, which runs the normal gatekeeper connect flow (`AuthenticatedApi.connectAccount("cloudflare")`). Billing reads a usable token from that gatekeeper's connection via `getUsableAccessToken()`; the `UserDurableObject` stores only the selected account id, a cached balance, and the daily counter — never tokens.
</Step>
<Step title="Top up">
Add credits in the [Cloudflare dashboard](https://dash.cloudflare.com/?to=/:account/ai/ai-gateway). The platform never holds money.
</Step>
<Step title="Wait out the balance cache">
The balance is read live from `/ai-gateway-billing/credit_balance` and **cached for 5 minutes**. A top-up that appears not to take effect is usually a stale cache entry.
</Step>
</Steps>

### Tuning the thresholds

```
DAILY_LLM_CALL_LIMIT=100        # free-tier LLM calls per user per UTC day
MINIMUM_CLOUDFLARE_BALANCE=2    # min connected-account balance (USD) to proceed via BYOK
```

The daily counter lives on each `UserDurableObject` (`consumeDailyLlmCall` / `checkDailyLlmCount`) and resets per UTC day — there is no separate binding to inspect or clear.

### Account selection stalls

The account to bill is auto-selected when the OAuth grant sees exactly one account. With several accounts, the user must pick one; that prompt is rendered by `components/billing/AccountSelectionModal`. Billing is account-level (Unified Billing) and inference routes through the account's auto-created "default" AI Gateway.

## AI Gateway configuration failures

### Missing `CF_AI_GATEWAY_ACCOUNT_ID` / `CF_AI_GATEWAY_API_TOKEN`

Gateway mode always requires both. All inference goes over HTTPS with tokens, and the API token needs **AI Gateway Run and Read** permissions — Read is what lets gadgets retrieve each log's cost for user-visible accounting. Omitting Read produces working inference with no cost reporting.

```
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...
```

The Gateway may live in the Worker's own account or a different one.

### Missing `WORKERS_AI` binding in local gateway mode

When any `CF_AI_GATEWAY*` variable is set in local development, start the server with the binding flag so the `webFetch` tool's document-to-Markdown conversion still has a `WORKERS_AI` binding:

```bash
pnpm run dev-server -- --use-workers-ai-binding
```

<Info>
Inference itself no longer uses the binding — it goes over HTTPS with the tokens above. Only the document-conversion path in `webFetch` still needs `WORKERS_AI`, which is why the flag is opt-in rather than implied by `CF_AI_GATEWAY`.
</Info>

### Workers AI routed unexpectedly

Workers AI uses `CF_AI_GATEWAY` as its Gateway ID by default. Two overrides change that:

| Variable | Effect |
| --- | --- |
| `CF_AI_GATEWAY_WAI` | Route Workers AI through a different Gateway in the same account |
| `CF_AI_GATEWAY_WAI_DIRECT=true` | Bypass gateways and call the Workers AI REST endpoint directly, using the same account/token pair |

`CF_AI_GATEWAY_WAI_DIRECT=true` produces **no gateway cost logs**. If per-call cost disappeared from the UI, check this flag first.

### Cloudflare OAuth endpoints look wrong

The Cloudflare dashboard OAuth endpoints and scopes are hardcoded in `packages/gatekeeper-cloudflare/src/oauth.ts` and are not configurable:

```
auth:   https://dash.cloudflare.com/oauth2/auth
token:  https://dash.cloudflare.com/oauth2/token
scopes: offline_access aig.read aig.run aig.write user-details.read account-settings.read
```

## Error reporting silently does nothing

Both reporting paths degrade to no-ops rather than failing, by design.

```text
 backend capture site                    browser report
        │                                      │
 reportIssue(failureSite, caught)      POST /api/... (JSON)
        │                                      │
        ├─ env.ERROR_REPORTER absent?          ├─ FRONTEND_ERROR_REPORTER absent?  ──┐
        │     └─> return (no-op)               ├─ FRONTEND_ERROR_RATE_LIMITER absent? ┤─> 204
        │                                      │                                     │
        └─ waitUntil(report(event))            └─ limiter.limit({key}) not success ───┘
              └─ .catch -> logger.debug                     │
                                                            └─> reporter.report(event)
```

<AccordionGroup>
<Accordion title="Backend: no events arrive from reportIssue()">
`reportIssue()` returns immediately when the optional `ERROR_REPORTER` service binding is absent — expected in local dev and in deployments without an Issue destination. When the binding *is* present, dispatch failures are logged at `debug` under `error_report.dispatch.failed`, and setup failures under `error_report.setup.failed`, both from the `backend-utils.error-reporting` component. Raise log verbosity to see them; reporting never disturbs the caller.
</Accordion>
<Accordion title="Frontend: /client-errors always returns 204">
`handleClientErrorRequest()` returns `204` with no body when either `FRONTEND_ERROR_REPORTER` or `FRONTEND_ERROR_RATE_LIMITER` is missing, and also when the rate limiter denies the request or the limiter call throws (`frontend_error_report.rate_limit.failed`). A `204` is therefore not evidence that a report was delivered.
</Accordion>
<Accordion title="Frontend: non-204 rejections">
| Status | Cause |
| --- | --- |
| `405` | Method is not `POST` (response carries `allow: POST`) |
| `403` | `origin` header does not equal the request URL origin, or CF Access JWT invalid, or the Access JWT specified no user identity |
| `415` | `content-type` is not `application/json` |
| `413` | Body exceeds `MAX_BODY_BYTES` (128 KiB), by declared `content-length` or measured stream length |
| `400` | Body is not parseable JSON, or `normalizeFrontendErrorReport()` rejected the shape |

When `CF_ACCESS_AUD` is set, the rate-limit key comes from the verified Access JWT (`accessRateLimitKey`); otherwise it falls back to `cf-connecting-ip`, or the literal `unknown`.
</Accordion>
<Accordion title="Events look incomplete or truncated">
Both event builders bound their inputs and set `truncated: true` when anything was cut. Backend `attributes` drop non-scalar values, drop duplicate keys, cap at `MAX_ATTRIBUTE_KEYS`, and clamp strings to `MAX_STRING_CHARS`. `http.kind` is normalized to exactly `"server"` or `"client"` because callers cross a JS trust boundary where the union is not enforced at runtime. A missing field usually means it was dropped by normalization, not lost in transit.
</Accordion>
</AccordionGroup>

## RPC session and stub problems

The client/server API is a Cap'n Web RPC interface over a single WebSocket opened at startup and kept open for the session lifetime, reconnecting as needed. Two consequences show up as bugs.

### Abandoned sign-in attempts

`PublicApi.startGatekeeperLogin(vendorId)` returns `{ url, attempt }` where `attempt` is an `RpcStub<LoginAttempt>`. Holding that stub *is* the capability to receive the resulting session token.

<ParamField body="url" type="string" required>
Opened by the client in a new tab; the gatekeeper's OAuth popup self-closes on completion.
</ParamField>

<ParamField body="attempt" type="RpcStub<LoginAttempt>" required>
`attempt.wait()` resolves with a session token to store and pass to `authenticate()`, and is safe to call immediately after `startGatekeeperLogin()`. It rejects if the attempt fails or is abandoned.
</ParamField>

Dispose `attempt` when the user closes the popup — disposal is what cancels the server-side wait. Failing to dispose leaks the stub and leaves a pending server-side wait; disposing while still expecting a token turns into a `wait()` rejection.

### Authentication paths

| Call | Use when |
| --- | --- |
| `authenticate(token)` | Normal token flow; token typically from `localStorage` |
| `authenticateFromCfAccess()` | The server sits behind Cloudflare Access and the browser already authenticated with Access |
| `login(username, passwordHash)` | Username/password; returns `null` for no such user or wrong password |
| `createAccount(username, displayName, passwordHash)` | Returns `null` if the username already exists; other errors throw |

`login()` and `createAccount()` may be disabled when the server uses SSO — a `login()` path that stops working after enabling SSO is expected, not broken.

`passwordHash` must be derived exactly as specified, or login fails against an account created with different parameters:

```js
argon2id({
  password,
  salt: SERVICE_SALT + encode(username, 'utf8'),
  parallelism: 1,
  iterations: 3,
  memorySize: 64MiB,
  hashLength: 32,
});
```

`SERVICE_SALT` is the fixed 16-byte constant exported from `packages/workshop-shared/src/api.ts`. The server never sees the plaintext password; it hashes the submitted `passwordHash` again server-side.

### Stale connected-account credentials

`subscribeConnectedAccounts()` delivers each account through `add(id, description, vendor, supportedResources, credentialsValid, vendorId)`. When `credentialsValid` is `false`, the account's credentials are known to be expired: the UI must call `reconnectAccount()` if the user selects it. Treating an expired account as usable produces downstream gatekeeper failures rather than a clear reconnect prompt. `ready()` fires after `add()` has been called for every account known so far — reading the list before `ready()` yields a partial set.

### Blueprint fetches that return `null`

`getBlueprint(id)` returns `null` when the blueprint does not exist and requires no authentication: knowing the ID is sufficient, because a blueprint is "just data". `downloadBlueprint(id)` streams a `.gadget` archive containing only `BlueprintMetadata` plus the current code snapshot — not the full KV record. An import that appears to lose state is usually this omission, not a corrupted archive.

### Gadget iframe cannot reach the network

By design. Gadgets run in a sandboxed iframe with no ability to talk to the outside world except `postMessage()` to the parent frame; RPC to the Workshop is bridged over those exchanges, and the Workshop hands the gadget a stub pointing at its own server-side Durable Object interface. Direct `fetch()` from gadget UI code is not a supported path.

## Local development environment

<Steps>
<Step title="Create a root .dev.vars">
Set required variables in a root `.dev.vars` file — gitignored, one `KEY=VALUE` per line. `pnpm run dev-server` loads it automatically. Variables placed elsewhere are not picked up.
</Step>
<Step title="Set PUBLIC_BASE_URL to the router port">
Locally that is `PUBLIC_BASE_URL=http://localhost:8787`. Every gatekeeper redirect URI is derived from it, so a mismatch here surfaces as `redirect_uri_mismatch`.
</Step>
<Step title="Add the Workers AI flag if using gateway mode">
`pnpm run dev-server -- --use-workers-ai-binding`.
</Step>
<Step title="Verify a gatekeeper end to end">
Open a gadget → **Connections** tab → **+ New Connection** → choose a GitHub resource type (repository, issue, or pull request) → connect the account. GitHub's authorization page opens in a new tab, the tab closes on grant, and the picker lets you select the exact resource. The gadget then has access only to the selected resource scope. Connected accounts can be added and removed from settings via the account menu in the upper right.
</Step>
</Steps>

A minimal working `.dev.vars` for the public multi-user posture:

```bash title=".dev.vars"
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google
CF_AI_GATEWAY_ACCOUNT_ID=...
CF_AI_GATEWAY_API_TOKEN=...
```

## Where billing code lives

When tracing a limits or balance bug, server code is under `packages/workshop-backend/src/ai-gateway-billing/`:

:::files
```
ai-gateway-billing/
├── config.ts                     # ENABLE_CLOUDFLARE_LIMITS / minimum-balance readers
├── limits/
│   ├── config.ts                 # daily-limit + calendar-day helpers + DailyQuotaResult
│   └── usage-checker.ts          # checkUsageAndBalance / getUsageInfo
└── cloudflare/
    ├── account-service.ts        # CF REST: accounts / balance
    └── connection-service.ts     # token, account selection, balance cache, BYOK routing
```
:::

Client side, `ServerConfigContext` exposes `cloudflareLimitsEnabled`, and `components/billing/` (`UsageSettings`, `OutOfCreditsModal`, `AccountSelectionModal`) renders the usage, top-up, and account-selection UI.

## Related pages

<CardGroup cols={2}>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
The `${PUBLIC_BASE_URL}/gatekeeper/<name>/oauth` contract, per-connector `CLIENT_ID`/`CLIENT_SECRET`, and dev seeding from shell variables.
</Card>
<Card title="Configure sign-in and AI Gateway billing" href="/configure-signin-and-billing">
`AUTH_GATEKEEPERS`, `DISABLE_PASSWORD_AUTH`, email-keyed identity, and the `ENABLE_CLOUDFLARE_LIMITS` free-tier plus top-up flow.
</Card>
<Card title="Environment variables" href="/environment-variables">
Every backend variable and its default, including the `CF_AI_GATEWAY*` family and the frontend error-reporting switch.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
`PublicApi`, `LoginAttempt`, `AuthenticatedApi`, and the stub-disposal and promise-pipelining constraints.
</Card>
<Card title="Local development" href="/local-development">
`.dev.vars` loading, the two-terminal workflow, and the `--use-workers-ai-binding` flag.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How `/gatekeeper/<name>/*` routes are derived and which backend bindings are optional.
</Card>
</CardGroup>

---

## 23. Developer conventions and contributing

> The rules a change must satisfy: pnpm only, kernel review standards for `workshop-backend` and `workshop-shared`, doc-comment every exported member, no hand-written RPC mirror interfaces with `as unknown as`, promise pipelining and stub disposal, structured logging field vocabularies and the never-log-secrets rule, opt-in frontend error reporting boundaries, and the narrow external-PR policy.

- Page Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/pages/23-developer-conventions-and-contributing.md
- Generated: 2026-08-05T16:42:29.464Z

### Source Files

- `AGENTS.md`
- `CONTRIBUTING.md`
- `packages/backend-utils/src/logger-core.ts`
- `packages/backend-utils/src/observability-context.ts`
- `packages/error-reporting/src/index.ts`
- `.oxlintrc.json`

---
title: "Developer conventions and contributing"
description: "The rules a change must satisfy: pnpm only, kernel review standards for `workshop-backend` and `workshop-shared`, doc-comment every exported member, no hand-written RPC mirror interfaces with `as unknown as`, promise pipelining and stub disposal, structured logging field vocabularies and the never-log-secrets rule, opt-in frontend error reporting boundaries, and the narrow external-PR policy."
---

Cloudflare OS is a pnpm workspace whose review standards are not uniform across packages: `packages/workshop-backend` is treated as the kernel and is read line-by-line, along with any API change in `packages/workshop-shared`. The conventions below are the ones the repository actually encodes — in `AGENTS.md`, `CONTRIBUTING.md`, `.oxlintrc.json`, and in the type signatures of `packages/backend-utils` and `packages/error-reporting`, where several rules are enforced by the type checker rather than by review.

## Package tiers and review bar

```text
┌──────────────────────────────────────────────────────────────┐
│ kernel — every line reviewed, diffs kept small and elegant   │
│   packages/workshop-backend      (Workers server)            │
│   packages/workshop-shared       (RPC API surface)           │
├──────────────────────────────────────────────────────────────┤
│ normal bar                                                   │
│   packages/workshop-frontend     (React SPA, Vite)           │
│   packages/gatekeeper-*          (per-vendor Workers)        │
│   packages/mcp-shared            (library, not a Worker)     │
│   packages/configurator-ui       (type-only helpers)         │
│   packages/typed-storage, packages/router, packages/         │
│   backend-utils, packages/error-reporting                    │
└──────────────────────────────────────────────────────────────┘
```

<Warning>
A large kernel change must be split by concern into separate PRs. At minimum, group commits so that `workshop-backend` and `workshop-shared` can be reviewed apart from UI changes. Fewer kernel lines means easier review.
</Warning>

### Kernel rules

| Rule | Applies to | Detail |
| --- | --- | --- |
| Doc-comment every exported member | `workshop-shared` public API | Types, consts, and functions — not just interfaces |
| No mirrored RPC interfaces | `workshop-shared`, `workshop-backend` | Never add a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast; derive from the real type instead, or rethink the design |
| Reuse over parallel mechanisms | kernel | Prefer extending an existing mechanism to introducing a second one alongside it |
| Small, elegant diffs | kernel | Split large changes by concern into separate PRs |

### Capability-based security invariant

A resource becomes *ambient* (auto-injected into a chat's env) only through user or admin configuration. A gatekeeper must never assert its own ambience. For auto-provisioning connectors, the account capability — not an asserted identity — is the authority after provisioning: `GatekeeperVendor.createAccount()` takes no user identity, and the per-vendor **disabled** / **optional** / **enabled** mode (default **optional**) is resolved by the deployment admin in `provisioning-policy.ts`.

For the MCP connectors, the equivalent invariant is that `packages/mcp-shared/src/tools.ts` is the trust boundary and nothing outside it reads a tool's annotations. A tool declaring `readOnlyHint: true` runs as an observation; everything else is queued for approval. Auto-applying a write additionally requires a `vetted` endpoint, producible only by the portal via `MCP_PORTAL_TRUST_ANNOTATIONS`.

<Note>
OAuth work in `mcp-shared` uses the official `@modelcontextprotocol/client`. Always pass `sdkFetch(...)` to SDK OAuth operations so every request and redirect retains endpoint and SSRF checks.
</Note>

## Package manager

pnpm only. The repository is a pnpm workspace and its scripts are pnpm scripts (`pnpm import:format-blueprint`, the recursive `build`/`types:check`/`test` scripts). Do not introduce npm or yarn lockfiles or invoke another package manager in scripts.

## RPC conventions

The client/server protocol is Cap'n Web, defined in `packages/workshop-shared/src/api.ts`. Its semantics resemble Cloudflare's Worker-to-Worker RPC while running in a browser over WebSocket; the vendored README at `packages/workshop-shared/node_modules/capnweb/README.md` is the reference.

<AccordionGroup>
<Accordion title="Derive types; never mirror an RPC interface">
A hand-written interface shaped like an RPC interface, paired with `as unknown as` to bridge the two, is rejected in review. Derive from the real type, or change the design so no bridge is needed.
</Accordion>
<Accordion title="Promise pipelining and stub disposal">
Cap'n Web stubs are capabilities with lifetimes. Pipeline dependent calls rather than awaiting each round trip, and dispose stubs you mint. In `packages/integration-tests`, stub minting is centralized so lifetimes stay auditable.
</Accordion>
<Accordion title="Never import capnweb values outside rpc-client.ts (integration tests)">
`.oxlintrc.json` restricts value imports of `capnweb` inside `packages/integration-tests/**/*.ts`, with an override re-allowing them only in `packages/integration-tests/src/rpc-client.ts`. `import type` remains allowed everywhere.

```jsonc
// .oxlintrc.json — integration-tests override
"no-restricted-imports": ["error", { "paths": [{
  "name": "capnweb",
  "message": "Mint stubs via stubFor() from rpc-client: a consumer repo can hold two capnweb copies, and a stub from the wrong one fails to serialise. `import type` is fine.",
  "allowTypeImports": true
}]}]
```

The failure this prevents: a repo that vendors this one as a submodule installs its own workspace and this one separately, ending up with two copies of `capnweb`. A stub minted by one copy is unserialisable by the other's session — an error that surfaces only once the installs are split, i.e. in CI.
</Accordion>
</AccordionGroup>

## Structured logging

Backend logging goes through `packages/backend-utils`. `createLoggerWithContext()` returns a `Logger<ExtraFields>` whose `debug`/`info`/`warn`/`error` methods each take a message plus a details object, and whose `with()` returns a new logger with additional fields rather than mutating the receiver.

```ts
// packages/backend-utils/src/logger-core.ts
export interface Logger<ExtraFields extends object = Record<never, never>> {
  with(fields: Readonly<Partial<AllowedFields<ExtraFields>>>): Logger<ExtraFields>;
  debug(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
  info(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
  warn(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
  error(message: string, details: Readonly<LogDetails<ExtraFields>>): void;
}
```

<ParamField body="component" type="string" required>
Base field on every logger, supplied to `createLoggerWithContext(defaults)`. It is fixed: `#write` re-applies `component` last, so neither ambient context nor call details can override it.
</ParamField>

<ParamField body="event" type="string" required>
Required on every log call. Details are `LogDetails<ExtraFields>`, which mandates `event` and allows optional `error`.
</ParamField>

<ParamField body="error" type="unknown">
Optional per call. Normalized to a string via `normalizeError`; when the value is an `Error` with a stack, `errorStack` is populated automatically. When `error` is `undefined` the field is deleted rather than emitted as `undefined`.
</ParamField>

### Reserved fields and the never-log-secrets rule

`ReservedLogField` is a closed union, and the `ProhibitedFields` mapped type sets each reserved key to `?: never` in `ExtraFields`, logger defaults, and call details. Attempting to attach one is a type error, not a runtime warning.

| Reserved field | Allowed as a caller-supplied field? |
| --- | --- |
| `secret` | No |
| `token` | No |
| `prompt` | No |
| `body` | No |
| `message` | No — written by the logger from the message argument |
| `header`, `headers` | No |
| `errorStack` | No — derived from `error` |
| `component` | Only as a logger default (`LoggerDefaults` exempts it) |
| `event` | Only in call details (`LogDetails` exempts it) |
| `error` | Only in call details (`LogDetails` exempts it) |

<Warning>
The reserved list is the mechanical half of the rule; the intent is broader. Never route secrets, tokens, prompts, request bodies, or headers into logs under a differently named field either — the type system cannot see through a rename.
</Warning>

### Field value types

Field values must satisfy `LogValue`: `string | number | boolean | Date | null | undefined`, plus nested records and arrays of `LogValue`. `SafeFields` maps any non-`LogValue` field to `never`, so an object that Workers Logs cannot represent fails to type-check.

### Ambient context

`createObservabilityContext<Fields>()` in `packages/backend-utils/src/observability-context.ts` builds an isolated, typed context per package or domain over `AsyncLocalStorage`, returning `{ createLogger, get, with: withContext }`.

Merge precedence in `#write`, lowest to highest: ambient context → logger defaults → call details → `component`.

<Warning>
Context does not cross RPC, hibernation, or restart. Re-establish it at those boundaries; a gatekeeper convention is a logger carrying `component` and `vendorId`.
</Warning>

```mermaid
classDiagram
  class Logger~ExtraFields~ {
    <<interface>>
    +with(fields) Logger
    +debug(message, details)
    +info(message, details)
    +warn(message, details)
    +error(message, details)
  }
  class LoggerImpl {
    -#defaults LoggerDefaults
    -#readContext LogContextReader
    -#write(level, message, details)
  }
  class ObservabilityContext {
    +createLogger(defaults) Logger
    +get() ContextFields
    +with(fields, callback) Result
  }
  class ReservedLogField {
    <<type>>
    body component error errorStack
    event header headers message
    prompt secret token
  }
  Logger~ExtraFields~ <|.. LoggerImpl
  ObservabilityContext ..> Logger~ExtraFields~ : createLogger
  LoggerImpl ..> ReservedLogField : excluded via ProhibitedFields
```

## Frontend error reporting

Error reporting is opt-in and bounded by design. `packages/error-reporting` defines two distinct schemas whose trust levels differ, and the distinction is load-bearing.

| Type | Producer | Trust |
| --- | --- | --- |
| `ErrorEventV1` | Worker-side capture sites | Internal; `attributes` capped at `MAX_ATTRIBUTE_KEYS` (32) scalars |
| `ErrorReporterProps` | Reporter service binding config | Trusted producer metadata (`service`, `release`, `environment`) |
| `FrontendErrorReportV1` | Browser | Untrusted, bounded; "no field in this report conveys authority" |
| `FrontendFrameErrorReportV1` | Trusted opaque-origin frame | A `Pick` of the frontend report: `failureSite`, `severity`, `handled`, `captureMechanism`, `exception` |

### Boundary rules

- Only trusted opaque-origin UI frames may post failures, using the `FRONTEND_ERROR_MESSAGE_TYPE` discriminator `"gadgets.frontend-error.v1"`.
- Every enumerated field arriving from a frame is allowlisted, never passed through: `severity` ∈ `warning | error | fatal`; `captureMechanism` ∈ `window.error | unhandledrejection | react | explicit`; `surface` ∈ `workshop | gatekeeper-app | configurator`; `browser.family` ∈ `Chromium | Firefox | Safari | Other`; `browser.platform` ∈ `Windows | macOS | Linux | Android | iOS | Other`.
- Unrecognized values fall back to a default (`severity: "error"`, `captureMechanism: "explicit"`) rather than being propagated.
- Strings are clipped against `MAX_STRING_CHARS`, `MAX_MESSAGE_CHARS`, and `MAX_STACK_CHARS`; any clip sets `truncated: true` on the report.
- Untrusted objects are read with `Object.getOwnPropertyDescriptor(...)?.value` (`ownValue`), not direct property access, so prototype-supplied values cannot slip in.
- `FrontendBrowserFacts` is documented as coarse triage telemetry and is "never authoritative for identity or access."

<Note>
Reporting is opt-in via the `VITE_FRONTEND_ERROR_REPORTING` frontend variable and the optional reporter binding on the backend. Without both, capture is a no-op — see the environment-variables and troubleshooting pages.
</Note>

## Lint and type-check posture

`.oxlintrc.json` sets `correctness` and `suspicious` to `error` and enables the `typescript`, `unicorn`, `oxc`, and `import` plugins, with per-area overrides.

| Scope | Added plugins / env |
| --- | --- |
| `packages/workshop-frontend/**/*.{ts,tsx}` | `react`, `jsx-a11y`; `browser` env |
| `packages/gatekeeper-*/**/*.tsx` | `react`; classic JSX runtime with the `h` pragma, not automatic `react-jsx` |
| `workshop-backend`, `router`, `gatekeeper-*/src`, `workshop-shared`, `typed-storage` | `serviceworker` env |
| `**/*.test.ts(x)`, `**/vitest.config.ts` | `vitest` plugin and env |
| `scripts/**/*.mjs`, root `*.js`/`*.mjs` | `node` env |

Rules deliberately turned off, and why:

| Rule | State | Reason recorded in config |
| --- | --- | --- |
| `import/default` | off | Gatekeepers import `.txt` files as bundled text assets; the resolver misreports "no default export" |
| `import/no-unassigned-import` | off | Side-effect imports are deliberate: `./styles.css`, `cloudflare:workers` |
| `unicorn/no-empty-file` | off | Comment-only placeholder modules are kept intentionally (e.g. `App.tsx`, `gatekeeper-cloudflare/src/types.d.ts`) |
| `no-underscore-dangle` | off | Conflicts with the `_`-prefix convention for intentionally unused bindings |
| `no-unused-vars` | error, tuned | `args: "none"`, `caughtErrors: "none"`, `varsIgnorePattern: "^_"`, `ignoreRestSiblings: true` — unused imports and locals still flagged |
| `no-shadow`, `typescript/no-this-alias`, `typescript/no-extraneous-class`, `unicorn/consistent-function-scoping` | warn | Real improvements but churny for an initial rollout; visible for incremental cleanup instead of blocking CI |

Ignored paths: `**/dist/**`, `**/generated/**`, `**/*.gen.ts`, `**/node_modules/**`, `**/.wrangler/**`, `**/worker-configuration.d.ts`.

<Warning>
Type-aware linting is intentionally not enabled. The type-aware engine uses tsgo (TypeScript 7), which requires an explicit `rootDir` when emitting declarations and has dropped `baseUrl`. This monorepo emits declarations while importing sibling-package *source* files via `paths`, so a `rootDir` that satisfies tsgo would break the real `tsc` build (TS6059). Full type safety is enforced by `tsc` through the `types:check` script instead — do not "fix" this by enabling type-aware rules.
</Warning>

## Generated files are not editable

Generated modules live under ignored paths and are produced by scripts, not committed by hand.

:::files
```text
packages/workshop-backend/
  format-blueprints/          committed data: <name>.gadget + <name>.json
  src/generated/
    format-blueprints.ts      gitignored; built by scripts/build-format-blueprints.mjs
packages/gatekeeper-context/
  app/                        single-file React SPA (Vite + Tailwind + Kumo)
  src/generated/app.txt       built by build-app.mjs
packages/gatekeeper-*/
  src/generated/              configurator UI built by
                              scripts/build-gatekeeper-configurator.mjs
```
:::

`scripts/build-format-blueprints.mjs` globs `format-blueprints/` (override the directory with `FORMAT_BLUEPRINTS_DIR` so a fork can ship its own set without touching this submodule) into the gitignored `src/generated/format-blueprints.ts`. Because of that, `build`, `types:check`, and `test` all run the generator first.

<Steps>
<Step title="Replace an existing format blueprint">
```bash
pnpm import:format-blueprint <export.gadget> <blueprintId>
```
</Step>
<Step title="Add a new format blueprint">
```bash
pnpm import:format-blueprint <export.gadget> --new <name>
```
</Step>
<Step title="Never rename a deployed blueprintId">
Install and promotion are keyed on `blueprintId`; a rename orphans the old entry. See `format-blueprints/README.md`.
</Step>
</Steps>

## External contribution policy

`CONTRIBUTING.md` states the project is not seeking outside contribution at this time. The stated reasoning: AI has made writing code easy, and the hard part today is reviewing it, keeping quality high, and keeping the product coherent — so external code contributions donate the easy part of the job while creating more of the hard work.

| Contribution | Accepted |
| --- | --- |
| Small, trivially-verified PR that fixes a problem | Yes |
| Low-value PR (e.g. typo fix) | No — closed with a reference to the guideline |
| PR larger than a dozen or so lines | No — closed with a reference to the guideline |
| Big idea | Open a discussion at `https://github.com/cloudflare/cloudflare-os/discussions` |

<Note>
The policy may change as the project matures.
</Note>

## Pre-submit checklist

<Check>
Ordered by what tends to fail first in review.
</Check>

1. Used pnpm; no other package manager artifacts or invocations added.
2. Kernel diff (`workshop-backend`, `workshop-shared`) is small; large changes split by concern into separate PRs, and at minimum committed so kernel review is separable from UI review.
3. Every new exported member of the `workshop-shared` public API — type, const, and function — carries a doc comment.
4. No hand-written interface mirroring an RPC interface, and no `as unknown as` bridge; types derived from the real ones.
5. Cap'n Web usage pipelines dependent calls and disposes minted stubs; integration-test code mints only through `stubFor()` in `rpc-client.ts`.
6. No gatekeeper asserts its own ambience; ambience comes from user or admin configuration.
7. Logging passes `component` at logger creation and `event` at each call site, uses `LogValue`-compatible fields, and touches no reserved field — including under a renamed key.
8. Any new frontend error path allowlists enumerated values, bounds strings, marks `truncated`, and treats browser-supplied data as untrusted and non-authoritative.
9. Generated modules were regenerated by their scripts, not edited; no deployed `blueprintId` renamed.
10. `pnpm lint` (oxlint plus recursive `tsc --noEmit`) and `pnpm test` pass, with the generators having run first.

## Related pages

<CardGroup cols={2}>
<Card title="Build, lint, and test" href="/build-lint-test">The exact commands CI enforces, their ordering, and generator prerequisites.</Card>
<Card title="RPC API reference" href="/rpc-api-reference">The Cap'n Web interfaces plus stub-disposal and promise-pipelining constraints.</Card>
<Card title="Build a gatekeeper" href="/build-a-gatekeeper">Connector package layout, configurator UI build, and the `component`/`vendorId` logger.</Card>
<Card title="Environment variables" href="/environment-variables">`VITE_FRONTEND_ERROR_REPORTING`, `MCP_PORTAL_TRUST_ANNOTATIONS`, and the rest of the backend surface.</Card>
<Card title="Integration testing" href="/integration-testing">How `createTestHarness()` boots real workers and speaks Cap'n Web over `/api`.</Card>
<Card title="Manage bundled format blueprints" href="/bundled-format-blueprints">`FORMAT_BLUEPRINTS_DIR`, the `.gadget`/`.json` split, and why `blueprintId` is immutable.</Card>
</CardGroup>

---
