# Build a gatekeeper

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

- Repository: cloudflare/cloudflare-os
- GitHub: https://github.com/cloudflare/cloudflare-os
- Human docs: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd
- Complete Markdown: https://grok-wiki.com/public/docs/cloudflare-cloudflare-os-838773bb92dd/llms-full.txt

## Source Files

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

---

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

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

## Package layout

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

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

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

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

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

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

## Declare vendor, account, and resource descriptions

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

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

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

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

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

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

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

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

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

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

### Account description fields

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

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

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

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

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

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

## Expose a session and its agent catalog

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

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

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

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

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

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

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

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

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

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

## Build a configurator UI

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

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

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

### The module contract

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

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

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

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

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

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

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

### Controls

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

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

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

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

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

### The `ui` capability side

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

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

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

Two details worth copying:

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

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

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

## Frontend error reporting in the configurator

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

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

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

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

## Structured logging

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

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

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

## Ownership boundaries

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

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

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

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

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

## Install the connector

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

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

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

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

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

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

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

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

## Reference notes drawn from an existing connector

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

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

## Related pages

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