# Gatekeeper protocol

> The interfaces every connector implements: `GatekeeperVendor`, `GatekeeperConnectCallback`, `GatekeeperUser`, `GatekeeperUserVerifier`, and `Gatekeeper<Session>`. Documents `VendorDescription`, `AccountDescription` (`singleton`, `providesUi`, `providesAuth`), `ResourceDescription` and URL-pattern matching, agent catalog limits, and the `autoProvisionsAccount` disabled/optional/enabled mode resolution.

- 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`
- `packages/workshop-backend/src/provisioning-policy.ts`
- `packages/workshop-backend/src/user.ts`
- `packages/gatekeeper-github/src/github.ts`
- `packages/gatekeeper-context/src/context-api.ts`
- `AGENTS.md`

---

---
title: "Gatekeeper protocol"
description: "The interfaces every connector implements: `GatekeeperVendor`, `GatekeeperConnectCallback`, `GatekeeperUser`, `GatekeeperUserVerifier`, and `Gatekeeper<Session>`. Documents `VendorDescription`, `AccountDescription` (`singleton`, `providesUi`, `providesAuth`), `ResourceDescription` and URL-pattern matching, agent catalog limits, and the `autoProvisionsAccount` disabled/optional/enabled mode resolution."
---

`packages/workshop-shared/src/gatekeeper.ts` defines the RPC interface between the Gadgets Workshop and each connector ("gatekeeper"). Every gatekeeper is deployed as an independent Cloudflare Workers application and reaches the Workshop as a service binding named `GATEKEEPER_*`; the vendor id is the lowercased binding suffix (`GATEKEEPER_GITHUB` → `github`, `GATEKEEPER_CONTEXT` → `context`). Communication is JavaScript RPC, so the types in this file are a live protocol contract, not a local abstraction: `WorkerEntrypoint`, `DurableObject`, `RpcTarget`, and `RpcStub` from `cloudflare:workers` appear directly in the declarations.

The design is capability-based. A gatekeeper hands out object references to individual resources — a single Google Doc, a single GitHub repository, issue, or pull request — so the Workshop can grant one gadget access to exactly the things a user selected, rather than to the account as a whole.

## Interface layering

```text
GatekeeperVendor            (WorkerEntrypoint; the service binding itself)
  ├─ getDescription() ............... VendorDescription
  ├─ connect(callback, options) ..... OAuth / connect flow
  └─ createAccount() ................ optional, no user identity
        │
        ▼
GatekeeperUser              (one connected account)
  ├─ AccountDescription ............. singleton? providesUi? granted patterns
  ├─ getAuthenticatedEmail() ........ only when providesAuth
  ├─ getSingletonGatekeeperClass() .. optional
  └─ startAppUi({ isAdmin }) ........ optional management UI
        │
        ▼
Gatekeeper<Session>         (one resource capability)
  ├─ getSession() ................... the object the agent calls
  ├─ getAgentCatalog(request) ....... bounded discovery metadata
  └─ addObserver(...) ............... observation registration

GatekeeperUserVerifier      (re-checks an account still exists / is valid)
GatekeeperConnectCallback   (Workshop-side callback during connect)
```

<Note>
The `GatekeeperVendor` entrypoint is the root of the protocol; `AccountDescription` is attached to the account, not the vendor. That split is deliberate — a vendor may serve many accounts with different declared capabilities.
</Note>

## `VendorDescription`

Returned by the vendor entrypoint and used for display and for policy decisions on the Connectors page.

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

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

<ParamField body="logo" type="AvatarImage">
Service logo. `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">
Two-to-three sentence description of what the gatekeeper enables, shown in the Connectors detail modal.
</ParamField>

<ParamField body="providesAuth" type="boolean" default="false">
True if the connect flow yields a provider-verified email via `GatekeeperUser.getAuthenticatedEmail()`. The Workshop may then offer the vendor as a sign-in method, subject to its own auth allowlist.
</ParamField>

<ParamField body="autoProvisionsAccount" type="boolean">
True if the vendor can mint an account with no OAuth flow via `GatekeeperVendor.createAccount()` and recommends the Workshop auto-provision one account per user.
</ParamField>

<Warning>
`providesAuth` is only a declaration of capability. Whether a vendor is actually accepted as a login method is decided by the Workshop's allowlist, never by the gatekeeper.
</Warning>

## `AccountDescription`

Describes one connected account (`GatekeeperUser`) for display, and declares whether the account provides an agent singleton and/or a management UI.

| Field | Type | Meaning |
|---|---|---|
| `displayName` | `string?` | Non-unique human-readable name, e.g. `"John Doe"`. |
| `uniqueName` | `string?` | Canonical login-form name: an email address or Unix-style username. |
| `avatar` | `AvatarImage` | Account avatar. Required. |
| `grantedResourceUrlPatterns` | `string[]?` | `urlPattern`s of the grantable resource types currently enabled on this account. |
| `singleton` | `{ tsType }?` | The account provides an agent singleton session. |
| `providesUi` | `boolean?` | The account provides a management UI. |

`grantedResourceUrlPatterns` distinguishes resource types that are usable now from those needing an additional grant. When omitted, treat the account as having every resource granted — that covers legacy accounts and gatekeepers with no grantable resource types.

`singleton` and `providesUi` are orthogonal: an account can declare either, both, or neither. In `packages/workshop-backend/src/user.ts` the Workshop never probes for the optional methods; the declaration flags are the gate. Because `getSingletonGatekeeperClass` and `startAppUi` are optional on `GatekeeperUser`, and TypeScript cannot call an optional method on a mapped stub type, `user.ts` views the stub through derived plain shapes:

```ts
// packages/workshop-backend/src/user.ts
type AccountCreatorStub = Required<Pick<GatekeeperVendor, "createAccount">>;
type SingletonAccountStub =
    Required<Pick<GatekeeperUser, "getSingletonGatekeeperClass" | "startAppUi">>;
```

These are derived with `Pick` + `Required` from the source interfaces rather than re-declared, so they cannot drift, and they are intentionally not wrapped in `Service`/`Fetcher` so declared return types (such as `createAccount`'s `Fetcher<GatekeeperUser>`) stay usable as the runtime stub actually behaves.

<Warning>
Never hand-write an interface that mirrors an RPC interface plus an `as unknown as` cast. Derive from the real type, as above, or rethink the design. This is an enforced kernel review standard for `workshop-shared` API changes.
</Warning>

### Management UI

A management UI is hosted at `/gatekeepers/$appId`, where `$appId` is the vendor id — e.g. `/gatekeepers/context` — and is opened via `startAppUi({ isAdmin })`. The `AppUiContext` is supplied fresh on every open rather than baked into the account, because a user's admin status can change over time:

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

## Resource descriptions and URL-pattern matching

A gatekeeper's resources are described by `ResourceDescription` / `SupportedResource`, and each grantable resource type carries a `urlPattern`. The pattern is the identity used across the protocol: `AccountDescription.grantedResourceUrlPatterns` lists the granted types by `urlPattern`, and the Workshop's admin config disables resources by the same key (`disabledResources`, consumed through `filterEnabledResources` / `isResourceDisabled` in `admin-config.ts`).

The GitHub connector shows the shape a multi-resource vendor takes. It declares three resource kinds and a configurator UI per kind:

```ts
// packages/gatekeeper-github/src/github.ts
type ResourceKind = "repo" | "issue" | "pull";
type EntityKind = "issue" | "pull";

type GitHubGatekeeperImplProps = {
  userObjectId: string;
  resourceKind: ResourceKind;
  owner: string;
  repo: string;
  issueNumber?: number;
};
```

Each kind ships a `ResourceConfiguratorFrame`-backed UI (`GitHubRepoConfiguratorUI`, `GitHubIssueConfiguratorUI`, `GitHubPullRequestConfiguratorUI`), built into `src/generated/*-configurator-ui.txt` by `scripts/build-gatekeeper-configurator.mjs` during the package build. The type-only helpers those modules compile against live in `packages/configurator-ui`.

## Sessions and pagination

`Gatekeeper<Session>` is the per-resource capability: it exposes the session object the agent actually calls. Multi-item reads use `Cursor<T>`, an RPC object rather than an array:

```ts
// packages/workshop-shared/src/gatekeeper.ts
export interface Cursor<T> {
  next(): Promise<T[] | null>;
}
```

Call `next()` repeatedly on the same cursor for subsequent batches; it returns `null` once exhausted. Dispose the cursor when finished.

## Agent catalog

`Gatekeeper.getAgentCatalog()` returns bounded discovery metadata so an agent can see *what* is reachable through a session — for example the titles of the Context Library collections it can search — without reading everything first. The catalog is injected into the agent's context as untrusted data: 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 };
```

| Constant | Value | Applies to |
|---|---|---|
| `AGENT_CATALOG_MAX_ENTRIES` | `25` | `entries.length` |
| `AGENT_CATALOG_MAX_ID_LENGTH` | `256` | `entry.id` |
| `AGENT_CATALOG_MAX_TITLE_LENGTH` | `100` | `entry.title` |
| `AGENT_CATALOG_MAX_DESCRIPTION_LENGTH` | `400` | `entry.description` |

The Workshop enforces these caps regardless of what the gatekeeper returns. Gatekeepers should not hand-roll the limits — call `boundAgentCatalog()`, which clamps the count to `min(request.limit, AGENT_CATALOG_MAX_ENTRIES)`, truncates each field to its cap, and sets `truncated` when entries were dropped:

```ts
export function boundAgentCatalog(
    entries: AgentCatalogEntry[], request: AgentCatalogRequest): AgentCatalog {
  let requestedLimit = Number.isFinite(request.limit) ? Math.max(0, Math.floor(request.limit)) : 0;
  let limit = Math.min(requestedLimit, AGENT_CATALOG_MAX_ENTRIES);
  return {
    entries: entries.slice(0, limit).map(entry => ({
      id: entry.id.slice(0, AGENT_CATALOG_MAX_ID_LENGTH),
      title: entry.title.slice(0, AGENT_CATALOG_MAX_TITLE_LENGTH),
      description: entry.description.slice(0, AGENT_CATALOG_MAX_DESCRIPTION_LENGTH),
    })),
    truncated: entries.length > limit,
  };
}
```

Note the defensive handling of `request.limit`: a non-finite limit becomes `0`, and negative or fractional limits are floored at zero.

`getAgentCatalog` is one of the two session entry points the agent reaches through `executeCode` (`getSession` being the other); each read is recorded as an observation.

## Auto-provisioned (ambient) gatekeepers

A vendor that sets `VendorDescription.autoProvisionsAccount` can mint a connected account with no OAuth flow through `GatekeeperVendor.createAccount()`, which takes no user identity. The Context Library (`GATEKEEPER_CONTEXT`) is the reference example: `createAccount()` returns a `ContextAccount` that keys its private data by its own generated `accountId`, and the account exposes `getSession()`, `getAgentCatalog()`, and `startAppUi({ isAdmin })`.

The Workshop persists such an account in the user Durable Object like any other connected account. The account capability — not an asserted identity — is the authority from that point on.

### Mode resolution

Per-vendor availability is a three-state admin decision stored in `AdminConfig.ambientGatekeeperModes` and resolved in `packages/workshop-backend/src/provisioning-policy.ts`.

| Mode | Behavior |
|---|---|
| `disabled` | Not available; no account is provisioned, and any existing one stays dormant. |
| `optional` | **Default.** Users opt in from the Connectors page; not forced on anyone. |
| `enabled` | Auto-provisioned for every user (forced); users cannot remove it, and it is hidden from the Connectors list. |

```ts
// packages/workshop-backend/src/provisioning-policy.ts
export const DEFAULT_AMBIENT_GATEKEEPER_MODE: AmbientGatekeeperMode = "optional";

export function ambientGatekeeperMode(config: AdminConfig, vendorId: string): AmbientGatekeeperMode {
  return config.ambientGatekeeperModes?.[vendorId.toLowerCase()] ?? DEFAULT_AMBIENT_GATEKEEPER_MODE;
}

export function shouldAutoProvisionAccount(config: AdminConfig, vendorId: string): boolean {
  return ambientGatekeeperMode(config, vendorId) === "enabled";
}
```

Two details matter for implementers:

- Lookup is by **lowercased** vendor id, matching the lowercased `GATEKEEPER_*` binding suffix.
- `ambientGatekeeperModes` may be `undefined` on a config persisted before the field existed, so the optional chain plus `??` default is load-bearing, not cosmetic.

These two helpers are the single chokepoint for the decision. `UserDurableObject` reads `AdminConfig` and calls them when provisioning, listing, and surfacing ambient accounts; `user.ts` imports both alongside the gatekeeper types.

<Warning>
The default is `optional` on purpose: ambient authority is not imposed on every user unless an admin explicitly turns it on. A gatekeeper must never assert its own ambience — a resource becomes ambient only through user or admin configuration.
</Warning>

### Ambient account records

An auto-provisioned account is stored with an `autoProvisioned` flag, which protects it from manual disconnect, because deleting one permanently destroys the user's data in that gatekeeper:

```ts
// packages/workshop-backend/src/user.ts
type ConnectedAccountRecord = {
  id: number;
  account: Fetcher<GatekeeperUser>;
  description: AccountDescription;
  vendorId: string;   // Derived from the GATEKEEPER_ binding name (e.g. "google", "email").
  credentialExpiresAt?: Date;    // When credentials are expected to expire, if known.
  credentialsExpired?: boolean;  // Set true by async notification from gatekeeper.
  autoProvisioned?: boolean;
};

export type ProvidedAccountInfo = {
  accountId: number;
  vendorId: string;
  description: AccountDescription;   // carries `singleton` / `providesUi` declarations
};
```

`ProvidedAccountInfo` is what the overseer receives for ambient capsules and catalog assembly, and what the management-UI listing reads.

Credential validity is computed from both signals — an explicit expiry notification from the gatekeeper and a known expiry timestamp:

```ts
function areCredentialsValid(record: ConnectedAccountRecord): boolean {
  if (record.credentialsExpired) return false;
  if (record.credentialExpiresAt && record.credentialExpiresAt.valueOf() < Date.now()) return false;
  return true;
}
```

### Singleton delivery to the agent

When an account declares `singleton: { tsType }`, the Workshop auto-provides it to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's `env` as a named chat binding. The name comes from the gatekeeper's `suggestedBindingName`; the folding happens in `prepareChatBindings` in `overseer.ts`. The agent reads it inside `executeCode` via `getSession` / `getAgentCatalog`, and each read is recorded as an observation.

A singleton is **not** bound to any gadget by default — most gadgets never call it programmatically. The agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it.

## Ownership boundaries

```mermaid
flowchart TB
  subgraph shared["packages/workshop-shared/src/gatekeeper.ts"]
    IFACE["GatekeeperVendor / GatekeeperUser<br/>Gatekeeper&lt;Session&gt; / GatekeeperUserVerifier<br/>GatekeeperConnectCallback<br/>VendorDescription / AccountDescription<br/>ResourceDescription / SupportedResource<br/>Cursor&lt;T&gt; / AgentCatalog + caps"]
  end

  subgraph kernel["packages/workshop-backend (kernel)"]
    USER["user.ts<br/>ConnectedAccountRecord<br/>ProvidedAccountInfo"]
    POLICY["provisioning-policy.ts<br/>ambientGatekeeperMode()<br/>shouldAutoProvisionAccount()"]
    ADMIN["admin-config.ts<br/>ambientGatekeeperModes<br/>disabledResources"]
    OVERSEER["overseer.ts<br/>prepareChatBindings()"]
  end

  subgraph connectors["packages/gatekeeper-* (independent Workers)"]
    GH["gatekeeper-github<br/>repo / issue / pull<br/>+ configurator UIs"]
    CTX["gatekeeper-context<br/>autoProvisionsAccount<br/>singleton + providesUi"]
    MCPGK["gatekeeper-mcp / -mcp-portal<br/>via packages/mcp-shared"]
  end

  subgraph helpers["packages/configurator-ui (type-only)"]
    CFGUI["ResourceConfiguratorFrame helpers"]
  end

  GH -->|implements| IFACE
  CTX -->|implements| IFACE
  MCPGK -->|implements| IFACE
  CFGUI -.->|compiled by build-gatekeeper-configurator.mjs| GH
  USER -->|imports types| IFACE
  USER --> POLICY
  POLICY --> ADMIN
  USER -->|ProvidedAccountInfo| OVERSEER
  USER -->|GATEKEEPER_* service binding| connectors
```

The direction of dependency is one-way: connectors depend on `workshop-shared` types and are reached only through service bindings. They never import kernel internals, and they cannot influence provisioning policy.

## Implementation notes for connector authors

<AccordionGroup>
<Accordion title="Import types from the gatekeeper subpath">
Connectors import the protocol from `@gadgets/workshop-shared/gatekeeper` as `type`-only imports where possible. The GitHub connector's import list is the canonical example: `ApprovalQueue` is a value import while `Gatekeeper`, `GatekeeperUser`, `GatekeeperUserVerifier`, `GatekeeperVendor`, `AccountDescription`, `VendorDescription`, `GatekeeperConnectCallback`, `GatekeeperConnectOptions`, `ResourceDescription`, `ResourceConfiguratorFrame`, `SupportedResource`, `ActionDescription`, and `Cursor` come in as types. It aliases the entrypoint interface (`GatekeeperVendor as GatekeeperVendorIface`) so the local class can keep the plain name.
</Accordion>

<Accordion title="Own your Durable Objects and namespace by sharing domain">
A gatekeeper owns its own state. `gatekeeper-context` uses three Durable Objects — `ContextCollectionDurableObject` for content, `UserLibraryDurableObject` for each account's private collections, and `LibraryRegistryDurableObject` for the domain's public set — plus a KV namespace. All data is namespaced by a `sharingDomain` taken from the binding's props (see `domain.ts`), so multiple Workshops sharing one gatekeeper instance stay isolated. In `context-api.ts` the namespacing is explicit at every stub lookup, e.g. `this.collections.idFromName(domainName(this.domain, id))`.
</Accordion>

<Accordion title="Authorize reads and writes separately">
`ContextApiImpl` splits authorization into `#assertCanRead` (own private collections or any public collection) and `#assertCanWrite` (own private collections, or public collections for admins). Both resolve ownership and public status concurrently and throw the same opaque message — `"Collection not found or you don't have access."` — so a failed check does not disclose existence. Admin-only operations go through a separate `#assertAdmin()`, and optional-binding features guard with `#assertArtifactsAvailable()`.
</Accordion>

<Accordion title="Use a structured logger with component and vendorId">
Each connector creates a logger tagged with its component and vendor id, e.g. in `packages/gatekeeper-github/src/github.ts`:

```ts
const VENDOR_ID = "github";
const logger = obsContext.createLogger({
  component: "gatekeeper.github", vendorId: VENDOR_ID,
});
```
</Accordion>

<Accordion title="Keep OAuth requests inside the SSRF-checked fetch">
For the MCP connectors, OAuth uses the official `@modelcontextprotocol/client`, and every SDK OAuth operation must be given `sdkFetch(...)` so all requests and redirects retain endpoint and SSRF checks.
</Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
<Card title="Build a gatekeeper" href="/build-a-gatekeeper">
Add a connector package: implement `GatekeeperVendor`, declare descriptions, own your Durable Objects, and expose a session.
</Card>
<Card title="Configure gatekeeper credentials" href="/configure-gatekeeper-credentials">
Register a third-party OAuth app and wire `CLIENT_ID` / `CLIENT_SECRET` into a connector.
</Card>
<Card title="Observations, actions, and approval queues" href="/observations-and-actions">
The read/write split behind `ApprovalQueue`, `ActionDescription`, and the MCP `readOnlyHint` trust boundary.
</Card>
<Card title="Sharing, roles, and observer re-verification" href="/sharing-and-observers">
`Gatekeeper.addObserver()`, verifier minting, and how a failing re-check blocks new observations.
</Card>
<Card title="Agent runtime and tools" href="/agent-runtime">
How `prepareChatBindings` folds ambient gatekeepers into `env` under `suggestedBindingName`.
</Card>
<Card title="Admin configuration reference" href="/admin-configuration">
`ambientGatekeeperModes`, `disabledResources`, `disabledGatekeepers`, and the `AdminSettings` DO.
</Card>
<Card title="Routing and worker bindings" href="/routing-and-bindings">
How `/gatekeeper/<name>/*` routes are derived by lowercasing `GATEKEEPER_*` env keys.
</Card>
<Card title="RPC API reference" href="/rpc-api-reference">
The Cap'n Web interfaces shared between client and backend, plus stub-disposal constraints.
</Card>
</CardGroup>
