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