Write and Deploy a Custom Gatekeeper for Cloudflare OS
Build a Gatekeeper Worker for Cloudflare OS. Covers the vendor, user, and DO layers; OAuth flow; session API; approval queue; and observer verification.
Use this file to discover all available pages before exploring further.
Gatekeepers are the “device drivers” of Cloudflare OS — they connect agents and Gadgets to external services while enforcing security, logging every access, and routing write actions through the human-in-the-loop approval queue. Because each Gatekeeper is a completely independent Cloudflare Worker, you can write one for any service and plug it into your deployment with a single service binding. This guide walks through the full anatomy and implementation of a custom Gatekeeper.
Before writing your own, check the existing reference implementations: gatekeeper-google (OAuth, multiple resource types, caching, simulation), gatekeeper-github (clean strategy-B observer pattern), gatekeeper-email (hook-based push notifications), and gatekeeper-supabase (strategy-C data-set tracking). Reading one alongside this guide will make the patterns concrete.
A Gatekeeper package is structured as three cooperating types, each living in the same Worker but at a different layer of abstraction.
GatekeeperVendor
A WorkerEntrypoint exported as the Worker’s default entrypoint. One per service. Handles the initial OAuth flow and describes the service to the Workshop.
GatekeeperUser
A WorkerEntrypoint with ctx.props holding the user’s credentials. Represents one connected account. Maps resource URLs to Gatekeeper DO classes.
Gatekeeper DO
A DurableObject running as a facet of the Overseer. One instance per (user, resource, Gadget) triple. Provides the Session API and handles the approval queue.
The Workshop discovers your Gatekeeper automatically by scanning its own GATEKEEPER_* service bindings — no registration step is needed beyond adding the binding.
export interface GatekeeperVendor extends WorkerEntrypoint { /** Human-readable service info for the Connectors page. */ describe(): Promise<VendorDescription>; /** * Start the OAuth flow. Returns a URL for the user to open. * `options.scopes`: * "auth" — minimal scopes for sign-in only (transient, not persisted) * "full" — full capability scopes (default, persisted as a connected account) */ connectAccount( callback: Fetcher<GatekeeperConnectCallback>, options?: GatekeeperConnectOptions, ): Promise<{ url: string }>; /** Resource types this vendor exposes, with URL patterns for matching. */ getSupportedResources(options?: { userId?: string }): Promise<SupportedResource[]>; /** Returns the content of a .d.ts file describing all Session types. */ getTypeScriptTypes(): Promise<string>; /** * For auto-provisioning vendors only (autoProvisionsAccount: true). * Mints a connected account with no OAuth flow or user identity. */ createAccount?(): Promise<Fetcher<GatekeeperUser>>;}
export interface Gatekeeper<Session> extends DurableObject { describe(): Promise<ResourceDescription>; getTypeScriptTypes(): Promise<string>; getAutoApprovableActions(): Promise<ActionKind[]>; /** Create the RPC session the Gadget code calls. Dup the ApprovalQueue before storing. */ startSession(approvalQueue: RpcStub<ApprovalQueue>): Promise<Session>; /** Apply an approved action. */ applyAction(action: number): Promise<void>; /** Clean up simulation state for a rejected action. */ rejectAction(action: number): Promise<void | { restart?: boolean }>; /** Undo an already-applied action. */ revertAction(action: number): Promise<void | { message?: string; canRetry?: boolean; restart?: boolean }>; /** * Observer verification — must throw if the user represented by `user` * cannot access everything this Gatekeeper has read. Called on EVERY open * by every authorized collaborator (re-verification). Must be idempotent. */ addObserver(id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void>; /** Idempotent — ignore unknown ids. */ removeObserver(id: string): Promise<void>;}
export type ObservationDescription = { title: string; description: string; /** * Legacy: set true to completely prohibit sharing this Gadget. * Prefer excludeObservers for per-user access control. */ prohibitAllSharing?: boolean; /** * Observer IDs that must NOT see this observation. * The Overseer blocks the observation unless every named observer * has already lost access in the sharing graph. */ excludeObservers?: string[];};
The most common Gatekeeper implementation uses OAuth 2.0. The typical flow looks like this:
1
connectAccount() is called
The Workshop calls GatekeeperVendor.connectAccount(callback, options). Create a new UserAccount Durable Object, store the callback and a cryptographic nonce (128-bit random), and return a URL like https://my-gatekeeper.example.com/<doId>/<nonce>.
2
User opens the URL
The user opens the URL in a new tab. Your Worker’s fetch handler verifies the nonce (single-use, time-limited) and redirects to the OAuth provider’s authorization URL.
3
OAuth callback
The provider redirects back to your Worker with an authorization code. Exchange it for tokens, store them in the UserAccount DO, and call callback.complete(userImpl) with a GatekeeperUser stub.
4
Workshop persists the account
The Workshop stores the returned GatekeeperUser stub as a connected account in the user’s UserDurableObject. From this point on, the account capability — not any asserted identity — is the authority.
Use scopes: "auth" for a minimal sign-in-only flow and scopes: "full" for a full-capability connection that gets persisted as a connected account. Vendors without providesAuth ignore the scopes option and always use their full scopes.
Every interaction with an external service goes through the ApprovalQueue. The distinction between observations and actions is what enables the asynchronous human-in-the-loop model.
Call approvalQueue.authorizeObservation(description)after fetching data but before returning anything to the caller. The method either returns normally (access allowed) or throws (access denied).
async getDocument(): Promise<DocumentContent> { // Fetch from the external service first const content = await this.#fetchFromService(); // Then authorize — description can reference actual content await this.#approvalQueue.authorizeObservation({ title: "Read document", description: `Read "${content.title}" from the service.`, }); return content;}
Call approvalQueue.submitAction(actionId, description) and return immediately. Do not call the external service yet. Update your local simulation state so subsequent reads reflect the pending action.
async updateDocument(newContent: string): Promise<void> { const actionId = this.#nextActionId++; // Store the action details in DO storage for applyAction() to retrieve this.ctx.storage.kv.put(`action:${actionId}`, { type: "update", content: newContent }); // Submit for approval — returns immediately await this.#approvalQueue.submitAction(actionId, { title: "Update document", description: `Replace content with: "${newContent.slice(0, 80)}…"`, implementsRevert: true, }); // Update simulation cache so the agent can read back the pending value this.ctx.storage.kv.put("cached:content", newContent);}
The applyAction(actionId) method on your Gatekeeper DO is called when the user approves. Retrieve the stored action details and apply them to the external service. rejectAction(actionId) is called on rejection — clear your simulation state.
Some Gatekeepers don’t need OAuth because they don’t represent a user’s third-party account. The Context Library and Scheduler are examples — they provision an account automatically, with no user interaction.To implement an auto-provisioning Gatekeeper:
1
Declare autoProvisionsAccount
Set autoProvisionsAccount: true in your VendorDescription. This tells the Workshop the vendor can mint accounts without a browser-based OAuth flow.
2
Implement createAccount()
Add createAccount() to your GatekeeperVendor. It takes no arguments (no user identity is passed in) and returns a GatekeeperUser stub backed by a freshly generated account ID.
async createAccount(): Promise<Fetcher<GatekeeperUser>> { const accountId = this.ctx.exports.UserAccount.newUniqueId(); // Initialize the account with no OAuth credentials await this.ctx.exports.UserAccount.get(accountId).initialize(); const props: MyUserImplProps = { userObjectId: accountId.toString() }; return this.ctx.exports.MyUserImpl({ props });}
3
Declare singleton and/or providesUi (optional)
If the account provides an agent-facing session, set singleton: { tsType: "MySessionType" } in AccountDescription. The Workshop will auto-provide it as an ambient named binding in every owner workspace.If the account has a management UI (like the Context Library file manager), set providesUi: { title: "My Service" }. The Workshop surfaces it at /gatekeepers/<vendorId> as a nav entry.
Every Gatekeeper must implement getVerifier(), addObserver(), and removeObserver() — the code won’t type-check without them. Choose a strategy based on the sensitivity of your resource:
Strategy
When to use
addObserver behavior
A — Private-only
Resource is too sensitive to share (e.g. personal email inbox)
Always throws
B — ACL check
Single atomic resource with a clear ACL (e.g. a specific document or repo)
Verifies the observer can access the bound resource; throws if not
C — Data-set tracking
Broad binding spanning sub-resources with distinct ACLs (e.g. a whole workspace)
Verifies observer against every sub-resource accessed so far; sets excludeObservers for new ones
D — Low-stakes
Personal or low-sensitivity service (e.g. Spotify, Home Assistant)
The key to observer verification is the “non-standard method” pattern. GatekeeperUserVerifier has no methods of its own — it is opaque to the Overseer. To check access, define a vendor-specific interface extending it with your own methods, implement it on a WorkerEntrypoint, and cast the Fetcher back to that interface inside addObserver. The Overseer’s same-vendor guarantee makes the cast safe.
// Define a vendor-specific verifier interfaceexport interface MyVerifierApi extends GatekeeperUserVerifier { hasResourceAccess(resourceId: string): Promise<boolean>;}// Implement it as a WorkerEntrypoint using the observer's own credentialsexport class MyVerifier extends WorkerEntrypoint<Env, { userObjectId: string }> implements MyVerifierApi { async hasResourceAccess(resourceId: string): Promise<boolean> { const account = this.ctx.exports.UserAccount.get( this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId) ); try { await callExternalServiceWithObserversCredentials(account, resourceId); return true; } catch (err) { if (isAccessDenied(err)) return false; // 401/403/404 → observer lacks access throw err; // transient failure → rethrow, so open fails loudly } }}// In GatekeeperUser:async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> { return this.ctx.exports.MyVerifier({ props: { userObjectId: this.ctx.props.userObjectId } });}// In Gatekeeper DO:async addObserver(id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void> { const verifier = user as unknown as Fetcher<MyVerifierApi>; if (!(await verifier.hasResourceAccess(this.ctx.props.resourceId))) { throw new Error( "This collaborator does not have access to the bound resource " + "and cannot observe data the Gadget read from it." ); }}async removeObserver(_id: string): Promise<void> {} // no-op for strategy B
The Workshop backend auto-discovers Gatekeepers from GATEKEEPER_*-prefixed bindings. The router also needs the same binding to route /gatekeeper/myservice/* requests — add it to packages/router/wrangler.jsonc as well.
3
Redeploy
Redeploy the workshop-backend and router workers. The new Gatekeeper will appear on the Connectors page immediately.
Here is a stripped-down skeleton showing the essential structure. Replace My/my/MY with your service name. See SKELETON.md in the source tree for the full version including hooks, configurator UI, and detailed TODOs.
import { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from "cloudflare:workers";import type { GatekeeperVendor as GatekeeperVendorIface, GatekeeperUser, GatekeeperUserVerifier, Gatekeeper, VendorDescription, AccountDescription, SupportedResource, ResourceDescription, ResourceConfiguratorFrame, ApprovalQueue,} from "@gadgets/workshop-shared/gatekeeper";import type { MySession } from "./types";import TYPES_CODE from "./types.txt";// ── Vendor ──────────────────────────────────────────────────────────────────export class GatekeeperVendor extends WorkerEntrypoint<Env> implements GatekeeperVendorIface { async describe(): Promise<VendorDescription> { return { displayName: "My Service", url: "https://example.com", tagline: "Short description of what this enables", }; } async connectAccount(callback: Fetcher<any>, options?: any) { const id = this.ctx.exports.UserAccount.newUniqueId(); const nonce = generateNonce(); await this.ctx.exports.UserAccount.get(id).setCallback(callback, nonce, []); return { url: `${getBaseUrl(this.env)}/${id}/${nonce}` }; } async getSupportedResources(): Promise<SupportedResource[]> { return [{ urlPattern: "https://example.com/*", title: "My Resource", description: "..." }]; } async getTypeScriptTypes(): Promise<string> { return TYPES_CODE; }}// ── UserAccount DO ───────────────────────────────────────────────────────────export class UserAccount extends DurableObject<Env> { async setCallback(callback: any, nonce: string, patterns: string[]) { this.ctx.storage.kv.put("callback", callback); this.ctx.storage.kv.put("nonce", { value: nonce, expiresAt: Date.now() + 600_000 }); this.ctx.storage.setAlarm(Date.now() + 3_600_000); // self-destruct if never completed } async alarm() { if (!this.ctx.storage.kv.get("credentials")) this.ctx.storage.deleteAll(); }}// ── UserImpl (GatekeeperUser) ────────────────────────────────────────────────type UserProps = { userObjectId: string };export class MyUserImpl extends WorkerEntrypoint<Env, UserProps> implements GatekeeperUser { async describe(): Promise<AccountDescription> { return { displayName: "TODO", avatar: { url: "" } }; } async getSupportedResources() { return this.ctx.exports.GatekeeperVendor.getSupportedResources(); } async getGatekeeperClassFor(url: string) { const props: GatekeeperProps = { userObjectId: this.ctx.props.userObjectId, resourceUrl: url }; return { class: this.ctx.exports.MyGatekeeperImpl({ props }), resource: /* ... */ {} as any }; } async startResourceConfigurator(_pattern: string): Promise<ResourceConfiguratorFrame> { throw new Error("TODO"); } async revoke() { /* TODO: revoke tokens */ } async reconnect() { throw new Error("TODO"); } async getAuthenticatedEmail() { return null; } async ensureResources(_patterns: string[]) { return {}; } async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> { return this.ctx.exports.MyVerifier({ props: { userObjectId: this.ctx.props.userObjectId } }); }}// ── Verifier ─────────────────────────────────────────────────────────────────type VerifierProps = { userObjectId: string };export class MyVerifier extends WorkerEntrypoint<Env, VerifierProps> implements GatekeeperUserVerifier { // Add non-standard methods here for strategy B/C (see Observer verification above). // For strategy D, leave this empty; an empty WorkerEntrypoint is still valid.}// ── Gatekeeper DO ────────────────────────────────────────────────────────────type GatekeeperProps = { userObjectId: string; resourceUrl: string };export class MyGatekeeperImpl extends DurableObject<Env, GatekeeperProps> implements Gatekeeper<MySession> { async describe(): Promise<ResourceDescription> { return { url: this.ctx.props.resourceUrl, title: "TODO", snippet: "", suggestedBindingName: "MY_RESOURCE", tsType: "MySession" }; } async getTypeScriptTypes() { return TYPES_CODE; } async getAutoApprovableActions() { return []; } async startSession(approvalQueue: RpcStub<ApprovalQueue>): Promise<MySession> { return new MySessionImpl(approvalQueue.dup(), this.ctx); } async applyAction(id: number) { throw new Error(`Unknown action: ${id}`); } async rejectAction(_id: number) {} async revertAction(_id: number) { throw new Error("Revert not implemented"); } // Strategy B — throws if observer lacks access; no-op remove. // Switch to strategy A (always throw), D (both no-ops), or C (data-set tracking) as needed. async addObserver(_id: string, _user: Fetcher<GatekeeperUserVerifier>) { // TODO: cast _user to Fetcher<MyVerifierApi> and check access } async removeObserver(_id: string) {}}// ── Session (RpcTarget) ───────────────────────────────────────────────────────class MySessionImpl extends RpcTarget implements MySession { #queue: RpcStub<ApprovalQueue>; #ctx: DurableObjectState<GatekeeperProps>; constructor(queue: RpcStub<ApprovalQueue>, ctx: DurableObjectState<GatekeeperProps>) { super(); this.#queue = queue; this.#ctx = ctx; } [Symbol.dispose]() { this.#queue[Symbol.dispose](); } async getData(): Promise<string> { const result = "TODO: fetch from external service or cache"; await this.#queue.authorizeObservation({ title: "Read data", description: "Fetched from service." }); return result; } async updateData(value: string): Promise<void> { const actionId = 0; // TODO: assign sequential ID and store in DO storage await this.#queue.submitAction(actionId, { title: "Update data", description: `Set value to: ${value}`, implementsRevert: true, }); // TODO: update simulation cache }}
Read packages/workshop-shared/node_modules/capnweb/README.md for full details on Cap’n Web RPC, especially promise pipelining — passing a not-yet-resolved promise as an argument to another RPC call. This is how getVerifier() results can be passed directly into addObserver() without an extra round trip, and how sessions can be pipelined without awaiting intermediate stubs.