Search for a command to run...
Compiled from 14 nodes · est. 72 min read
Updated
Cloudflare OS is an open-source, self-hostable AI agent workspace built entirely on Cloudflare's developer platform — Workers, Durable Objects, KV, and AI Gateway — that lets users create, run, and share Gadgets: small AI-powered mini-applications with their own code, chat history, and external service connections. The project began in July 2025 as an internal Cloudflare experiment code-named "Minions," was renamed to Gadgets in early 2026, and was open-sourced under Apache 2.0 so organizations can copy and customize it as their own company OS. Two architectural facts explain almost everything else: every workspace is a Durable Object and every Gadget runs in its own Dynamic Worker Facet, and every external service call is mediated by a Gatekeeper — a plugin Worker that handles OAuth, scopes access, and can require per-action human approval.
The Project orientation page (this one's neighbor) expands on what Cloudflare OS is, the OS analogy, the package layout, and how to contribute. The three core capability sections — Gadgets and blueprints, Agent and chat, and Gatekeepers — describe respectively the sandboxed app model, the Code Mode coding agent that builds and runs Gadgets, and the security framework that enforces guardrails on everything they do. Specific gatekeepers and Routing and admin drill into the shared mcp-shared library, individual gatekeeper implementations (GitHub, Cloudflare, Context, and others), and how the packages/router worker dispatches requests to them via service bindings. Sharing and permissions covers the collaborator roles and observer-verification model that governs who can see and act on a Gadget. Runtime and bindings (with its child AI Gateway and models) documents the Wrangler configuration, Durable Object and Facet architecture, environment variables, SSRF protection, and how AI providers are resolved. Developer workflows, Requirements and compatibility, and Release process cover local development with pnpm run-local, the pinned toolchain (pnpm, Node.js, Wrangler), the integration test harness, and how immutable releases are built by scripts/release/build-release.mjs. Migrations and breaking changes records the API shifts a returning reader needs to know about, including the move to numeric action IDs and the removal of Action/RevertInfo generics from the Gatekeeper type.
If you want to understand the architecture before touching code, read Project orientation and then Runtime and bindings for the Durable Object, Facet, and binding model that everything else assumes. If you're here to run or hack on the codebase, jump to Developer workflows for pnpm run-local and the test harness, and check Requirements and compatibility for the pinned pnpm and Node.js versions. If you're writing or debugging a Gatekeeper, start with Gatekeepers for the capability-based API and human-in-the-loop model, then Specific gatekeepers and Routing and admin for concrete implementations and dispatch. If you're building or sharing a Gadget, read Gadgets and blueprints for the sandbox and blueprint model, Agent and chat for the Code Mode agent that edits Gadget code, and Sharing and permissions for the collaborator and observer rules.
Updated
Cloudflare OS is an open-source AI workspace uniting an agent chat UI, sandboxed app development (Gadgets), and security controls (Gatekeepers) into three integrated capabilities, with architecture mirroring a traditional operating system. The codebase centers on packages/workshop-backend (kernel), packages/workshop-frontend (shell), and communication via Cap'n Web RPC over persistent WebSocket between client and server.
Cloudflare OS is an AI productivity workspace originally developed for internal use at Cloudflare and now open-sourced so organizations can copy and customize it as their own company OS.[1] Cloudflare OS provides three core capabilities: an agent chat UI preloaded with company knowledge, sandboxed AI-built app development (Gadgets), and a security framework (Gatekeepers) that enforces guardrails for both agents and apps — covered in detail on the Gadgets and blueprints, Agent and chat, and Gatekeepers pages respectively.[1]
The codebase maps onto a traditional OS: packages/workshop-backend is the kernel, packages/gatekeeper-* are device drivers, packages/workshop-frontend is the shell, gadgets are processes, and blueprints are executables.[1]
packages/workshop-frontend is a pure single-page app (React + Kumo UI + Phosphor icons + Vite) that communicates with the backend exclusively over a persistent WebSocket using Cap'n Web RPC.[2] packages/workshop-shared defines the application's RPC interface between frontend and backend using Cap'n Web — a protocol with similar semantics to Cloudflare Worker-to-Worker RPC that runs in a browser over WebSocket — with the root API defined in packages/workshop-shared/src/api.ts.[2] packages/configurator-ui provides type-only component helpers used by optional gatekeeper resource configurator UI modules, compiled by scripts/build-gatekeeper-configurator.mjs as part of package builds.[2] Bundled format blueprints live in packages/workshop-backend/format-blueprints/ as <name>.gadget archives plus <name>.json sidecars; scripts/build-format-blueprints.mjs bundles that directory (overridable with FORMAT_BLUEPRINTS_DIR) into a generated module.[3] packages/workshop-backend/src/server.ts re-exports all Durable Object entrypoint classes for wrangler binding, including OverseerDurableObject, GatekeeperLoopback, GatekeeperHookLoopback, CodeModeTailLoopback, AgentSpawnerGatekeeper, GadgetTailLoopback, AgentSelfLoopback, TransientStubLoopback, UserDurableObject, GatekeeperConnectCallbackImpl, PendingLogin, LoginConnectCallbackImpl, LanguageModelGatekeeper, AdminSettings, and ExternalMessageGateway.[4] The workshop-backend Worker's main entry point is .wrangler/validate/src/server.ts, a path generated by the capnweb-validate layer rather than pointing directly to src/server.ts.[5] packages/gatekeeper-context/src/index.ts is the Context Library worker entry point, exporting ContextCollectionDurableObject, UserLibraryDurableObject, LibraryRegistryDurableObject, GatekeeperVendor, ContextAccount, ContextVerifier, and ContextGatekeeper; its default HTTP handler returns a plain-text health-check response and is not used for product traffic.[6]
The project accepts only small, trivially-verified PRs of roughly a dozen lines or fewer that fix a concrete problem; low-value changes such as typo fixes are also declined.[7] Large ideas should be proposed as GitHub Discussions rather than PRs, as unsolicited large PRs will be closed.[7]
Sources
Updated
Cloudflare OS development uses pnpm run-local to spin up the full stack locally (wrangler, workerd, frontend), and relies on Wrangler's local test harness to run integration tests against real Workers in workerd with injected dependencies rather than mocks. The router gates requests to workshop-backend (the primary Worker) and frontend assets or Vite dev server; local development, testing, and deployment each configure different bindings and asset handling via wrangler.jsonc and environment variables. Cap'n Web is a WebSocket-based RPC protocol derived from Cap'n Proto, used as the transport layer for all client-to-Worker communication in Cloudflare OS. tsgo is a Go-based rewrite of the TypeScript compiler that is substantially faster than tsc but may surface previously-suppressed type errors or behave differently on edge cases.
The quickest way to run the cloudflare-os codebase locally is pnpm run-local, which starts the full stack on wrangler and workerd and serves it at http://localhost:8787.[[1]](https://github.com/cloudflare/cloudflare-os/blob/859a56f1f683de74c46c882801325e45bb759a57/README.md) During local development, Wrangler launches a local Chrome instance to emulate the Browser Run API for the BROWSER binding; add "remote": true to the browser config block to use a remote Cloudflare browser instead.[2] The router doubles as the dev router when pnpm dev-server is used: with no ASSETS binding it proxies frontend requests to the Vite dev server instead.[3] Vite configurations across all gatekeeper packages were restructured to reduce pnpm dev-server startup time. To run public-service mode locally, a .dev.vars file must supply PUBLIC_BASE_URL, ENABLE_CLOUDFLARE_LIMITS, AUTH_GATEKEEPERS, and OAuth client ID/secret pairs for each configured gatekeeper.[4] scripts/run-dev-server.ts automatically seeds Linear and Spotify OAuth credentials into the local development environment at startup, eliminating manual credential configuration for engineers developing those gatekeeper integrations.
workshop-backend is built with pnpm run build:worker; Wrangler watches the src directory for changes during development.[2] Worker type definitions are generated by running node scripts/generate-worker-types.mjs via the types:generate script; the output file worker-configuration.d.ts is auto-generated by wrangler types and must not be edited manually.[5][6] To replace a format blueprint use pnpm import:format-blueprint <export.gadget> <blueprintId>; to add a new one use pnpm import:format-blueprint <export.gadget> --new <name>. Never manually edit a blueprintId.[3] The frontend can optionally be bundled as static assets on the backend Worker by adding an assets block to wrangler.jsonc pointing to ../workshop-frontend/dist, with not_found_handling: single-page-application and run_worker_first for /api, /api/*, and /blueprint-screenshot/*. This block is commented out by default and is not the only deployment approach.[2] A GitHub Actions workflow (.github/workflows/contribution-policy.yml) uses scripts/contribution-policy.js to automatically close pull requests that clearly violate the contribution policy; policy rules and their test cases live in scripts/contribution-policy.js and contribution-policy.test.js respectively. The monorepo uses tsgo (TypeScript's Go-based compiler) for type checking across tsconfig.json and per-package tsconfig.app.json files in gatekeeper-context, gatekeeper-scheduler, and typed-storage. If a change passes local type checking with tsc but fails CI, the compiler difference (tsgo vs tsc) is the first diagnostic step. A preview.yml GitHub Actions workflow automatically deploys a Worker preview for each pull request; PR-scoped configuration is generated by scripts/preview/staging-config.ts and deployment is driven by scripts/preview/preview.ts. Scripts in scripts/ are written in TypeScript and run via Node's native type-stripping — no separate transpile step is needed; scripts/tsconfig.json defines the type-check boundary. Every script in scripts/ must have a co-located <script>.test.ts test file; new scripts and modifications to existing scripts alike are expected to follow this convention. scripts/pnpm-command.ts is the central utility for spawning pnpm subprocesses and handles cross-platform process invocation; it is the canonical reference for how subprocess execution works across platforms. Related entry points include scripts/run-local.ts, scripts/run-dev-server.ts, and scripts/bin-entry.ts. @gadgets/scripts is a shared workspace package that exports centralized Vite and Vitest configurations; packages including gatekeeper packages, backend-utils, configurator-ui, error-reporting, and gatekeeper-kit import their build configs from it rather than maintaining local copies. New packages must extend Vite and Vitest configurations from @gadgets/scripts rather than copying configs from existing packages. scripts/run-dev-server.ts, scripts/run-local.ts, and scripts/relay-termination.ts include process-tree management improvements for concurrent runs. scripts/vp/concurrency.ts detects the host machine's CPU count and derives the vp run parallelism limit from it, replacing a static default.
The root test script runs node --test scripts/*.test.js followed by recursive pnpm run test across all packages, so the build must succeed before tests are executed — CI's test job runs pnpm build then pnpm test.[5][7] CI triggers on pushes to main and on all pull requests; repository permissions are restricted to contents: read, and both jobs use pinned, SHA-verified versions of actions/checkout and actions/setup-node with persist-credentials: false to reduce supply-chain risk.[7]
Integration tests in packages/integration-tests use wrangler's createTestHarness() to boot workshop-backend and one or more gatekeepers as real Workers in workerd, with their checked-in wrangler.jsonc patched in memory. Tests communicate over Cap'n Web via WebSocket to /api — the same transport the browser uses.[8] workshop-backend is placed first in the workers array so that unrouted requests (e.g. /api) go to it as the primary worker.[9] startTestGatekeeperHarness() is a convenience wrapper that boots the Workshop with only the bundled fixture gatekeeper bound, using binding TEST and the fixtures/gatekeeper-test directory.[9] Harness.fetchWorker(name, ...args) dispatches a request directly to a named worker's HTTP entrypoint without host resolution, so no routes config is needed; the path must still match what the worker expects.[9] The packages/integration-tests suite runs with pnpm test as part of CI's normal test job using a fixture gatekeeper, while per-vendor consumer-repo suites run in their own CI step against real vendor gatekeepers.[8] packages/integration-tests/src/rpc-client.ts provides RPC client support for Workshop lifecycle, sharing, presence, and blueprint test scenarios. Integration test modules in packages/integration-tests/__tests__/ cover Workshop lifecycle (create/open/close), sharing flows, real-time presence, and blueprint creation and output validation, exercising the public RPC interface with a mock model.
Only outbound HTTP is stubbed in the integration test suite — nothing else is mocked. The code under test runs in a separate workerd process, which has two key consequences: vi.useFakeTimers() patches only the test process's clock and is invisible to the Worker, and time-dependent logic such as isTokenExpired()'s 30-second skew in gatekeeper-shared is evaluated entirely inside that Worker.[8] Storage persists for the harness's lifetime — no test may assume a clean slate. Tests stay independent by taking fresh identities via nextUsernames() from the toolkit, using per-test resource URLs, and relying on account labels allocated by the connect/provision helper rather than chosen by the caller.[8] server.reset() costs roughly 3 seconds per call, restarts the server (making server.url undefined and killing all open WebSocket RPC sessions), and is a teardown tool — not a between-tests storage wipe.[8] fixtures/gatekeeper-test/ is a real Worker speaking the real gatekeeper protocol whose verification outcome tests control via an HTTP control route. It is scoped to overseer logic testing, not a substitute for per-vendor coverage — see Gatekeepers for the full gatekeeper protocol details.[8]
In packages/gatekeeper-scheduler/src/scheduler.ts, ScheduleSessionImpl accepts injectable now and randomId functions via ScheduleSessionDependencies, enabling deterministic unit testing of schedule registration timing and ID generation.[10]
Sources
Updated
The release process (build-release.mjs) creates an immutable artifact containing worker bundles, static assets, and a manifest describing the whole release; the manifest is written last to mark completion. Release IDs use CI pipeline numbers to ensure a monotonic sequence that protects against concurrent promotion and guards against obsolete releases being promoted. A break-glass command named Bonk is defined in .github/workflows/bonk-pr.yml and can be triggered on pull requests to bypass normal workflow gates in emergency situations.
The release build script at scripts/release/build-release.mjs is invoked as node scripts/release/build-release.mjs --out <dir> [--release-id <id>], where --out is required and --release-id is optional (auto-generated when omitted).[1] scripts/release/build-release.mjs produces an immutable release consisting of worker bundles (via wrangler deploy --dry-run --outdir), static assets (Access-mode frontend build), and a manifest.json that describes everything; the manifest is written last because its presence marks the release as complete.[1] The script builds the frontend first because workshop-router's wrangler.jsonc points its assets directory at workshop-frontend/dist, which must exist before the router's dry-run bundle step.[1] Only the Access-mode frontend variant is built (VITE_CF_ACCESS_MODE=true), making it the one asset variant every release carries; the flag is a build-time switch in workshop-frontend/src/useAuth.ts.[1] scripts/release/build-release.mjs uses killProcessTree (defined in scripts/kill-process-tree.ts, tested in scripts/kill-process-tree.test.ts) to cleanly tear down child processes when a concurrent build fails. scripts/release/build-release.mjs uses a mapConcurrent utility (defined in scripts/map-concurrent.ts, tested in scripts/map-concurrent.test.ts) to run worker deploy builds concurrently. Preview environment gatekeepers receive OAuth application credentials automatically at deploy time via env-passthrough into scripts/preview/staging-config.ts, enabling OAuth-dependent flows (e.g. Google, GitHub gatekeeper sign-in) that would otherwise silently fail. scripts/release/manifest-lib.ts validates and normalizes shortName values at build time, enforcing legal install-slug character-set and length constraints so violations are caught before the installer stage.
The release ID uses CI_PIPELINE_IID (per-project monotonic) rather than CI_PIPELINE_ID (instance-global), because run numbers are compared by promote-release.mjs's supersededBy() guard and must form a single monotonic sequence from one publisher.[1] The release commit SHA is read from the CI_COMMIT_SHA environment variable in CI; locally it falls back to git rev-parse HEAD.[1] Worker module blobs in the release output are content-addressed by SHA-256 (<out>/modules/<sha256>), and static asset blobs are content-addressed by Cloudflare hash (<out>/assets/<cfHash>).[1] The build script reads the pinned wrangler version from node_modules/wrangler/package.json and records it in the manifest, so every release artifact knows which wrangler version produced it.[1]
Concurrent promotion runs are not safe against the shared release copy; CI serializes them with a GitLab resource group, and the promote script's supersededBy() newer-release guard skips already-superseded candidates.[2]
Sources
Updated
Cloudflare OS pins its toolchain (pnpm, Node.js, Vite, Vitest, Wrangler) to exact versions across the workspace and CI to ensure reproducible builds and sidestep known breakages. A minimumReleaseAge policy enforces 24-hour supply-chain quarantine for most dependencies, with exemptions for internal Cloudflare packages, while nodejs_compat compatibility flags enable the inference and routing layers to use standard Node.js SDKs.
The workspace uses pnpm@11.17.0 (hash-pinned) as its package manager.[1] CI pins Node.js to 22.14.0 for both the lint and test jobs, runs on ubuntu-latest, and installs dependencies with pnpm install --frozen-lockfile via Corepack.[2]
vite is pinned to exactly 7.3.6 across the entire workspace via a pnpm override to avoid Oxc Stage-3 decorator breakage (workers-sdk#12626); Vitest pool workers are aligned at 0.18.8 and Wrangler at 4.118.0 in the same pin pass.[3][4] @types/node is pinned to 26.1.0 and @lezer/markdown to 1.6.4 via workspace overrides to prevent minimumReleaseAge from being bypassed for these frequently-updated packages.[3] The minimumReleaseAge policy rejects any dependency version published within the last 1440 minutes (24 hours), matching the CI supply-chain policy so local installs cannot commit a too-fresh lockfile.[3] Exempt from minimumReleaseAge are capnweb, capnweb-validate, workerd, and @cloudflare/workerd-* packages, which may be used at any release age.[3] The integration-test wrangler version is pinned to ~4.104.0 because a newer wrangler brings a newer miniflare that requires a newer workerd than the root overrides pin, causing the harness to fail to boot; bumping wrangler therefore requires bumping the workerd override in step.[5]
The nodejs_compat compatibility flag is required by the pi-ai inference layer, which wraps official provider SDKs (@anthropic-ai/sdk, openai, @google/genai) and Puppeteer (used for Gadget PDF exports) — all of which need Node.js compatibility.[6] gatekeeper-context declares both the nodejs_compat and allow_irrevocable_stub_storage compatibility flags in its wrangler.jsonc — further detail on that Worker lives on the Routing and admin page.[7] The nodejs_compat compatibility flag causes Cloudflare Workers to polyfill or proxy standard Node.js built-ins (such as Buffer, crypto, and stream) that are otherwise absent in the Workers runtime.
Sources
Updated
A Gadget is a private, sandboxed instance of an AI-built application; users can modify its code on demand without affecting others, and Blueprints are reusable templates capturing the gadget's code and structure—shareable as .gadget archives or published for discovery. Blueprints are owned by the gadget's creator, stored across three backing stores (Gadget DO, User DO, Workers KV), and accessed publicly by ID without authentication; Blueprints explicitly exclude runtime state (storage, chat history, live credentials). A Durable Object (DO) is a Cloudflare Workers primitive that provides a single-instance, stateful computation with its own persistent storage — guaranteeing that all requests to a given DO are handled serially by one instance worldwide. Workers KV is Cloudflare's globally distributed key-value store with eventual consistency semantics — reads may lag behind writes across regions, making it suitable for high-read, low-write public data such as Blueprint lookups. Operational Transformation (OT) is a concurrency-control technique that imposes ordering and conflict-resolution on concurrent edits, as opposed to last-write-wins semantics.
A Gadget is a private instance of an AI-built application that runs in its own sandbox — each user gets their own copy, not a shared SaaS instance.[1] Gadget sandboxing provides two security properties: it prevents cross-user data leakage from app bugs, and it makes it safe for users to modify gadget code on demand via the agent.[1] Workspaces support multiple gadgets, each accessible via the sidebar.[2] AgentGadgetInfo.isDefault marks the workspace's default gadget — the gadget that tools operate on when their gadget-name parameter is omitted. Only workspaces migrated from single-gadget days (or created from a blueprint) have one.[3] Cross-gadget hook targeting allows a hook registered in one gadget to route events to or trigger behavior in any sibling gadget within the same workspace, enabling coordination across gadgets. In workshop-backend, agent.ts and overseer.ts support hooks targeting any gadget within a workspace — hooks are no longer scoped solely to the gadget that registered them. The frontend export menu disables the export button while an export is in progress, preventing duplicate submissions. Gadgets can declare their own export entrypoint; gadget-export.ts, browser-export.ts, and browser-export-runtime.ts resolve it via a browser-based runtime, replacing a previous monolithic export path. In overseer.ts and gadget-export.ts, entrypoint resolution is the designated failure surface for exports — both missing export entrypoints and missing files are handled with explicit error paths. SandboxedGatekeeperApp.tsx was updated for embedded iframe modal presentation; any gatekeeper that renders a sandboxed UI must be validated against the updated embedding contract. Workspace file state is persisted in src/git-store.ts; the Git repository structure is the canonical source of truth for workspace content. Backend sync reconciliation in agent.ts and overseer.ts uses Operational Transformation (OT) rather than last-write-wins semantics, imposing ordering and conflict-resolution constraints on concurrent edits across backend and frontend collaboration. The frontend code editor is CodeMirror-based, implemented in CodeEditor.tsx and CodeDiffEditor.tsx, with accompanying styles in CodeDiffEditor.css and tests in CodeDiffEditor.test.tsx.
Blueprints are the Cloudflare OS equivalent of templates — they specify a whole application (code and structure), not just document content, and can be created from existing Gadgets and shared.[1] A single Gadget can have multiple Blueprints, potentially at different code versions (e.g. a "stable" and a "latest" blueprint of the same gadget).[4] A Blueprint is always owned by the gadget's owner, regardless of which collaborator creates it. Bundled (format) blueprints have no owning user at all.[4] A Blueprint captures source code, binding requirements, and metadata, but explicitly does NOT capture SQLite storage contents, AI chat history, edit history, or live credentials — only the shape (type, gatekeeper name, URL pattern) of each binding.[4]
Blueprint IDs are 128-bit random hex strings generated server-side, except for bundled (format) blueprints which carry stable, readable IDs like format.document.[4] A blueprintId must never be changed after deployment — installs and promotions are keyed on it, and renaming orphans the old entry.[5] When a Blueprint is updated to reflect newer code, its version number increments and old code versions are retained in R2 storage to avoid race conditions during concurrent instantiation.[4]
Blueprint data flows one-way through three stores: Gadget DO (blueprints collection, authoritative) → User DO (blueprints collection, denormalized for listing) → Workers KV (BLUEPRINTS namespace, public-facing lookup).[4] Blueprint code content is stored in an R2 bucket (BLUEPRINT_CONTENT) keyed by <blueprintId>/<version> as a Yjs V2-encoded gzip-compressed document (full state, not incremental updates). Old versions are retained on update; all versions are cleaned up on deletion.[4] The dirty flag on a Blueprint's Gadget DO record is set to true before propagation begins and cleared only after all writes succeed. If a failure leaves it set, the UI shows a warning with a "Retry" button.[4] The Gadget DO holds the authoritative blueprints collection; the User DO holds a denormalized copy for listing purposes.
The .gadget export/import file format is a binary container with an 8-byte magic number (0xec2e2d3a2300e317), 4-byte format version (1), 4-byte JSON metadata length, 8-byte raw content length, JSON-encoded BlueprintMetadata, and raw blueprint content bytes.[4] The .gadget archive does NOT include ownerId, gadgetId, or screenshot bytes; imported archives clear any screenshot marker because screenshots are stored separately from the archive content.[4] Blueprint import validation caps JSON metadata at 64 KiB and the stored snapshot payload at 32 MiB to prevent unbounded allocation in the worker from malformed archives.[4] Blueprint import/export streams content bytes directly to and from R2 using pipeTo() rather than buffering the whole archive in memory on the server.[4]
Blueprints are publicly accessible via https://<host>/blueprint/<blueprint-id> — anyone with the link can view metadata without authenticating, but creating a Gadget from a blueprint requires authentication.[4] PublicApi.getBlueprint(id) fetches blueprint metadata by ID without authentication — knowing the ID is sufficient, since a blueprint is treated as public data. Returns null if the blueprint doesn't exist.[6] PublicApi.downloadBlueprint(id) returns a ReadableStream<Uint8Array> of a .gadget archive containing BlueprintMetadata plus the current code snapshot — not the full KV record.[6] Library entries come in two forms: "saved by reference" (via addBlueprintToLibrary(), stores cached metadata but blueprint remains owned by the original publisher — removing it only deletes the personal library entry) and "uploaded" (via importBlueprint() from a .gadget archive — removing one deletes the imported blueprint content as well).[4] Pinning a public blueprint that is not already in the user's library adds it to the library first, then pins it.[4]
A "format" is a blueprint promoted by admins (AdminConfig.formats) so it appears in the composer's + menu. A blueprint can declare BlueprintMetadata.output with a grouping id, noun/plural, and icon from the closed OUTPUT_ICONS set — this is presentation only and grants nothing special.[4] Admin format overrides (FormatCuration.overrides) are applied on every instantiation path — renames reach gadgets the agent builds as well as ones made from the menu.[4] Only gadget-backed published blueprints are featureable by admins; uploaded/imported library blueprints are intentionally excluded from featuring.[4] The featured blueprint state is split: the authoritative featured bit lives in the owning user's blueprints record inside their User DO, while the AdminSettings durable object (a singleton via getByName("")) mirrors the current public metadata and writes a KV snapshot consumed by AuthenticatedApi.listFeaturedBlueprints().[4] Blueprint binding annotations (friendly name, description, suggest value) are configured in the Blueprint modal in the gadget editor header; the annotation is stored on GatekeeperRecord as the blueprintAnnotation field.[4]
packages/workshop-backend/format-blueprints/ holds the deployment's built-in output-format blueprints as committed data (.gadget archive + .json sidecar). scripts/build-format-blueprints.mjs generates src/generated/format-blueprints.ts from this directory; build, types:check, and test all run the generator first.[5] Agent skill documentation in .agents/skills/write-gatekeeper/ (AGENTS.md, SKELETON.md, SKILL.md) designates the Vite+ task build pattern as the canonical reference for authoring new gatekeepers and modifying the build pipeline. Each built-in format blueprint is stored as a plain source directory containing a blueprint.json manifest and human-readable source files (README, client.js, server.js) under a files/ subdirectory — enabling direct source editing and version-control diffing instead of working with opaque .gadget archives. The three built-in format blueprints shipped with the deployment are workspace-docs, workspace-sheets, and workspace-slides.
packages/workshop-backend/src/feature-flags.ts exports resolveUiFeatureFlags, which evaluates all UI feature flags concurrently using Promise.all, passing userId as the evaluation context to the Flagship binding's getBooleanValue. When env.DEV is true, all flags are set to DEV_UI_FEATURE_FLAGS without remote evaluation.[7] If the FLAGS Flagship binding is absent at runtime, resolveUiFeatureFlags logs a warning with event feature-flags.binding.missing and falls back to DEFAULT_UI_FEATURE_FLAGS.[7] If an individual flag evaluation throws, the error is logged with event feature-flags.evaluate.failed and the flag's configured default value is used — evaluation never propagates exceptions.[7]
Frontend components in workshop-frontend that subscribe to account state must dispose pending subscriptions on unmount; failing to do so allows stale callbacks to fire after unmounting, causing state-update-after-unmount bugs and memory leaks. A responsive-layout refactor across packages/workshop-frontend covers AppShell, sidebar, modal surfaces, blueprint pages, chat, gadget editor, settings, share, and onboarding flows — mobile viewports are a first-class target for all new frontend surfaces. The useDialogSelectPortalContainer hook in packages/workshop-frontend provides a dedicated portal container for dialog-based select dropdowns that must escape a stacking context — this is the canonical pattern for modal and popover layering in the frontend. In packages/workshop-frontend, BlueprintLandingPage.tsx and GatekeeperModal.tsx were updated to resolve a z-index/portal-container conflict where the model selector dropdown rendered beneath the gatekeeper modal overlay.
The agent skill documentation in .agents/skills/write-gatekeeper/ (AGENTS.md, SKELETON.md, SKILL.md) has been updated to reflect the Vite+ task build pattern as the canonical reference for new gatekeepers and build pipeline modifications.
Sources
Updated
Cloudflare OS implements a permission graph where collaborators gain access through user edges (direct grants with roles) or share-link edges (redeemed keys); role-based use/build access controls restrict what operations each collaborator can invoke, while lazy revocation re-evaluates graph reachability at every access attempt rather than cascading deletions. Share links are immutable first-class nodes stored as hashed 128-bit keys with soft revocation; multiple copies (aliases) of the same link collapse to a single permission edge per collaborator, and transitively revoke when the link's creator loses access.
A use collaborator may only call getUiBundle(), connectToGadget() (mainline only, no chatId), getMetadata()/subscribeToMetadata() (restricted to id/title/owner/role), and subscribeToPresence(). Every other Overseer method throws Unauthorized.[1] For use collaborators, subscribeToConsoleLogs() and subscribeToActions() return inert subscriptions that never deliver data rather than throwing Unauthorized, because the editor speculatively opens both from top-level hooks before switching to the use-only view.[1] A caller may never grant a role higher than their own effective role. Only the owner and build collaborators can invoke sharing methods; sharing is not in the use allowlist.[1] Share link keys are 128-bit random values; the server stores only the HMAC-SHA-256 hash (using the domain-separation constant SHARE_KEY_HMAC_KEY) and never the raw key, so a database leak does not expose valid share keys.[1] A share link's raw key is shown to the creator only once at mint time and is never stored server-side; re-copying a link mints a new key rather than reproducing the old one.[1] In the shareKeys table, the first key's hash serves as the link ID and carries the link's metadata; subsequent copies store only an alias pointing back to that ID.[1] Share key redemption and gadget opening happen atomically in a single RPC call openGadget(id, shareKey), allowing subsequent calls to be pipelined on the returned Overseer stub without a separate redemption round-trip.[1] A shared gadget does not appear on a collaborator's home page until they first open it, at which point UserDurableObject.recordSharedGadgetOpen() creates a record caching the gadget's title and owner's profile. The lastActive timestamp is updated on each subsequent open.[1] Dismissing a shared gadget from the home page removes the local record from the collaborator's user account but does not revoke access; opening the gadget again via URL causes it to reappear.[1] When a collaborator's access is revoked, the stale record remains on their home page until they try to open it, at which point open() returns a workspace access-denied error. The system does not proactively remove the record from their account.[1] The permission graph has two edge types: User edge (records a specific sharer by profile.id, role, timestamp, optional note) and Share-link edge (records redemption of a specific link by keyId, with the role taken from the link). A collaborator retains access as long as they have at least one valid edge.[1] Edges and share links created before roles were introduced have no role field and are treated as build for backwards compatibility.[1] Share links are first-class nodes in the permission graph. If a link's creator loses access, the link is transitively revoked, removing anyone who gained access solely through it.[1] The owner is the implicit root of the permission graph, is never stored in the collaborators table, and cannot be removed. All permission chains must ultimately trace back to the owner.[1] Revoking a share link sets the link's revoked flag rather than deleting it; all edges referencing the link remain intact to avoid dangling references. Link copies (aliases) are deleted outright since no edge ever references an alias.[1] Because the permission graph is never destructively pruned, revocation is reversible: re-adding a removed collaborator restores their full subtree of downstream grants without any additional steps.[1] packages/workshop-backend/src/sharing.ts implements a lazy revocation model: removing a collaborator or revoking a share link never deletes records or cascades to downstream edges — access is re-evaluated at every open() call by checking graph reachability from the owner.[2] Because records are never deleted in packages/workshop-backend/src/sharing.ts's lazy revocation model, revocation is reversible: re-adding a removed collaborator restores access for them and transitively for everyone they shared with.[2] Raw share keys are never stored server-side in packages/workshop-backend/src/sharing.ts; only their HMAC-SHA-256 hex digest (keyed with a fixed 256-bit personalization constant SHARE_KEY_HMAC_KEY) is persisted as the storage ID.[2] A ShareLinkRecord in packages/workshop-backend/src/sharing.ts uses soft revocation: revoking a link sets revoked: true rather than deleting the record, preserving permission-graph edges and allowing future restoration.[2] A ShareKeyAliasRecord in packages/workshop-backend/src/sharing.ts is an additional key for an existing share link (created when the user copies a link). It carries no metadata; redeeming it resolves to the parent ShareLinkRecord so all copies of a link behave identically.[2] SharingManager.getEffectiveRole() in packages/workshop-backend/src/sharing.ts always returns "build" for the gadget owner without consulting the permission graph.[2] SharingManager.hasAnyShares() in packages/workshop-backend/src/sharing.ts returns true if there is any collaborator reachable from the owner in the permission graph, OR any un-revoked share link — because un-revoked links can still be redeemed by new users.[2] When redeeming a share key alias in packages/workshop-backend/src/sharing.ts, the permission edge is recorded against the alias's parent link ID (not the alias key's own ID), so multiple copies of a link collapse to a single grant per collaborator.[2] In packages/workshop-backend/src/sharing.ts, a CollaboratorRecord stores a denormalized profile snapshot in its profile field to allow display without hitting the user's Durable Object.[2] The SharingStorage interface in packages/workshop-backend/src/sharing.ts requires a byAlias non-unique index on the shareKeys collection, keyed by the alias field (parent link ID), to efficiently enumerate all copies of a given link.[2]
The prohibitAllSharing observation flag was implemented to prevent gadget content from being shared; BigQuery observations were subsequently marked with this flag.[3]
The sharing model was extended with explicit roles — 'build' vs. 'use' — giving workspace owners control over whether a recipient can edit or only run a gadget.[4] Sharing now enforces that recipients have direct access to all underlying gatekeepers observed by the gadget before a share link can be accepted.[5] ObserverConfigModal.tsx implements observer verification logic that handles both legacy and current grant structures; legacy account grants remain in circulation and must be accounted for to avoid verification failures. ObserverConfigModal.test.tsx includes a test covering observer verification against legacy grant structures, ensuring the verification path remains exercised when new grant types are added or the observer config modal is modified. docs/observers.md and docs/sharing.md document observer verification behavior and session-restart semantics when observer scopes are widened. When an observer's granted scope is widened after initial verification, packages/workshop-backend/src/overseer.ts and sharing.ts restart the session so expanded permissions apply immediately without requiring a manual reconnect. Observer verification in packages/workshop-backend/src/observability.ts miscounted verified scopes, producing false-positive grants that incorrectly permitted access; the coverage-bookkeeping defect has been corrected.
Sources
Updated
The Cloudflare OS agent is a code-execution agent that writes and runs snippets to complete tasks; it respects gatekeeper approval gates, recovers from restarts via durable storage, and accesses reachable resources through a bounded AgentCatalog. Agent chat state and bindings are stored server-side in AiChatAgentContext; the system validates binding names uniformly, preserves reasoning across turns with StoredAssistantMessage, and compacts chat history into immutable checkpoints that retain proposed changes only in registry rows. A gatekeeper mediates what resources and actions an agent session can access: it controls approval gates and exposes the AgentCatalog, establishing the trust boundary between the agent and the broader system. The keyboardEvent.ts module in workshop-frontend centralises IME composition state detection; its guards must be used by all keyboard-shortcut handlers and onKeyDown listeners to prevent actions from firing during CJK input method composition.
The Cloudflare OS coding agent is a "Code Mode" agent — it performs tasks by writing and immediately executing code snippets, and can be used for general-purpose tasks beyond building Gadgets.[1] The agent loop honors the ActionDescription.awaitDecision field, pausing execution until the user resolves gatekeeper approval when set.[2] Agents can be resumed after a server restart; in-progress sessions are recovered from durable storage.[3] The agent runtime in workshop-backend/src/agent.ts treats responsiveness as an explicit design constraint when generating or scaffolding gadgets. In packages/workshop-backend/src/overseer.ts, the overseer startup sequence awaits completion of all pending connection requests before allowing the agent to resume execution, preventing requests from being dropped or handled out of order during startup. In packages/workshop-backend/src/overseer.ts, a metrics call records the size of the message-replay payload emitted when a client subscribes to an existing chat session, enabling visibility into historical data volume per subscription for diagnosing reconnect-time latency and planning around Durable Object storage reads. All action-log read paths in workshop-backend/src/overseer.ts and workshop-shared/src/api.ts are bounded with explicit pagination limits, preventing arbitrarily large logs from being loaded into memory. The frontend hooks useActions.ts, useActionHistory.ts, Activity.tsx, ActivityNotifications.tsx, ChatInterface.tsx, and GadgetEditor.tsx consume paginated action-log responses. The resume cursor allows action-stream delivery to survive WebSocket disconnects: on reconnect, the client sends its last-acknowledged position and overseer.ts replays only the log tail from that point forward, avoiding full-log retransmission. The resume cursor is implemented in useActions.ts, useActionHistory.ts, and useWorkspaceOpen.ts; the cursor field is carried in the shared API contract in workshop-shared/src/api.ts; Activity.tsx and ChatInterface.tsx thread it through the render path. otClient.ts in workshop-frontend applies ordering guarantees to prevent concurrent operational-transform operations from overwriting the result of an already-completed chat metadata rebuild with a stale snapshot. packages/workshop-backend/src/overseer.ts logs return values from executeCode calls, surfacing results in runtime logs for debugging agent code-execution flows and observability tooling. The executeCode return-value contract for the gatekeeper scheduler is defined in packages/gatekeeper-scheduler/src/types.d.ts.
The Agent Catalog (AgentCatalog) is bounded discovery metadata a gatekeeper exposes via Gatekeeper.getAgentCatalog() so the agent can see what is reachable through a session — for example, Context Library collection titles — without reading everything upfront; entries are shown to the agent as untrusted data, carry no authority, and are size-capped.[4] The Workshop enforces hard caps on AgentCatalog entries: max 25 entries (AGENT_CATALOG_MAX_ENTRIES), max 256-character IDs (AGENT_CATALOG_MAX_ID_LENGTH), max 100-character titles (AGENT_CATALOG_MAX_TITLE_LENGTH), and max 400-character descriptions (AGENT_CATALOG_MAX_DESCRIPTION_LENGTH).[4]
validateBindingName() in packages/workshop-shared/src/api.ts is the single shared validator for all binding names in the system, applied at gadget binding edges, workspace default binding lists, chat binding maps, spawner env configs, and agent tools.[5] validateBindingName() rejects names that are not ASCII JavaScript identifiers matching /^[A-Za-z_][A-Za-z0-9_]*$/ (the $ character is deliberately excluded), ECMAScript reserved words, prototype, or any name that exists on Object.prototype such as __proto__, constructor, and hasOwnProperty.[5]
AiChatAgentContext in packages/workshop-backend/src/agent.ts stores additional per-chat-thread info needed by the AI agent but not exposed to clients, including the chat ID, an optional spawner config, and a frozen initial binding set (bindings).[6] The bindings map in AiChatAgentContext is frozen after the chat starts; new bindings introduced by changes messages in the chat log are not added to it — the log must be replayed to find the current binding set.[6] alwaysAvailableCapsuleIds in AiChatAgentContext is a legacy field that predates per-chat named bindings; its contents are now folded into bindings and it persists for old-chat migration and as a record of which bindings came from ambient gatekeepers.[6] A ChatBindingEntry in packages/workshop-backend/src/agent.ts resolves a name in the agent's executeCode env to either a workpiece (gadget or gatekeeper, distinguished at env-build time) or the value arguments of an agent callback.[6]
The changes summary message is generated at the end of a turn rather than at the end of each step, reducing noise in the chat history.[7] File uploads are supported in chat messages, and chat attachment types are validated on submission.[8] CompactionCheckpoint in packages/workshop-backend/src/agent.ts is an immutable record of a compacted chat prefix; a chat keeps every checkpoint it has published so history reads and reverts can select the newest checkpoint below any given sequence.[6] Provisional gadget creations and binding additions from before a compaction boundary are deliberately absent from CompactionCheckpoint.proposedChanges because registry rows (GadgetRecord.pending, BindingRecord.pending) already record them; duplicating them in the checkpoint would create a second source of truth.[6] Chat and gadget composer draft state is stored in sessionStorage via the composerDraft.ts module; drafts survive page navigation within a session but are cleared on send or tab close. composerDraft.ts is the canonical reference for draft lifecycle semantics; work on auto-save, multi-window sync, or draft migration must account for its sessionStorage persistence layer. ChatInterface.tsx renders a copy-to-clipboard button inside markdown code blocks; the button's styling is defined in ChatInterface.module.css. Chat composer attachment handling lives in packages/workshop-frontend/src/features/chat/composer/ and is split across three modules: useComposerAttachments.ts, useComposerAttachmentDrop.ts, and prepareChatAttachment.ts. ChatComposer.tsx in packages/workshop-frontend/src/features/chat/composer/ is a self-contained component extracted from ChatInterface.tsx; it owns dedicated sub-modules for drafts, attachments, inline items, submission, and layout. Chat composer draft logic resides in packages/workshop-frontend/src/features/chat/composer/draft/composerDraft.ts and useComposerDraft.ts, extracted from ChatInterface.tsx.
StoredAssistantMessage in packages/workshop-backend/src/agent.ts is stored server-side only and never sent to clients; it preserves reasoning across turns and restarts by keeping thinking blocks with their provider signatures (including encrypted/redacted payloads) and the message's true api/provider/model provenance, so the provider's cross-model conversions apply correctly when the user switches models.[6] The StoredAssistantMessage schema is intentionally subtractive — it copies everything from pi's AssistantMessage and deletes only what is provably redundant — so fields pi adds in the future are retained by default, preventing silent fidelity loss and prompt-caching breakage.[6] StoredToolCall in packages/workshop-backend/src/agent.ts omits the arguments field from pi's ToolCall type because arguments are already stored in the step's AiToolCall record as input and rehydrated at replay time; this avoids duplicating large payloads such as writeFile/executeCode content.[6]
IME composition guards from keyboardEvent.ts are applied in ChatInterface.tsx, Connections.tsx, FileSidebar.tsx, GadgetEditor.tsx, SettingsPage.tsx, ShareModal.tsx, WorkpiecePicker.tsx, CommandPalette.tsx, SidebarGadgetRow.tsx, GadgetList.tsx, ConnectionConfigModal.tsx, AdminFormatsPanel.tsx, and pickerNavigation.ts.
Workshop integration tests run in parallel; per-file setup was moved from global-setup.ts into per-suite lifecycle hooks, with corresponding updates to vitest.config.ts and workshop-backend/vite.config.ts. Agent integration tests in packages/integration-tests/ cover agent behaviour across workshop-agent.test.ts, workshop-agent-actions.test.ts, and mock-model.test.ts; a gatekeeper-test fixture in fixtures/gatekeeper-test/ provides an isolated Worker for test harness wiring. The packages/integration-tests/ suite includes mock-model infrastructure — src/mock-model.ts and src/network-interceptor.ts — that intercepts outbound model calls and replays scripted responses, enabling deterministic agent testing without live provider access. otClient.test.ts reproduces the stale chat metadata rebuild race scenario and provides regression coverage for the ordering guarantees in otClient.ts. packages/workshop-evals is the canonical location for regression coverage of agent behaviour changes; engineers modifying agent decision logic or action schemas should add or update an eval scenario there rather than relying solely on unit tests. AgentSession in packages/integration-tests/src/agent-session.ts is a reusable abstraction that wraps gatekeeper-test fixtures for use in eval scenarios.
Sources
README.mdgithub.com/cloudflare/cloudflare-os/commit/c5bacc4github.com/cloudflare/cloudflare-os/commit/9a0f4f4packages/workshop-shared/src/gatekeeper.tspackages/workshop-shared/src/api.tspackages/workshop-backend/src/agent.tsgithub.com/cloudflare/cloudflare-os/commit/78fd806github.com/cloudflare/cloudflare-os/commit/2a778ebUpdated
Gatekeepers wrap external services with a Cap'n Web API, enforce OAuth and resource-scoped access, and provide asynchronous human-in-the-loop approval for side-effecting operations—each implemented as a separate Cloudflare Worker communicating with the Workshop via RPC. A Gatekeeper forms a three-tier hierarchy (Vendor, User, Session), uses capability-based security to avoid self-assertion of ambience, and publishes interfaces per logical resource type rather than generic god-objects accepting resource IDs. The break-glass escape hatch in .github/workflows/bonk-pr.yml persists across job re-runs; re-triggering CI does not revoke a previously granted break-glass approval. The workflow token in .github/workflows/bonk-pr.yml has write access to PR labels, enabling the break-glass label-manipulation step to reliably mark a PR as break-glassed.
Gatekeepers wrap external services with a Cap'n Web API, handle OAuth authorization, enforce narrow resource-scoped access, log every action, and offer human-in-the-loop approval for side-effecting operations.[1] Each Gatekeeper is implemented as a separate Cloudflare Worker and communicates with the Workshop over JavaScript RPC via service bindings.[1][2] The canonical interfaces and detailed JSDoc for Gatekeepers live in packages/workshop-shared/src/gatekeeper.ts.[3]
A Gatekeeper is structured as a three-tier hierarchy: GatekeeperVendor (a WorkerEntrypoint, one per service), GatekeeperUser (a WorkerEntrypoint with ctx.props, per human user), and Gatekeeper<Session> (a Durable Object facet of the Overseer, per-resource per-Gadget).[3] Gatekeeper service bindings (GATEKEEPER_*) are intentionally NOT declared in env.d.ts; the backend discovers them generically by scanning env for the GATEKEEPER_ prefix via buildGatekeeperVendorMap and never references a specific gatekeeper binding by name.[4] Gatekeeper<Session> is implemented as a Durable Object facet of the Overseer, giving each per-resource, per-Gadget session single-threaded, globally-unique state that persists across requests.
Gatekeepers implement asynchronous human-in-the-loop approval: when an agent action requires approval, the Gatekeeper simulates the outcome and lets the agent continue queuing more actions; the user approves or rejects in bulk later, avoiding blocking the agent mid-task.[1] Every action with an externally-visible side effect must be submitted via submitAction() and must not be performed until applyAction() is called; read-only observations must call authorizeObservation() before returning data.[3] Submitted but unapplied actions must be simulated as if already applied: callers reading back data should observe the state as if pending actions had been applied, enabling batch approval without blocking agents.[3] A pre-approval flow lets users configure per-connection auto-approvals by action tag; the pre-approval dialog auto-opens when a new resource is connected.[5]
Capability-based security rule: a resource becomes "ambient" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience.[6] The Gatekeeper API should be capability-based and object-oriented — one interface per logical resource type, not a god-object where resource IDs are passed to every method. For example, a Google Docs gatekeeper provides an interface to a specific document rather than accepting a doc ID on every call.[3] SupportedResource.grantable marks a resource type as independently grantable: users can enable or disable it at account-connection time, and the Workshop requests only the underlying authorization (e.g., OAuth scopes) needed for enabled resource types. If omitted or false, the resource type is not separately grantable.[2] AccountDescription.grantedResourceUrlPatterns lists the URL patterns of grantable resource types currently enabled on an account. If omitted, the account is treated as having every resource granted (for legacy accounts or gatekeepers with no grantable resource types).[2] ResourceDescription.suggestedBindingName should be based on the binding's type, not the specific resource title, since the agent can see the binding name and the user may not intend to reveal the resource title to the agent.[2]
A Gatekeeper vendor's providesAuth flag (VendorDescription.providesAuth) signals that its connect flow yields a provider-verified email, allowing the Workshop to offer it as a login method, subject to the AUTH_GATEKEEPERS allowlist. Defaults to false.[2] AUTH_GATEKEEPERS is a comma-separated allowlist of gatekeeper vendor IDs permitted to drive sign-in; a listed gatekeeper must also advertise providesAuth. An empty value means no gatekeeper sign-in (password / CF Access only).[4] A gatekeeper that declares VendorDescription.autoProvisionsAccount can mint a connected account with no OAuth flow via GatekeeperVendor.createAccount(). The deployment admin picks a per-vendor mode — disabled / optional / enabled (default optional) — in the admin Gatekeepers panel.[6] When a gatekeeper's auto-provisioning mode is enabled, the account is force-provisioned for every user and hidden from the Connectors list. When optional, each user opts in from the Connectors page. When disabled, no one is offered it and existing accounts go dormant.[6] An auto-provisioned gatekeeper account singleton is folded into each chat's env as a named chat binding (named by suggestedBindingName); the agent reads it in executeCode via getSession/getAgentCatalog, with each read recorded as an observation. It is not bound to any gadget by default.[6] Ambient gatekeepers — gatekeeper accounts that require no per-user auth — were introduced along with a management UI.[7]
AccountDescription.singleton indicates that this account provides an agent singleton — a gatekeeper the Workshop installs into the owner's gadgets and auto-provides as an unnamed capsule. The tsType field names the session's TypeScript interface.[2] ResourceDescription.hookTsType signals that the resource supports client-side event subscriptions via a hook WorkerEntrypoint. The named type must be one of the exports from getTypeScriptTypes().[2] Hooks were redesigned to be based on persistent stubs rather than the previous ephemeral mechanism, and immutable hook target identifiers are now passed to the enable() method so hooks can identify their target regardless of mutable configuration.[8][9] Hook delivery is blocked for disabled gatekeepers, serving as a backstop so hooks are never dispatched to a gatekeeper the user has turned off.[10]
PublicApi in packages/workshop-shared/src/api.ts is the unauthenticated RPC interface exposed to the internet; it provides getServerConfig, startGatekeeperLogin, authenticate, authenticateFromCfAccess, login, createAccount, getBlueprint, and downloadBlueprint.[11] PublicApi.getServerConfig() returns deployment-level configuration (auth mode, available sign-in vendors, Cloudflare limits flow status) that the client needs at boot; it contains no secrets.[11] PublicApi.startGatekeeperLogin(vendorId) begins an OAuth sign-in flow; it returns a url for the OAuth popup and an attempt stub whose wait() resolves to a session token once the popup completes. Disposing attempt cancels the server-side wait.[11] LoginAttempt.wait() resolves with a session token once the gatekeeper OAuth popup completes, or rejects if the attempt fails or is abandoned. Disposing the stub abandons the attempt server-side.[11] PublicApi.authenticateFromCfAccess() authenticates via a Cloudflare Access session rather than an explicit token; it expects the server to be behind Cloudflare Access and the client to have already passed Access authentication before loading the app.[11] PublicApi.login() and PublicApi.createAccount() may be disabled when the server uses SSO for authentication.[11] PendingLogin is a short-lived Durable Object that bridges each gatekeeper OAuth login callback back to the waiting browser session, enabling sign-in via authentication gatekeepers.[12]
ConnectedAccountsSubscriber.ready() is called after all initial add() calls for known accounts, signalling that the subscription has delivered its current snapshot.[11] ConnectedAccountsFilter extends GatekeeperVendorFilter and adds the includeForcedAutoProvisionedAccounts flag, which ensures and includes auto-provisioned accounts forced by deployment policy.[11] Gatekeeper catalog failures are isolated so that one unavailable gatekeeper does not prevent the rest of the catalog from loading.[13]
The Cursor<T> interface in gatekeeper.ts is an RPC pagination object. Callers call next() repeatedly on the same cursor to fetch subsequent batches; next() returns null when exhausted. The cursor must be disposed when finished.[2] Gatekeepers should use the exported boundAgentCatalog(entries, request) helper to produce a well-formed AgentCatalog rather than hand-rolling the limits. It clamps entry count to min(request.limit, AGENT_CATALOG_MAX_ENTRIES), truncates each field to its cap, and sets truncated: true when entries were dropped.[2]
The observer tracking system replaces the blunt prohibitAllSharing flag (in packages/workshop-shared/src/gatekeeper.ts) with a per-user, gatekeeper-mediated check that allows sharing with users who have equivalent data access, rather than blocking sharing entirely.[14] Observer account vendor is now validated before verification to prevent a security issue where a mismatched vendor could bypass access checks.[15] When re-verification fails, packages/workshop-backend/src/overseer.ts retains pre-existing observer registrations rather than discarding them, ensuring observers continue receiving updates across transient auth failures.
packages/gatekeeper-context (the Context Library) owns its state in three Durable Objects — ContextCollectionDurableObject, UserLibraryDurableObject, and LibraryRegistryDurableObject — plus a KV namespace, all namespaced by sharingDomain so multiple workshops sharing one gatekeeper instance stay isolated.[6] Context Library collections have two visibility levels: private (owned and readable/writable only by one account) and public (created/edited only by deployment admins, readable by all users and auto-enabled for all).[6] packages/gatekeeper-scheduler (Scheduled Tasks) is an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks. A single ScheduleDriver Durable Object stores enabled schedules and delivers them via a shared alarm.[6] Agent skill tool invocations carry the name of the source document the skill came from; this value is populated in agent-skill.ts and surfaced via context-types.ts. Context Library collections are included in the agent catalog; previously they were silently dropped during catalog construction. Context file content in packages/gatekeeper-context is stored internally as Uint8Array rather than a JS string; all call sites for artifact sync, context retrieval, and observer implementations that feed gatekeeper-context must encode and decode accordingly to avoid corruption of binary file formats.
When writing a new gatekeeper, the developer must STOP after designing types.d.ts and present the API for operator review before implementing anything else; the API is considered the most important and hardest-to-change part.[3] Gatekeeper types.d.ts is the agent's sole API documentation and must only describe what the agent needs to use each method; it must NOT mention submitAction/applyAction, approvals, caching, DO storage, OAuth, or any other implementation detail.[3] Phase 1 of gatekeeper implementation focuses only on responsibilities 1–3 (auth, API design, resource granting); the observer methods getVerifier/addObserver/removeObserver must be included as minimal stubs because the code won't type-check without them.[3] The Workshop calls GatekeeperUser.startResourceConfigurator(resourceUrlPattern) to open the resource selection UI; the method must return iframeHtml and a ui RPC object for the iframe to call back.[3] The optional @gadgets/configurator-ui package provides base form components consistent with the Gadget Workshop and a build script (scripts/build-gatekeeper-configurator.mjs) that turns src/configurator/*-ui.tsx into iframeHtml, producing output in src/generated/*.txt.[3] If a configurator's value keys already match the URL pattern's named groups, pre-filling from a known resource URL works automatically with no code; otherwise, implement initialValuesFromResourceUrl({ resourceUrl, resourceUrlPattern, ui }) to map a concrete URL back to form values.[3] The Workshop frontend propagates the active accent theme into sandboxed gatekeeper app iframes via SandboxedGatekeeperApp.tsx; the shared theme shape is defined in workshop-shared/src/theme.ts, and breaking changes there affect all gatekeeper apps simultaneously. All exported symbols in the monorepo must carry JSDoc annotations; CI linting enforces this rule and will reject commits that omit it. New gatekeeper authors must annotate every exported symbol accordingly. packages/gatekeeper-kit is a shared primitive library providing reusable leaf modules for gatekeeper implementations: actions.ts, auth-retry.ts, cache.ts, connect-handshake.ts, connect-nonce.ts, connect-pages.ts, credentials.ts, endpoint.ts, http-errors.ts, observers.ts, response-body.ts, serial-queue.ts, and single-flight.ts. Gatekeeper developers must use packages/gatekeeper-kit as the authoritative shared primitive layer and must not re-implement patterns already provided by its leaf modules. packages/gatekeeper-kit modules include test coverage under __tests__/, with workerd-environment tests for runtime-sensitive paths including connect handshake, nonce validation, credential expiry, KV-backed serial queue, and cursors. packages/gatekeeper-kit exports src/preview-oauth.ts, a reusable class that wires up OAuth credentials for preview deployments; gatekeeper-google/oauth.ts uses it. packages/gatekeeper-kit exports src/action-files.ts, a helper for persisting pending-action state to durable files; gatekeeper-google/gmail-state.ts and gatekeeper-confluence/confluence-actions.ts use this abstraction.
Nonce comparison in SKELETON.md uses crypto.subtle.timingSafeEqual wrapped in constantTimeEqual() to prevent timing-based nonce disclosure.[16] The GatekeeperVendor.connectAccount() skeleton ignores options.resourceUrlPatterns and stores an empty list; gatekeepers with grantable resources must follow the pattern from gatekeeper-google instead.[16] The gatekeeper skeleton's getBaseUrl() defaults to http://localhost:8787/gatekeeper/<name> when the BASE_URL env var is absent, reflecting the renamed env var (previously ORIGIN).[16] After the user completes authorization and completeConnection() calls callback.complete(), if the callback throws, credentials are immediately deleted from DO storage — preventing a connected account from existing in a broken state.[16] The MyUserImpl.getVerifier() method mints a MyVerifier WorkerEntrypoint with ctx.props baked in; the Overseer hands it only to gatekeepers of the same vendor, so MyGatekeeperImpl.addObserver may trust it without re-validating the vendor.[16]
Sources
README.mdpackages/workshop-shared/src/gatekeeper.ts.agents/skills/write-gatekeeper/SKILL.mdpackages/workshop-backend/src/env.d.tsgithub.com/cloudflare/cloudflare-os/commit/28a5384AGENTS.mdgithub.com/cloudflare/cloudflare-os/commit/1609642github.com/cloudflare/cloudflare-os/commit/d2bab4fgithub.com/cloudflare/cloudflare-os/commit/d8a70afgithub.com/cloudflare/cloudflare-os/commit/999117epackages/workshop-shared/src/api.tspackages/workshop-backend/wrangler.jsoncgithub.com/cloudflare/cloudflare-os/commit/3814217docs/observers.mdgithub.com/cloudflare/cloudflare-os/commit/178b703.agents/skills/write-gatekeeper/SKELETON.mdUpdated
A gatekeeper decides whether an MCP tool runs immediately as an observation (if read-only and compliant) or enters an approval queue (if a write); the trust boundary at tools.ts enforces this via readOnlyHint, destructiveHint, and idempotentHint annotations, with only vetted endpoints auto-approving writes that meet all conditions. The session layer (McpSessionBase) stages and queues actions through an approval service while classifying tools from annotations, and generated methods delegate to callTool — but tool names are validated, normalized for JavaScript identifiers, and collision-checked to reach the RPC stub securely. In slack-api.ts and slack.ts, account identity is qualified by the Slack workspace, preventing collisions when a user is connected to multiple workspaces or when multiple workspaces share one deployment. The workspace-qualified account identity shape in slack-api.ts and slack.ts is a breaking change: stored identifiers in KV, Durable Objects, or permission checks may require verification or migration.
packages/mcp-shared is a shared library (not a Worker) used by both gatekeeper-mcp and gatekeeper-mcp-portal. It holds the MCP client, OAuth chain, account DO base, resource-URL scope grammar, and queued-action store.[1] packages/mcp-shared/src/tools.ts is the exclusive trust boundary between MCP server self-description and Gadget permissions — nothing outside it reads a tool's annotations.[2] In gatekeeper-mcp-portal, connectors flagged native: true are filtered out before the connector list is rendered, making native connectors invisible to portal users. The filtering logic lives in src/config.ts and src/portal.ts, with related changes in src/configurator/server-configurator-ui.tsx. scripts/build-gatekeeper-configurator.ts builds the gatekeeper configurator artefact, including scroll-state concerns for picker components. Its test suite lives in build-gatekeeper-configurator.test.ts.
McpToolMode classifies each MCP tool as either "read" (returns data immediately; every call recorded as an observation) or "action" (queued for approval before running).[3] The ServerTrust type has two values: "vetted" (an administrator asserted the endpoint's annotations are reliable, enabling auto-approval) and "byo" (a user typed the URL in, so no annotation can auto-apply a write).[2] readOnlyHint is honoured on both vetted and byo tiers — a known tradeoff: a mislabelled read-only tool runs with no approval, whereas an unlabelled tool is queued. Auto-applying a write additionally requires a vetted endpoint.[2] isDeclaredReadOnly requires tool.annotations?.readOnlyHint === true (strict equality), matching the MCP spec's default of false — an absent annotation is never treated as read-only.[2] Tool classification fails closed: a tool without readOnlyHint is classified as an action (write), not a read.[4] A tool is autoApprovable only when all four conditions hold: it is not read-only, the server trust is "vetted", destructiveHint === false, and idempotentHint === true.[2] MAX_TOOLS_PER_SERVER is set to 200 as an upper bound on tools taken from one endpoint, to keep generated types and catalogs bounded.[2] catalogRevision produces a 16-hex-character SHA-256 fingerprint of a tool catalog covering each tool's name plus its policy-relevant annotations (readOnlyHint, destructiveHint, idempotentHint). Descriptions are excluded so copy edits do not trigger the signal.[2] actionKindFor produces an ActionKind by percent-encoding both the scope tag and tool name and joining them with :, ensuring two connectors using the same binding ID cannot share pre-approvals.[2] The portal layer implements pagination primitives to page through an MCP server's tool list incrementally, avoiding loading the entire catalog into memory at once. Callers must not assume all tools are available immediately after connection.
describeCall renders an approval prompt in Markdown, sanitizing server-supplied text via quoteUntrusted (strips headings, defuses fences, block-quotes the result, caps at 600 chars for descriptions and 4000 chars for arguments) to prevent a tool description from forging content in the prompt's own voice.[2] defuseFences replaces any run of 3+ backticks with ''' to prevent a server-supplied value from closing a Markdown code fence inside an approval prompt.[2] quoteUntrusted strips heading and blockquote markers repeatedly (because one pass turns ## into #, still a heading), then caps and block-quotes the result. MAX_DESCRIPTION is 600 characters and MAX_ARGUMENTS is 4000 characters.[2] codeSpan removes all backticks from server-chosen text before placing it in a Markdown code span, preventing a tool name containing a backtick from closing the span and injecting arbitrary prompt prose.[2]
McpSessionBase in packages/mcp-shared/src/session.ts is the Gadget-facing RPC class for one MCP session over one binding. It exposes listTools(), callTool(), and getActionResult(); per-tool named methods are installed by a per-grant subclass in session-methods.ts.[5] McpSessionHost is an intentionally narrow interface: it is handed to a Gadget, so anything reachable from it is one followPath away from untrusted code.[5] McpSessionBase is designed to be subclassed by connectors, which apply @validateRpc() in the subclass so the decorator is visible in the file that hands the session to a Gadget.[5] McpSessionBase.listTools() fetches the classified tool list from the host and calls queue.authorizeObservation() before returning — listing tools is itself an observation gated by the gatekeeper approval flow.[5] For "read" mode tools, McpSessionBase.callTool() executes the call immediately and then calls queue.authorizeObservation() before returning the result — authorization is recorded after the call, not before.[5] For "action" mode tools, McpSessionBase.callTool() stages the action, submits it to the approval queue via queue.submitAction(), and immediately returns { status: "pending", actionId, message }. The caller must poll getActionResult() for the outcome.[5] If queue.submitAction() throws in McpSessionBase.callTool(), the staged action is immediately discarded via host.discardStagedAction() to prevent orphaned pending records.[5] McpSessionBase.callTool() throws if the requested tool name is not found in the current tool list. The error message is context-sensitive: on a scoped binding it lists only the granted tools; on a whole-endpoint binding it reports the tool as missing from the server.[5] For an "applied" action, getActionResult() calls queue.authorizeObservation() at the moment the result is handed to the Gadget — not when the action was applied — because the result was produced while the Gadget was not watching.[5] When getActionResult() is called for an "applied" action whose result is missing, McpSessionBase returns a synthetic { status: "ok", content: [], text: "", isError: false } fallback rather than throwing.[5] McpSessionBase holds a private RpcStub<ApprovalQueue> and disposes it via [Symbol.dispose](), ensuring the approval queue stub is cleaned up when the session is disposed.[5]
StoredAction.state transitions through "pending" → "applying" → "applied" | "rejected" | "failed". The "applying" state exists specifically to prevent a second applyAction from finding the record still "pending" and calling the tool a second time.[5] StoredAction.retryable marks whether a "failed" action can be retried. Absent means retryable (backward-compatible default); false means the request may already have taken effect and a retry could duplicate a non-undoable MCP write.[5]
McpContent in packages/mcp-shared/src/base-types.ts is a discriminated union covering five content block types: text, image, audio, resource_link, and resource.[3] toCallResult flattens McpToolCallResult content: it concatenates all "text" blocks (joined by newlines) into a text field and also passes through structuredContent and isError unchanged.[2]
In packages/mcp-shared/src/session-methods.ts, session tool methods are placed on a prototype rather than as own properties because Cap'n Web and Workers RPC both refuse own properties on an RpcTarget.[6] Each generated session method is a one-line delegate to callTool, keeping the scope check, approval queue, and observation record in one place while delegates inherit the @validateRpc() checking applied there.[6] toMethodName converts MCP wire tool names to camelCase JavaScript method names (e.g., list_issues → listIssues). It returns null when the result cannot start an identifier (a leading digit), keeping those tools reachable only through callTool.[6] When two tools produce the same generated method name (e.g., list_issues and listIssues both map to listIssues), BOTH are dropped from the generated methods; they remain reachable through callTool, which keeps the names distinct.[6] RESERVED_METHOD_NAMES is the set of names that must never be generated as tool methods. The first group (then, catch, finally, dup, onRpcBroken, constructor, toString, valueOf, hasOwnProperty, __proto__, map) is intercepted or hijacked by the RPC stub; the second group (callTool, getActionResult, listTools) is the session's own surface.[6] installToolMethods returns an anonymous subclass of the provided Base class so the mutated prototype cannot be one that anything else shares. If the tool list cannot be fetched, the base class is returned directly and still works.[6] Write tools (those without readOnlyHint) are installed as callable session methods and reach callTool normally — the approval queue, not the method type system, is the gate that holds writes for user approval.[4] The e2e test in packages/mcp-shared/__tests__/session-methods-e2e.test.ts verifies that the generated .d.ts type and the installToolMethods-produced object agree: every method the type promises is installed and routes to the correct tool name via callTool, and nothing callable was omitted from the type.[4] The e2e test asserts that list_issues and listIssues both collide and are excluded from generated methods, that then/map are excluded because they are hijacked by the RPC stub, and that 2fa is excluded because it is not a valid JavaScript identifier.[4]
A ToolScope in packages/mcp-shared/src/scope.ts encodes a binding's grant over an MCP endpoint using two optional fields: serverId (restricts to one portal upstream server) and tools (restricts to exact tool wire names). Absence of both fields means the whole endpoint is granted.[7] Tool names inside a ToolScope are stored as exact wire names (not relative to a server), so no code has to guess at the portal's separator and grants issued before portals existed still resolve correctly.[7] The URL fragment of a resource URL encodes the scope: no fragment = whole endpoint; #server=<id> = all tools of one portal server; #tool=a&tool=b = exact named tools; both keys together are enforced independently.[7] parseToolScope() is intentionally fail-closed: an obsolete tools key (plural) produces an empty restriction rather than granting the whole endpoint, and a tool key that yields nothing usable is kept as an empty restriction. Only the complete absence of both tool and tools keys grants the whole endpoint.[7] formatToolScope() explicitly emits an empty tool key when scope.tools is an empty array, preserving the fail-closed semantics after a round-trip through parseToolScope().[7] sameEndpoint() compares the full URL (path and query included, not just origin) to determine if two resource URLs name the same MCP endpoint. It returns false for anything unparseable.[7] endpointTag() produces a URL-encoded, fragment-stripped endpoint identity for use in persistent approval policy namespacing. Two endpoints share a tag if and only if sameEndpoint() considers them equal; the value is encodeURIComponent-encoded so path or query characters cannot act as separators.[7] scopeAllows() always blocks portal-native tools (those matching isPortalNativeTool) regardless of scope, but requires the isPortal boolean argument to be set — a plain server with a tool coincidentally named portal_something is still permitted.[7] requireCompleteCatalogForToolSelection() throws if the catalog is truncated, preventing individual-tool selection when the server's catalog is too large to enumerate completely. Callers must grant all tools instead.[7] validateToolScopeAgainstCatalog() throws if the catalog is truncated and the scope names specific tools, because a truncated catalog cannot reliably validate individual tool names.[7] For portal server scopes, validateToolScopeAgainstCatalog() throws if no tool in the catalog belongs to the requested server and the server is not in reportedServers; a server present in reportedServers even with no current tools is accepted and returned so callers can persist its display name.[7]
validateCustomEndpoint in packages/mcp-shared/src/endpoint.ts requires HTTPS and rejects private/link-local/metadata hosts for user-supplied MCP endpoint URLs. The MCP_ALLOW_INSECURE env flag (checked via fetchOptions(env).allowInsecure) disables both checks for local development.[8] validateCustomEndpoint strips URL credentials and the fragment (url.hash = "") before returning the canonical URL, because credentials in the URL would end up in logs and approval prompts.[8] The SSRF blocklist (BLOCKED_HOST_PATTERNS) is deliberately NOT the security boundary; enforcement is the global_fetch_strictly_public wrangler compat flag (set in each connector's wrangler.jsonc), which makes workerd reject reserved IP ranges after DNS resolution on every request and redirect hop. The blocklist adds only a legible refusal at connect time.[8] isBlockedHost is exported because a user-supplied endpoint URL is not the only URL its server can cause the system to fetch: OAuth discovery follows a WWW-Authenticate header and then an issuer chosen by the far side, so redirect targets must also be checked.[8] normalizeHost converts IPv4-mapped IPv6 addresses ([::ffff:7f00:1]), hex/octal/decimal bare-integer IPv4 addresses (e.g., 0x7f000001, 2130706433), and normal dotted-quad to a single canonical dotted-quad form so blocklist patterns cannot be bypassed by alternate address spellings.[8]
OAuthTokens in packages/mcp-shared/src/oauth.ts is defined as StoredOAuthTokens & { expiresAt?: number }, extending the SDK type with an absolute expiry timestamp used on the account hot path.[9] MCP OAuth must always use sdkFetch(...) to ensure every request and redirect retains endpoint and SSRF checks.[1] isCredentialRejection() returns true only for authorization-server verdicts (invalid_grant, invalid_client, unauthorized_client, invalid_scope); transport failures are intentionally left retryable.[9] safeOAuthError() scrubs submitted credentials (including both plain and URL-encoded Basic auth header values) from OAuth error messages before logging or displaying them.[9] revokeToken() sends the token_type_hint, client_id, and token as application/x-www-form-urlencoded POST body; it does not include client_secret, reflecting that the account registers as a public client with token_endpoint_auth_method of "none".[9]
In mcp-shared, the trust boundary is tools.ts: a tool declared readOnlyHint: true by the server runs as an observation; everything else is queued for approval. Auto-applying a write additionally requires a vetted endpoint, which only the portal gatekeeper can produce via MCP_PORTAL_TRUST_ANNOTATIONS.[1] The Gatekeeper skeleton's HTTP handler routes auth initiation via a two-segment path /<doId>/<nonce>; the first segment is 64 hex characters (the DO ID string) and the second is NONCE_BYTES * 2 hex characters (64 hex characters for a 32-byte nonce).[10]
The "nothing escaped to the internet" assertion must be placed in afterAll, not afterEach. With it.concurrent, an afterEach fires while sibling tests are still running and could discard an escape event a sibling was about to be blamed for.[11] When a consumer repo installs both its own workspace and the public/ submodule separately, capnweb resolves to two different copies, causing TypeError: Cannot serialize value: [object RpcStub] when stub instances cross the boundary. This manifests in CI but not locally when a single pnpm install deduplicates both.[11] To avoid the dual-capnweb serialization trap, always mint callback stubs with stubFor() from rpc-client, never with an imported RpcStub value. Importing RpcStub as a TypeScript type is fine. This is enforced by .oxlintrc.json restricting capnweb value imports within the integration-tests package to rpc-client.ts.[11] Worker entry modules may only export classes and the default handler. Exporting a plain string constant from the fixture entry module causes workerd to throw Incorrect type for map entry '...': the provided value is not of type 'function or ExportedHandler'. Type-only exports are safe because they erase.[11]
The gatekeeper-cloudflare package contains an observability subsystem spanning observability-api.ts, observability-discovery.ts, observability-parse.ts, observability-session.ts, and observability.ts; together they query Cloudflare Workers logs and metrics and expose that telemetry through the gatekeeper resource catalog. observability-discovery.ts in gatekeeper-cloudflare is the designated extension point for surfacing per-worker telemetry through the gatekeeper interface.
packages/gatekeeper-google is a gatekeeper package for Google Drive integration. Its core modules are: drive-api.ts (Drive REST API client), observers.ts (change-watching), resources.ts (resource model), cursor.ts (pagination/cursor handling), gmail-validate.ts (Gmail address validation), and google-configurators.ts (configurator registry). packages/gatekeeper-google includes a Gmail configurator UI at configurator/gmail-configurator-ui.tsx and a test suite in __tests__/ covering the Drive API client, markdown converter, cursor logic, observer behaviour, and Gmail address validation. packages/gatekeeper-google is wired into the workspace via pnpm-lock.yaml; gatekeeper registration into the main workshop is not present in the package. The gatekeeper-google OAuth flow accepts configurable redirect origins, enabling preview and staging deployments to complete the OAuth round-trip with Google; previously only production origins were supported. Test coverage for this path lives in packages/gatekeeper-google/__tests__/oauth.test.ts. packages/gatekeeper-google supports Google Drive metadata search, allowing callers to filter on Drive-native metadata fields in addition to full-text search. The feature spans drive-api.ts, drive-session.ts, drive-observers.ts, and drive-types.d.ts. The configurator type definitions drive-account-configurator-types.d.ts, drive-file-configurator-types.d.ts, and shared-drive-configurator-types.d.ts in packages/gatekeeper-google, along with their corresponding UI components, expose metadata search query parameters to configurator consumers. packages/gatekeeper-google includes src/auth-retry.ts for resilience during token-refresh failures; test coverage lives in drive-session.test.ts, native-sessions.test.ts, and types-parity.test.ts. packages/gatekeeper-google includes a markdown converter at src/markdown-converter.ts and type declaration files docs-types.d.ts, docs-read-types.d.ts, and drive-types.d.ts formalising Drive and Docs API response shapes. packages/gatekeeper-google provides a native Google Drive document session layer in src/drive-session.ts and a Docs API client in src/docs-api.ts, enabling agents to open, read, and interact with Google Docs and Drive files through a session abstraction. packages/gatekeeper-google includes workerd integration tests at workerd/gmail-actions.test.ts and workerd/gmail-state.test.ts confirming Gmail runtime behaviour inside the Workers runtime. packages/gatekeeper-google adds OAuth scope negotiation via src/gmail-scope.ts and per-session state tracking via src/gmail-state.ts to reduce privilege-scope mismatch bugs. packages/gatekeeper-google supports cursor-based pagination for Gmail via src/cursor.ts, enabling incremental navigation of large mailboxes. A silent OAuth token-refresh failure in packages/gatekeeper-google/src/google-configurators.ts could leave the gatekeeper holding expired credentials without surfacing a clear error. The silent token-refresh bug in google-configurators.ts is covered by a workerd-environment test in packages/gatekeeper-google/__tests__/workerd/configurators.test.ts; vitest.worker.config.ts was updated to include that test. In packages/gatekeeper-google/src/resources.ts, agent-facing resource descriptions instruct agents to batch Google Calendar free/busy queries rather than issuing one per calendar, reducing API call volume and rate-limit collisions. In src/configurator/calendar-configurator-ui.tsx and src/google-configurators.ts, the Google Calendar "primary" alias is resolved to the concrete calendar ID at configuration time, preventing mismatches when the real calendar ID is returned by other API responses.
packages/gatekeeper-github implements a read-write Git workflow: agents pull file trees from GitHub, edit them in-session, and push commits back. The implementation spans src/git-transport.ts, src/git-commits.ts, src/git-diff.ts, and src/github-api.ts. packages/gatekeeper-github includes storage-schema.md documenting the on-disk layout for Git state and file trees, and workerd-scoped integration tests under __tests__/workerd/ covering push, pull simulation, and session-level Git state.
Sources
AGENTS.mdpackages/mcp-shared/src/tools.tspackages/mcp-shared/src/base-types.tspackages/mcp-shared/__tests__/session-methods-e2e.test.tspackages/mcp-shared/src/session.tspackages/mcp-shared/src/session-methods.tspackages/mcp-shared/src/scope.tspackages/mcp-shared/src/endpoint.tspackages/mcp-shared/src/oauth.ts.agents/skills/write-gatekeeper/SKELETON.mddocs/integration-testing.mdUpdated
Routing is handled by the packages/router origin worker, which serves frontend assets, dispatches API and blueprint requests to the workshop backend, and delegates /gatekeeper/<name>/* paths to dynamically discovered service bindings. Admin operations flow through a single enforced chokepoint in getGatekeeperClassFor() before capability minting, with admin status checked once when AdminApi is obtained rather than per-method. Three pieces form the admin system: the AdminSettings Durable Object as sole writer of AdminConfig, the ADMINS binding (usernames) on the backend worker, and the #isAdmin() check at capability-acquisition time that gates access to the adminIsBlueprintFeatured() and adminSetBlueprintFeatured() RPCs. In the capability model, a capability is an unforgeable object granting access to a specific operation. Cloudflare OS enforces access control at mint time via getGatekeeperClassFor() rather than at each call site, preventing downstream bypass.
packages/router is the public origin worker of a deployed Cloudflare OS instance: it serves workshop-frontend assets and routes /api/* and /blueprint-screenshot/* to the workshop backend, and /gatekeeper/<name>/* to whichever gatekeepers are bound — discovered by scanning GATEKEEPER_* service bindings, so installing a gatekeeper is purely a binding change.[1] endpointOfResourceUrl() in packages/mcp-shared/src/scope.ts strips the fragment from a resource URL to recover the bare endpoint, passing it through URL so two spellings of the same endpoint cannot be treated as different endpoints.[2]
The AdminSettings Durable Object is the sole writer of AdminConfig; it mirrors the config to the .adminConfig KV key so hot-path code reads it with one cheap KV get via readAdminConfig(env).[1] user.ts:getGatekeeperClassFor() is the single enforcement chokepoint where disabled gatekeepers and resources are blocked before a capability is minted; gadget and agent code cannot bypass it.[1] Admin operations are exposed as an AdminApi capability obtained via AuthenticatedApi.getAdminApi(), which returns null for non-admins; the #isAdmin() check happens once when the capability is minted, so individual methods do not re-check.[1] Admin usernames are configured via the ADMINS binding (an array of usernames) on the backend worker; admins gain access to AuthenticatedApi.adminIsBlueprintFeatured() and AuthenticatedApi.adminSetBlueprintFeatured() RPCs.[3]
The GitHub Gatekeeper identifies itself throughout the system using the VENDOR_ID constant "github".[4] Three environment variables are read from Cloudflare.Env: BASE_URL (the mount URL), CLIENT_ID, and CLIENT_SECRET (GitHub OAuth app credentials). When BASE_URL is absent it defaults to "http://localhost:8787/gatekeeper/github", matching local wrangler dev conventions.[4] If CLIENT_ID or CLIENT_SECRET are absent, ensureConfigured throws "The GitHub gatekeeper is not configured." and the OAuth callback page renders NOT_CONFIGURED_HTML rather than proceeding.[4]
The GitHub Gatekeeper requests OAuth scopes ["repo", "read:user", "user:email"] for full repository access, and a minimal ["read:user", "user:email"] set when connecting in auth-only (sign-in) mode.[4] Nonces used during OAuth initiation and at the callback each have a 10-minute lifetime (INITIATION_NONCE_LIFETIME_MS and OAUTH_NONCE_LIFETIME_MS), and are 32 bytes of cryptographically random data hex-encoded.[4] After a successful OAuth callback, the gatekeeper serves SELF_CLOSING_HTML — a page that calls window.close() — so the popup tab dismisses automatically.[4]
Three resource kinds are supported — "repo", "issue", and "pull" — encoded in the ResourceKind type and exposed as SUPPORTED_RESOURCES with URL patterns https://github.com/:owner/:repo, https://github.com/:owner/:repo/issues/:number, and https://github.com/:owner/:repo/pull/:number.[4] Cache TTLs: individual entities (issues, PRs) expire after 30 s (ENTITY_CACHE_TTL_MS), list results after 15 s (LIST_CACHE_TTL_MS), and the viewer identity after 5 min (VIEWER_CACHE_TTL_MS).[4] Discussion sync uses a 5 s overlap window (DISCUSSION_SYNC_OVERLAP_MS) and bails out after 500 entries (DISCUSSION_SYNC_BAIL_LIMIT) to cap memory and request volume; reply-target resolution stops after 50 hops (MAX_REPLY_TARGET_HOPS) to prevent unbounded comment-thread traversal.[4]
The GitHubAction discriminated union covers eleven mutation types: createIssue, createPullRequest, setTitle, setBody, addLabels, removeLabels, changeState, postComment, postReview, replyToDiffComment, and mergePullRequest.[4] Pending actions are stored as StoredActionRecord values with a StoredActionState of "staged", "pending", "approved", or "rejected", plus optional appliedAt, rejectedAt, and revertInfo metadata.[4] Create-type actions (createIssue, createPullRequest) carry a provisionalId field so the gatekeeper can track provisional resources before the real GitHub ID is available post-approval.[4] Revert information is stored as a GitHubRevertInfo discriminated union — issueComment or reviewComment — keyed by commentId, enabling rollback of posted comments.[4]
The GitHub logo is embedded as an inline data:image/svg+xml URI by URL-encoding the imported SVG, avoiding a separate asset request.[4] Three configurator UI modules — GitHubIssueConfiguratorUI, GitHubPullRequestConfiguratorUI, and GitHubRepoConfiguratorUI — are imported from ./github-configurators, each paired with a pre-built HTML bundle from the ./generated/ directory.[4]
In packages/gatekeeper-cloudflare/src/cloudflare.ts, the HTTP entrypoint is used only to initiate and complete the OAuth flow; all other product functionality is served over RPC/DOs.[5] BASE_URL defaults to http://localhost:8787/gatekeeper/cloudflare when not set; CLIENT_ID and CLIENT_SECRET are optional env vars that may be omitted from wrangler.jsonc and supplied as secrets.[5] If CLIENT_ID or CLIENT_SECRET is missing when the OAuth initiation URL is hit, the worker returns a NOT_CONFIGURED_HTML page instructing the user to consult the README, rather than throwing or silently failing. Internally, the UserAccount Durable Object also throws "The Cloudflare Gatekeeper is not configured." when #config() is called without those values.[5] The OAuth flow uses a two-stage nonce pattern: an "initiation" nonce (valid 10 minutes) is verified and replaced with a fresh "oauth" nonce plus PKCE pair when beginOAuthFlow is called; only one nonce is active at a time. Access tokens have a 60-second expiry safety margin (ACCESS_TOKEN_EXPIRY_SAFETY_MS).[5] Nonce comparison in cloudflare.ts also uses constantTimeEqual (backed by crypto.subtle.timingSafeEqual) to avoid timing-oracle attacks.[5] connectAccount accepts a scopes option: when options?.scopes === "auth", it uses AUTH_SCOPES; otherwise it uses FULL_SCOPES. Auth-only grants are stored as ephemeral: true and dropped shortly after the email is read.[5] On success the OAuth callback serves a self-closing HTML page that calls window.close(); on an invalid or stale nonce it returns an "Authorization Link Expired" page covering both the initiation and OAuth stages.[5] The Cloudflare vendor logo is embedded as a data:image/svg+xml URI (the orange cloud SVG) so it can be rendered directly as the vendor/account avatar without a separate network request.[5] PKCE (Proof Key for Code Exchange) is an OAuth extension that binds an authorization request to its token exchange using a one-time verifier/challenge pair, preventing authorization-code interception attacks.
The GatekeeperVendor class (decorated @validateRpc()) currently returns an empty array from getSupportedResources() — the Cloudflare gatekeeper provides auth only, with no gadget or agent resource types yet.[5] The CloudflareGatekeeperUser interface in packages/workshop-shared/src/cloudflare-gatekeeper.ts extends the generic GatekeeperUser contract with a getUsableAccessToken() method that returns a currently-usable (refreshed if needed) Cloudflare API access token for the connected account, or null if the connection is broken or expired.[6] getUsableAccessToken() is Workshop-only — never exposed to gadgets or agents — and is used specifically by the AI Gateway billing flow to read the credit balance and route BYOK inference through the account's default AI Gateway.[6] CloudflareGatekeeperUser is explicitly an extension point: more Cloudflare capabilities (Workers logs, R2, etc.) are intended to be added to it in the future.[6]
The Context Library worker supports private per-account collections and public per-domain collections; the vendor auto-provisions accounts that expose a read-only agent singleton and a management UI.[7] ContextApiImpl in packages/gatekeeper-context/src/context-api.ts is decorated with @validateRpc() and extends RpcTarget, meaning all exposed methods are subject to Cap'n Web runtime RPC type validation.[8] Creating a public collection requires admin access (#assertAdmin()); non-admins can only create private collections.[8] Read access (#assertCanRead) is granted to any caller who either owns the collection privately or the collection is public; all others receive "Collection not found or you don't have access.".[8] Write access (#assertCanWrite) is granted only to the private-collection owner or an admin acting on a public collection; all other callers receive the same "Collection not found or you don't have access." error.[8] Only collection owners or admins can manually trigger an artifact sync via syncContextCollectionArtifactSource; read-path non-owners may trigger a background stale-while-revalidate sync but have no direct control.[8]
createContextCollection generates a UUID via crypto.randomUUID(), initializes the collection Durable Object first, then registers it in either the user library (private) or domain registry (public); if registration fails, the now-unreachable collection is deleted.[8] Collection Durable Object stubs are addressed by the composite key domainName(domain, id), and user-library stubs by domainName(domain, accountId), scoping all lookups to the sharing domain.[8] getViewerInfo() surfaces both the caller's admin status and whether Git-backed collections are enabled (!!env.ARTIFACTS), letting clients conditionally show Git collection UI.[8] Git-backed collections require the ARTIFACTS environment binding; any Git-related operation throws "Git-backed Context collections are not enabled." if env.ARTIFACTS is falsy. The binding is optional — deployments without an Artifacts R2 bucket can still use the gatekeeper.[8][9]
SchedulerGatekeeper is a Durable Object that extends DurableObject and implements the Gatekeeper<ScheduleSession> interface, exposing a ScheduleSession to agents.[10] SchedulerGatekeeper.describe() returns resource metadata including url: "scheduler://tasks", tsType: "ScheduleSession", and suggestedBindingName: "SCHEDULER".[10] SchedulerGatekeeper is read-only: applyAction, rejectAction, and revertAction all throw "Scheduled Tasks is read-only and implements no actions.", and getAutoApprovableActions() always returns an empty array.[10] SchedulerGatekeeper.getAgentCatalog() always returns null; schedule discovery happens through list() instead.[10] SchedulerGatekeeper.startSession() guards against a session being scoped to the account's own ID — if workspaceId === accountId, it throws "Invalid inherited scheduler workspace scope.".[10]
ScheduleSessionImpl.every() registers an interval schedule anchored to the current wall-clock time of registration (registeredAt = this.#now()). The Scheduler also supports finite recurrence bounds, allowing scheduled tasks to be configured with a maximum number of occurrences — passing an occurrences limit to a one-shot runAt schedule throws TypeError: "Occurrence limits apply only to recurring schedules.".[10] ScheduleSessionImpl.list() requires an authorizeObservation approval from the ApprovalQueue before returning the list of schedules.[10] ScheduleSessionImpl implements [Symbol.dispose](), which disposes the approvalQueue stub; callers should use the session within a using block to avoid stub leaks.[10] enableScheduleController strips the accountId from ScheduleControllerProps before passing activation data to driver.enable(), keeping account identity out of the persisted activation record.[10] ScheduleManagementApi is a read-only RpcTarget that exposes only a list() method (backed by driver.listAccount), providing account-level schedule visibility without mutation authority.[10]
The harness in packages/integration-tests/src/harness.ts is parameterized over gatekeepers deliberately: adding a new gatekeeper's test suite means pointing the harness at its package and plugging in a handler module, not forking the harness file.[11] The harness uses binding suffix "TEST" for the fixture gatekeeper, from which the Workshop derives vendor ID "test"; both constants are exported as TEST_GATEKEEPER_BINDING and TEST_VENDOR_ID.[11] workshopConfig injects only the gatekeeper service bindings the suite requested, so buildGatekeeperVendorMap() discovers exactly those vendors and the observer-config prompt has no surprise rows.[11]
packages/workshop-backend is the kernel of Cloudflare OS and is held to a higher review bar: every exported member of the workshop-shared public API must have a doc-comment, hand-written interfaces that mirror RPC interfaces with as unknown as casts are forbidden, and changes must prefer reusing existing mechanisms over adding parallel ones.[1]
Sources
AGENTS.mdpackages/mcp-shared/src/scope.tsdocs/blueprints.mdpackages/gatekeeper-github/src/github.tspackages/gatekeeper-cloudflare/src/cloudflare.tspackages/workshop-shared/src/cloudflare-gatekeeper.tspackages/gatekeeper-context/src/index.tspackages/gatekeeper-context/src/context-api.tspackages/gatekeeper-context/wrangler.jsoncpackages/gatekeeper-scheduler/src/scheduler.tspackages/integration-tests/src/harness.tsUpdated
Cloudflare OS's runtime stacks Durable Objects, Dynamic Workers, and Facets into a layered architecture: each workspace is a Durable Object, each Gadget runs in a Dynamic Worker Facet, and Gatekeepers inject facets to mediate access to external services. The system uses Workers KV and R2 for persistent storage (blueprints, avatars, collections), injects bindings dynamically in dev and prod, and requires global_fetch_strictly_public in production to prevent SSRF via global fetch. A Durable Object provides a single-instance, stateful execution context with strongly consistent storage, guaranteeing that only one instance runs at a time and that reads always reflect the latest committed writes.
Cloudflare OS is built on Cloudflare Workers using Durable Objects, Dynamic Workers, and Facets: every workspace is its own Durable Object, every Gadget runs in a Dynamic Worker Facet, and Gatekeepers install facets into each workspace to manage access to remote services.[1]
Gatekeeper service bindings and the Workers AI binding are not declared in the base wrangler.jsonc; they are dynamically injected by run-dev-server.js for dev and generate-wrangler-prod.js for production.[2] All Durable Object classes (UserDurableObject, OverseerDurableObject, AdminSettings, PendingLogin) are reached via ctx.exports and require no explicit durable_objects binding in wrangler.jsonc.[2] Two KV namespace bindings are declared for workshop-backend: BLUEPRINTS (blueprint metadata, preview ID gadgets-blueprint-metadata) and AVATARS (user avatar images, preview ID gadgets-avatars).[2] Blueprint code snapshots are stored in an R2 bucket bound as BLUEPRINT_CONTENT (bucket name gadgets-blueprint-content).[2]
capnweb-validate was introduced to provide runtime RPC type validation for both the backend and gatekeepers; packages/workshop-backend/src/server.ts applies the @validateRpc() decorator to AuthenticatedApiImpl, enabling this validation for all authenticated API calls.[3][4] Structured logging for Workers Logs was introduced to replace unstructured log output from the backend.[5]
workshop-backend enables observability with full head sampling (head_sampling_rate: 1) for logs but disables invocation logs. Traces are enabled at 50% head sampling (head_sampling_rate: 0.5); a comment notes spans will bill against the Logs quota from 2026-10-01, so the sampling rate should be revisited before then.[2] The gatekeeper-context worker enables observability with full head sampling (head_sampling_rate: 1) but disables invocation logs; no trace sampling is configured for it.[6]
The global_fetch_strictly_public compatibility flag is required in wrangler.jsonc to prevent SSRF: without it, the webFetch agent tool (and global fetch()) can reach RFC1918/private-network addresses in production.[2] This flag only takes effect in production or when running workerd standalone; wrangler dev intentionally reconfigures its global outbound to permit fetching from any address (including localhost), making the flag a no-op during local development.[2]
The gatekeeper-context worker's main entry point is a generated file at .wrangler/validate/src/index.ts, produced by the build command pnpm exec capnweb-validate build --out .wrangler/validate, with src as the watch directory.[6] Four Durable Object classes are registered as new SQLite classes under migration tag v0 in packages/gatekeeper-context/wrangler.jsonc: ContextCollectionDurableObject, UserLibraryDurableObject, LibraryRegistryDurableObject, and ContextGatekeeper.[6] As with workshop-backend, Durable Object classes in gatekeeper-context are reached via ctx.exports, so no durable_objects binding entry is needed in that config.[6] gatekeeper-context binds a KV namespace named CONTEXT_COLLECTIONS (preview ID gadgets-context-collections) for public-collections snapshots.[6]
Product analytics is optional: deployments can bind PRODUCT_ANALYTICS to a Cloudflare Pipelines stream typed as Pipeline<ProductAnalyticsRecord>; local and dev configs omit it, and analytics no-op when the binding is absent.[7] Frontend error reporting requires both FRONTEND_ERROR_REPORTER (a Service<ErrorReporter> binding) and FRONTEND_ERROR_RATE_LIMITER (a RateLimit binding) to be present before error reports are dispatched.[7] The error-reporting package and workshop-frontend's errorReporting.ts attach the current route and authenticated user to every client-side error report sent to the backend; the useAuth hook exposes the signed-in user in the form required by the error reporter.
Cloudflare Access authentication is enabled by setting CF_ACCESS_AUD (audience) and CF_ACCESS_ISS (team URL, e.g. https://<team>.cloudflareaccess.com) in the environment.[7] The integration-test harness omits CF_ACCESS_AUD so /api takes the unauthenticated code path and password signup is available during tests.[8] AUTH_GATEKEEPERS=cloudflare,google,github allowlists which connected gatekeepers may be used for sign-in, showing a "Continue with …" button for each alongside username/password. Each auth gatekeeper OAuth app must be registered with a redirect URI of the form ${PUBLIC_BASE_URL}/gatekeeper/{github|google|cloudflare}/oauth.[9] DISABLE_PASSWORD_AUTH=true hides username/password login and signup, leaving gatekeeper sign-in only; this setting is ignored unless AUTH_GATEKEEPERS is non-empty, to avoid locking everyone out.[9][7]
ENABLE_CLOUDFLARE_LIMITS=true enables the free daily AI usage limit plus the Cloudflare-credits top-up flow; billing reads a token from the connected Cloudflare gatekeeper.[9] DAILY_LLM_CALL_LIMIT sets the per-user daily free-tier LLM-call limit as a string (defaulting to DEFAULT_DAILY_LLM_CALL_LIMIT), and MINIMUM_CLOUDFLARE_BALANCE sets the minimum connected-account balance in USD required to proceed via BYOK (defaulting to the MINIMUM_CLOUDFLARE_BALANCE constant).[7]
packages/workshop-backend/src/server.ts uses a module-level formatBlueprintInstallStarted flag to ensure the bundled format blueprints are only requested to install once per Worker instance; the AdminSettings Durable Object holds the authoritative answer.[4] SERVICE_SALT in packages/workshop-shared/src/api.ts is a fixed 16-byte Uint8Array used as part of the argon2id password hashing salt on the client side.[10] Durable Object resets are absorbed at the Worker layer in user.ts, server.ts, and do-telemetry.ts so that transient DO evictions do not propagate to the client as fatal failures; overseer.ts applies the same recovery to Overseer session capabilities. The mcp-shared package modules connection.ts, session.ts, client.ts, fetch.ts, and account.ts include hardened error handling and state management covering MCP connection establishment, session tracking, and request fetching. mcp-shared/__tests__/ contains test coverage for the hardened MCP lifecycle modules in mcp-shared. The reconnect probe handshake contract — used to test session liveness after device sleep or browser tab suspension — is implemented in packages/workshop-backend/src/server.ts and packages/workshop-shared/src/api.ts. packages/workshop-frontend detects tab-wake events in routes/__root.tsx and injects a reconnect probe into GadgetEditor.tsx and main.tsx; on visibility restore the probe tests WebSocket liveness and forces a clean reconnection when the session is stale. Crash-recovery logic in overseer.ts and packages/workshop-backend/src/agent.ts handles git-storage edge cases that could leave the system in an inconsistent state after a crash or interrupted write, allowing the Workshop to resume correctly after partial writes without manual intervention. Related crash-recovery and transactionality fixes also appear in workshop-shared/src/api.ts and workshop-shared/src/code-change.ts. The step-transactionality.md design document records the transactionality and crash-recovery approach; it is the reference for engineers working on storage durability or debugging state corruption in overseer.ts.
Sources
README.mdpackages/workshop-backend/wrangler.jsoncgithub.com/cloudflare/cloudflare-os/commit/1b92ab5packages/workshop-backend/src/server.tsgithub.com/cloudflare/cloudflare-os/commit/4b53133packages/gatekeeper-context/wrangler.jsoncpackages/workshop-backend/src/env.d.tspackages/integration-tests/src/harness.tsdocs/public-server.mdpackages/workshop-shared/src/api.tsUpdated
AI Gateway mode, controlled by the CF_AI_GATEWAY env var, routes all AI inference through Cloudflare's shared gateway using server-managed provider credentials, eliminating the need for user API keys. Configuration determines routing: CF_AI_GATEWAY_WAI_DIRECT bypasses the gateway for Workers AI; CF_AI_GATEWAY_PROVIDERS filters which models are available; and credential validation ensures required gateway auth tokens are present. The "developer" role is supported by Cloudflare and OpenAI backends but is not a universal AI provider concept — local and third-party backends such as Ollama do not recognize it.
AI Gateway mode is enabled by setting the CF_AI_GATEWAY env var to a gateway name; when set, supported AI providers are routed through Cloudflare AI Gateway with server-managed keys, so users do not need their own API keys.[1] getAiGatewayConfig() in packages/workshop-backend/src/ai-gateway.ts returns null when CF_AI_GATEWAY is not set, indicating AI Gateway mode is disabled.[2] Outside AI Gateway mode, Workers AI (provider cloudflare) is BYOK — the account ID and API token live in the user's model config, not in the Worker's environment.[1] Gadget AI providers were switched to route all inference through the shared AI Gateway rather than calling provider APIs directly.[3] The workshop backend's AI Gateway support is implemented in packages/workshop-backend/src/ai-gateway.ts and packages/workshop-backend/src/ai-models.ts, wired into the agent and environment binding layer via env.d.ts.
docs/ai-gateway-billing.md describes billing implications of AI Gateway mode; docs/public-server.md describes public server configuration for AI features.
CF_AI_GATEWAY_ACCOUNT_ID and CF_AI_GATEWAY_API_TOKEN (a Run + Read token) are required whenever CF_AI_GATEWAY is set; AiGatewayConfig construction in packages/workshop-backend/src/ai-gateway.ts throws if either is missing.[2]
Setting CF_AI_GATEWAY_WAI_DIRECT to "true" routes Workers AI to its plain REST endpoint — no gateway, no cost logs — instead of a named Gateway.[1] Setting both CF_AI_GATEWAY_WAI and CF_AI_GATEWAY_WAI_DIRECT=true simultaneously throws a configuration error; the two options are mutually exclusive.[2] When CF_AI_GATEWAY_WAI_DIRECT is "true", AiGatewayConfig.workersAiGateway is set to undefined, disabling the Workers AI binding route; otherwise it falls back to CF_AI_GATEWAY_WAI and then to the main CF_AI_GATEWAY gateway.[2]
AiGatewayConfig.providers is parsed from CF_AI_GATEWAY_PROVIDERS, a comma-separated string; empty entries are stripped.[2] AiGatewayConfig.getModelList() returns only models whose provider is listed in CF_AI_GATEWAY_PROVIDERS; models from unlisted providers are silently excluded.[2] AiGatewayConfig.resolveModel() populates apiToken and apiUrl as empty strings in the returned UserAiModelRecord, because real credentials are read from env at inference time — not stored in the record.[2] AiGatewayConfig.getQuickModelConfig() always uses the cloudflare (Workers AI) provider for the quick model, regardless of which gateway providers are configured.[2] In ai-models.ts, the "developer" role is excluded from the Ollama code path; passing it to Ollama caused failures and has been removed. The pi dependency in packages/workshop-backend/package.json includes Workers AI model definitions for the DeepSeek V4 family; without it, the backend cannot resolve DeepSeek V4 model identifiers at runtime. DeepSeek V4 Pro 0813 is available as a selectable model in the Workshop; packages/workshop-shared/src/api.ts adds it to the suggested models list exposed over the public API. packages/workshop-shared/src/api.ts is the source of truth for the available model catalogue; the model registry is synchronized with the AI model test suite in packages/workshop-backend/__tests__/ai-models.test.ts. GLM 5.3 Flash is available as a selectable model in the Workshop; packages/workshop-shared/src/api.ts adds it to the suggested models list exposed over the public API.
getAiGatewayLogCost() in packages/workshop-backend/src/ai-gateway.ts uses two retrieval paths: if the route has no accountId, it uses the Workers AI binding (env.WORKERS_AI.gateway(...).getLog()); otherwise it calls the REST API at api.cloudflare.com, applying a 10-second timeout via AbortSignal.timeout(10_000).[2] AiGatewayLogRetryableError signals transient failures in AI Gateway log lookups — including network errors, 404/408/429/5xx responses, and unreadable response bodies — that the caller should retry.[2] A cost value of undefined or null from an AI Gateway log record also throws AiGatewayLogRetryableError ("not available yet"), indicating the log exists but cost has not yet been computed.[2]
Sources
Updated
Cloudflare OS has deprecated several type parameters in Gatekeeper, renamed core concepts like "pin" to "favorite", and removed experimental compatibility flags. The webFetch tool now negotiates content format with sites via Accept headers and respects opt-out signals like Content-Signal: ai-input=no and the prohibitAllSharing flag.
The Action and RevertInfo type parameters were removed from the Gatekeeper type; gatekeeper implementations no longer need to supply these generics.[1] All gatekeepers were migrated from string-keyed action identifiers to numeric action IDs.[2]
The "pin" concept was renamed to "favorite" throughout the codebase to make terminology consistent with the sidebar.[3] The experimental compatibility flags were removed from wrangler configs.[4]
The webFetch tool now sends Accept headers that prefer Markdown, so that agent-optimized sites can serve Markdown responses directly.[5] webFetch respects the Content-Signal: ai-input=no response header, refusing to feed page content into the agent when a site sets this signal.[6] webFetch is disallowed when the prohibitAllSharing observation flag is set — see Sharing and permissions for how sharing restrictions are configured.[7]
Sources
github.com/cloudflare/cloudflare-os/commit/fbc0e39github.com/cloudflare/cloudflare-os/commit/dba3690github.com/cloudflare/cloudflare-os/commit/29773a1github.com/cloudflare/cloudflare-os/commit/b2a215egithub.com/cloudflare/cloudflare-os/commit/b7a46e5github.com/cloudflare/cloudflare-os/commit/02e50b7github.com/cloudflare/cloudflare-os/commit/5142d30