- name
- write-gatekeeper
- description
- Guides implementation of Gatekeeper Workers that bridge Gadgets to external services. Covers auth, capability-based API design, approval queue integration, action kinds and auto-approval verdicts, caching, and action simulation. Load when creating, modifying, or reviewing the implementation of a gatekeeper.
# Writing a Gatekeeper
A Gatekeeper is a Cloudflare Worker that mediates all access between a Gadget and an external service. It implements a three-tier hierarchy:
- **Vendor** (`GatekeeperVendor`, a `WorkerEntrypoint`) — top-level entry for the service. One per service.
- **User** (`GatekeeperUser`, a `WorkerEntrypoint` with `ctx.props`) — a human user's authenticated connection.
- **Instance** (`Gatekeeper<Session>`, a DO facet of the Overseer) — per-resource, per-Gadget binding that provides the Session API.
Read `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces and detailed JSDoc.
## New-gatekeeper observer contract
Write new gatekeepers to this observer contract:
- Every read that returns information to its caller (provider data, anything derived from it, or simulated state) explicitly reports `subresources`: every resource whose access rules govern that information. A [simulated](#simulation) read reports every real resource its data came from. All observer authorization goes through `verifyObserverAccess()`; no requirement may live only in a binding-wide admission check.
- `subresources: []` adds **no requirement**. It does not clear requirements already recorded, and it never means "the binding already checked it." Private-only provider data reports a real resource ID and denies collaborators through `verifyObserverAccess()`. Report and verify any required workspace or tenant membership explicitly.
- Implement `getHistoricalSubresources(): Promise<string[]>` as a read-only method returning `[]`, with no legacy tracker. It is required even though the current `Gatekeeper` interface does not declare it, so type-checking will not catch its absence. Do not omit or remove it.
- Implement `addObserver(id, verifier)` and `removeObserver(id)`, which the current interface requires, as **compatibility-only no-ops**. Do not introduce observer rosters, observed-set logs, stored observer verifiers, `excludeObservers`, or new legacy trackers.
- Require `subresources` in your read helper's type; the shared type still makes it optional. Type-checking does not enforce the whole contract; use the [review checklist](#reviewing-a-gatekeeper).
The current `gatekeeper.ts` JSDoc still describes the legacy protocol, in which `addObserver()` must verify and throw and a binding-wide requirement such as tenant membership belongs there. For new gatekeepers this section overrides it. The current kernel already supports that: it checks every reported requirement itself through `verifyObserverAccess()`.
This is a **new-authoring** rule, not a shortcut for migrating existing gatekeepers. Existing migrations must preserve old checks and export their historical requirements through `getHistoricalSubresources()`; they cannot export a blind `[]` or turn their admission methods into no-ops. See [Existing-code migration and legacy history](#existing-code-migration-and-legacy-history).
## Seven responsibilities
1. **Auth management** — Manage authorization to the external service via OAuth (or similar), on behalf of the human end user. This means managing "connected accounts" — token storage, refresh, and revocation in a `UserAccount` Durable Object.
2. **API design** — Provide a TypeScript API wrapper around the service's API, compatible with Cap'n Web RPC. The interface should be designed around capability-based security: object-oriented, with separate interfaces representing logical resources. For example, the Google Docs gatekeeper provides an interface to a *specific* document, rather than a coarse-grained interface where you pass the doc ID to every method. **IMPORTANT:** When writing a new gatekeeper, design a proposed API and then STOP to let the operator review and make changes before proceeding with the rest of the implementation. Getting the API right is the most important and delicate part of creating a new gatekeeper.
3. **Fine-grained resource granting** — Enable the end user to grant access to agents at fine granularities, in addition to coarse-grained access. For example, a user may want to give an agent access to a specific Google Doc or GitHub repo, rather than granting broad access to everything they can do. This should be straightforward given a capability-based API. That said, broad access should also be allowed when it makes sense. Consider carefully which granularities are meaningful — a Jira gatekeeper might support "whole service", "project", and "issue" granularities, but it would be silly to support granting access to a single field of an issue separately.
4. **Logging & approvals** — Every action the agent or gadget performs must be logged via the `ApprovalQueue` API. Every action with an externally-visible side effect must be submitted via `submitAction()`, and must not actually be performed until `applyAction()` has been called. Every action declares an `autoApprovable` verdict, `true` unless the [opt-out policy](#action-kinds-and-auto-approval) applies. Read-only observations must call `authorizeObservation()` before returning data to the caller. Reads the gatekeeper makes for its own purposes, returning nothing to the caller, are not observations (see [What counts as an observation](#what-counts-as-an-observation)).
5. **Caching** — When it makes sense, cache remote content in the gatekeeper's DO storage to improve performance when agents or gadgets repeatedly read the same data. Caching also enables a better TypeScript API when the service's underlying API has an inconvenient data shape. For example, Gmail's API for listing threads returns only thread IDs without metadata, requiring a callback for each thread; with caching, the gatekeeper can provide an API that returns rich thread summaries directly, reading from local content synchronized with Gmail as needed. See Phase 2 for implementation guidance.
6. **Simulation** — Actions submitted but not yet applied should be simulated as if they already occurred, to the maximum extent reasonable. If the caller reads back data, it should observe the data as if pending actions had been applied, even though they haven't yet. This allows the agent to continue working without waiting for each approval, and allows the end user to batch-approve a lot of work at once. Simulation may leverage caching (updating the cache on submit, clearing or repopulating it on reject), or it may work by storing pending actions separately and adjusting read results at query time — the latter is arguably cleaner but trickier to implement correctly. See Phase 2 for implementation guidance.
7. **Observer verification** — When a Gadget is shared, collaborators may see data it previously read. Every observation reports the resources that restrict its audience, and `verifyObserverAccess()` checks whether each collaborator could read all of them. Design this model with the Session API and implement it before any restricted provider data is returned; it is not a deferred Phase 2 concern. See [Observer verification](#observer-verification).
## Phase 1: Core implementation
In the first phase, focus on responsibilities 1 - 3, the observation half of responsibility 4, and the access model in responsibility 7. Every read must already authorize its observation with complete `subresources`; returning restricted data with `subresources: []` while planning to implement security later is not acceptable. Actions, caching, and simulation follow in Phase 2.
### Step 1: Understand the external service
Study the service's API docs. Identify:
- Auth model (OAuth 2.0, API keys, etc.)
- Resources to expose and what access granularities make sense
- Which operations are observations (read-only) vs. actions (side effects)
- For each action, its kind (the group a user would enable together, labelled like "Edit labels") and its auto-approval verdict (see the [opt-out policy](#action-kinds-and-auto-approval))
### Step 2: Design the Session types
Create `src/types.d.ts` defining the Session interface (and Hook interface if the service pushes events — see [Hooks](#hooks-push-notifications)).
Before designing, read `packages/workshop-shared/node_modules/capnweb/README.md` to understand what Cap'n Web RPC supports — this determines what types and patterns are expressible in the Session interface.
Design principles:
- One interface per logical resource type, not a god-object
- Methods return structured data, not raw API responses
- Use capability-based design principles: make it easy to limit authority in useful ways by simply limiting access to specific objects or allowing/blocking specific methods
- Simplify API complexities that are not likely to matter to agents and gadgets; design for a more novice user and common use cases
- Consider what URL patterns `getGatekeeperClassFor()` should match — each pattern maps to a resource granularity
- Include JSDoc comments; these types serve as the agent's API documentation. See [Documenting the API](#documenting-the-api-typesdts) below.
- Design the subresource/access model alongside these types: which canonical resource IDs each read reports (including parent membership requirements), what each kind means, and how the observer's account is checked. Include cursor pages, cache hits, empty results, nested capabilities, and hook deliveries.
#### Documenting the API (`types.d.ts`)
This JSDoc is the agent's sole documentation for the API, so keep it **narrowly focused on what the agent needs to use each method**: what it does, its parameters, the returned data shape, and any errors the caller must handle.
Do NOT leak details the caller doesn't need to use the API — the approval queue (never mention `submitAction`/`applyAction`/approvals; correct simulation keeps this invisible), or gatekeeper internals (caching, DO storage, OAuth, syncing). Document those in the `.ts` implementation or PR, never in the agent-facing `.d.ts`.
The agent receives the `.d.ts` text **verbatim** (via the `types.txt` symlink), and nothing resolves its imports — so it must be **self-contained**. Don't import types from other packages or files (e.g. `Cursor` from `@gadgets/workshop-shared/gatekeeper`); copy the definition into the `.d.ts` instead. TypeScript's structural typing means the implementation can still return the shared type. The only exception is `cloudflare:workers` (e.g. `import type { RpcTarget } from "cloudflare:workers"`), which the agent's environment provides. `pnpm lint` enforces this with the `gadgets/self-contained-agent-types` rule on every `packages/gatekeeper-*/src/*types.d.ts`. Also keep the file free of comments aimed at maintainers (such as "keep in sync with X"), since the agent reads those too.
### Step 3: STOP — Present API for review
**Do not proceed without operator approval.**
Present the proposed `types.d.ts` and explain the design: what resource granularities are supported, what Session methods do, and what trade-offs were made. The API is the most important and delicate part of a gatekeeper — getting it wrong means rebuilding. Wait for the operator to review and approve (or request changes) before continuing.
Present the subresource/access model too: map every data-revealing method and hook to its reported resource kinds and verifier checks, including private-only data and empty/cached pages. Review it before implementation; observer authorization cannot be postponed until Phase 2.
Also confirm the JSDoc follows [Documenting the API](#documenting-the-api-typesdts) — no approval-queue or implementation details leaked in, and run the [API review](#reviewing-a-gatekeeper) on your own design first.
### Step 4: Implement
See [SKELETON.md](SKELETON.md) for a complete implementation template.
Package structure:
```
packages/gatekeeper-<name>/
├── src/
│ ├── configurator/ # Optional resource-picker UI modules and UI-facing types
│ ├── <name>.ts # Vendor, UserAccount, UserImpl, GatekeeperImpl, SessionImpl
│ ├── types.d.ts # Session/Hook types (compile-time)
│ ├── types.txt -> types.d.ts # Symlink (runtime, for getTypeScriptTypes())
│ └── <name>-api.ts # (optional) Helper wrapping the service's HTTP API
├── cloudflare.config.ts # wrangler.jsonc is generated from it
├── package.json
└── tsconfig.json
```
#### Authorizing observations
Any operation that returns information to its caller must await `authorizeObservation()` with explicit `subresources` before returning, including cached data, simulated reads, cursor pages, and hook deliveries. Study the `ApprovalQueue` API in `gatekeeper.ts` for details.
##### What counts as an observation
The observation log records what the **caller** (agent or gadget) learned, not what the gatekeeper read. Deciding this correctly keeps the log readable for the user. Ask: *what information did this call hand back to the caller?* If the answer is "nothing", don't log an observation, however much the gatekeeper fetched internally.
Do **not** call `authorizeObservation()` for:
- **Reads that only prepare an action.** A mutation that fetches current state to build the action description, bind an expected old value, capture revert information, or validate the request, and then calls `submitAction()`, returns `void` (or a provisional stub or a freshly generated ID). The action description the user approves already shows what was read. Don't log "Read X before mutation".
- **Session setup.** `startSession()` resolving the account, a bound label, and so on returns only a session stub.
- **Creating a cursor or stub that returns no data itself**, provided its later reads (cursor pages, `getDetails()`, …) log their own observations. Log wherever the data actually crosses to the caller; if a cursor logs once up front and not per page, that up-front log is the real one and must stay.
- **Information that leaks only through a validation error** ("label already exists", "branch is not a fast-forward").
**Existence checks are a judgment call, but usually not an observation.** A method like `getIssue(id)` that confirms an item exists and returns a stub tells the caller one bit. When the ID is an unguessable, high-entropy value (a Gmail message ID, a commit SHA, a UUID), that bit is no information at all: a caller that already holds the ID knows the item exists, and the ID itself encodes nothing. Sequential numbers and human-readable names are more ambiguous, but an agent leaking information by probing whether titles exist is far-fetched, so the default is not to log. Log the stub's real reads instead. Don't put fetched details (such as the item's title) into a log entry for data the caller never received.
Returning a boolean or count *is* data when that is the answer the caller asked for (`isFollowing()`, `areTracksSaved()`, a `null` from `getArea()` meaning "no area"). Log those.
**Exception: access enforcement.** Some observation fields do more than log. `subresources` is what collaborators are verified against, and `containsRestrictedData` / `ownerInvitesOnly` change workspace state. Removing a call that carries these controls (for `subresources`, a non-empty list) is a security decision, not a logging cleanup (see the Drive session's "Check Google Drive folder" fences). Existing legacy code may also enforce security through `excludeObservers` and tracking side effects; preserve those until its migration covers them.
### Step 5: Register
There is no binding to add. `run-dev-server.ts` binds every `packages/gatekeeper-*` that has a `wrangler.jsonc` to the backend as `GATEKEEPER_<NAME>`, and the deploy service does the same for installed gatekeepers. The backend auto-discovers vendors from `GATEKEEPER_`-prefixed bindings (see `packages/workshop-backend/src/user.ts`).
### Step 6: Add resource selection UI
Add a resource selection UI for each connect card. A card is one resource type returned in `getSupportedResources()`, together with any types whose `groupedWith` names it. This will be used by users to select the specific resource.
- Workshop calls `GatekeeperUser.startResourceConfigurator(resourceUrlPattern)` with the `urlPattern` of a type on the card: the card's own type from the connect dialog, or a binding's own type from a blueprint. Return the card's configurator for every type on it.
- Return `iframeHtml` of the selection UI and `ui` for any RPCs that UI needs.
- When the user selects "Add connection", Workshop asks the iframe for the selected resource URL.
- Group types (`SupportedResource.groupedWith`) when they are one choice found in one place, such as a whole drive, a folder, a file, or a document in it. Each type is still granted separately, and a grouped card asks for the missing ones in one consent request, so a user connecting any type on the card is asked for every type on it; group types that need different authorization only when that is acceptable. The gatekeeper's `getGatekeeperClassFor` decides which type a returned URL is, while Workshop's grant check takes the first type in declaration order whose pattern matches it, so declare a narrower type before a broader one whose pattern also matches its URLs. A whole-instance `https://*` type is the exception: the check skips it and uses it only for a URL no other type on the card matches, so it can be declared anywhere.
Keep the iframe-facing capability narrow, only what's necessary to provide desired interface to help user find and select the resource.
#### Optional helper: `@gadgets/configurator-ui`
For configuration UIs, use `@gadgets/configurator-ui`. It provides form components built on Kumo (rendered with Preact standing in for React inside the frame) that look and behave like the rest of the Workshop, and a build that turns `src/configurator/*-ui.tsx` into `iframeHtml`. Gatekeepers whose needs the components cannot meet can produce their own `iframeHtml`.
Components: `Section`, `Field`, `TextInput`, `RadioCards`, `CheckboxList` (multi-select; refuses more than 200 options rather than truncating), `Autocomplete` (search-as-you-type, one result), `HierarchyPicker` (choose the whole hierarchy from a card above the list, or an open container or a leaf from its own row; as in a file dialog, a choice lasts while it is in view; a container's `choice` says what connecting it grants, since only the gatekeeper knows whether that is its whole subtree, and a node's `unselectableReason` says why it cannot be connected; breadcrumbs with a back button, optional search, paging and `loadItem`, which pre-filling anything but the root needs; optional `tabs` split what lies below the root into top-level containers or views, each listed with its id as `parentId` and heading the `path` of every node under it, and offered as a choice only with `selectable: true`; nodes name icons the configurator supplies in `icons`, and may carry a `badge`, at the end of the title's line, and a `meta`, at the end of the subtitle's; a location that cannot be listed yet, such as one waiting on a grant, returns a page with `unavailable` set to why, shown with Retry like a failed load but not reported as an error, while a page's `notice` tells the user something about a listing it can show, such as that it is partial or why it is empty), `Button` (an action in the form; call the render context's `ensureResources` from its click to ask for access the next step needs) and `Notice` (an inline info or error message, announced to screen readers; its `action` puts the one step the message leads to, such as allowing access, in the same row, so prefer it to a Notice followed by a Button, and keep Buttons for a choice between steps). The runtime strips control and bidi-formatting characters from titles, subtitles and meta it shows, since those come from third parties.
If you use this:
- UI modules live in `src/configurator/*-ui.tsx`.
- `resourceUrl()` returns the selected resource URL.
- A module that can return several types reads `acceptedResourceUrlPatterns` from the context every callback receives (`render`, `isReady`, `resourceUrl`, `initialValuesFromResourceUrl`): the types Workshop will take, which leave out a grouped type the account hasn't granted and anything a blueprint binding doesn't want. Show other types if that helps the user find their way, but don't let them be chosen, and keep `isReady` false while one is. It is `undefined` from an older Workshop, which takes any type. The list is read when the frame starts, and Workshop restarts the frame when it changes.
- `src/configurator/*-types.d.ts` describes the iframe-facing `ui` API.
- Each module is compiled on its own: it may import only `@gadgets/configurator-ui` and types. URL grammar it shares with the server is copied, and a test keeps the copies in step.
- The frame's content security policy allows exactly the scripts the build wrote (by hash) and no network access; anything the UI needs goes through `ui`.
- `scripts/build-gatekeeper-configurator.ts` generates `src/generated/*.txt`, using the runtime and build in `packages/configurator-ui` (`src/runtime/`, `build/`).
- Nothing invokes `build-gatekeeper-configurator.ts` by hand. `vite.config.ts` re-exports the
shared `build` and `build:configurator` Vite+ tasks from
`scripts/gatekeeper-configurator-vite-config.ts`; `build` is just `tsc` and depends on
`build:configurator`, which carries `VITE_FRONTEND_ERROR_REPORTING` in its fingerprint, and
`deploy` runs `vp run --no-cache build:configurator && wrangler deploy` — deploys never replay a
cached artifact. There is no `build` script
and no direct builder call, because a script running the builder gets vp's stripped environment
and bakes the wrong flag into the shipped HTML.
##### Pre-filling the form from a known resource URL
When something already knows the exact resource — most importantly an AI agent's `requestConnection` (which passes a concrete `resourceUrl`) — the configurator should open **pre-filled and editable**, not blank. The runtime handles this for you:
- If your value keys already match the `urlPattern`'s named groups (e.g. pattern `.../area/:areaId` with a value key `areaId`), prefill works automatically — no code needed.
- Otherwise, implement the optional `initialValuesFromResourceUrl({ resourceUrl, resourceUrlPattern, ui })` on your spec to map a concrete URL back to your form values. Keep it pure where possible (parse the URL); it may use `ui` and may be async. Example (GitHub, whose value is `repoFullName` but pattern is `:owner/:repo`):
```ts
initialValuesFromResourceUrl({ resourceUrl }) {
const [owner, repo] = new URL(resourceUrl).pathname.split("/").filter(Boolean);
return owner && repo ? { repoFullName: `${owner}/${repo}` } : {};
},
```
The runtime seeds these values before first render and reflects them in `Autocomplete`/`TextInput`/`RadioCards`/`HierarchyPicker` inputs. Make sure your `resourceUrl(values)` and `initialValuesFromResourceUrl(url)` are inverses so a prefilled form round-trips to the same URL. **Every gatekeeper with selectable resources should support this** so agents can fully pre-configure a connection.
### Step 7: STOP — Ask operator whether to proceed to phase 2
The operator may prefer to implement phase 2 later, perhaps in a new context. Stop here and ask the operator whether to proceed.
## Phase 2: Actions, caching, and simulation
Complete the action half of responsibility 4 and responsibilities 5-6 in this phase, building on the observation authorization and observer checks already protecting the core implementation. These refinements may be implemented in a separate session; access enforcement is not optional in between.
### Actions and approvals
Go through all the API methods and decide where to insert `submitAction()` calls, and confirm every read still authorizes its observation as in [Authorizing observations](#authorizing-observations).
- Any operation which has visible side effects on the world must call submitAction(), and must not actually apply the action until approved.
Study the `ApprovalQueue` API in `gatekeeper.ts` for details.
#### Action kinds and auto-approval
Every action declares an `autoApprovable` verdict, and a kind (`{tag, label}`) whenever the verdict can be `true`; kit definitions always declare both. Nothing auto-applies until the user enables a rule for the kind; the verdict says whether honoring it is safe for *this* action.
**The default is `true`.** Use `false` only when the action:
1. deletes or removes content (its restore counterpart is true);
2. sends content as the user to people outside the bound resource;
3. spends money or billed credits;
4. runs arbitrary code, queries or service calls where one kind covers both benign and destructive effects;
5. passes a governance gate (merge, approving review, push to a default or protected branch, deploy, publish);
6. is barred from automation by provider policy;
7. changes permissions, sharing or membership.
Exceptions, kept `true` by decision although a rule covers them; add one only with the maintainers' agreement:
- Gmail's trash, spam, draft delete and label delete (rule 1): routine mailbox upkeep, and trash and spam are undone in Gmail.
- GitHub's force push to a branch that is neither the default nor protected (rule 1): it has its own `forcePush` kind, which a user enables apart from other pushes.
- X's bookmark, mute and hide (rule 3): one billed write each, and the only X writes its developer policy lets software automate (see `packages/gatekeeper-x/AGENTS.md`).
Every `false` carries a one-line comment naming its rule. Posting into the resource the user bound (its channel, thread or issue) is not rule 2. A package's `AGENTS.md` or README may be stricter (e.g. `gatekeeper-x`); follow it.
View on GitHub