{"owner":"cloudflare","repo":"cloudflare-os","hasSkills":true,"totalSkillsCount":37,"totalTokensCount":96602,"categories":["anthropic-skill","root-instruction","plugin-manifest"],"hasMcp":false,"mcpConfig":null,"found":[".agents/skills/frontend-conventions/SKILL.md",".agents/skills/write-gatekeeper/SKELETON.md",".agents/skills/write-gatekeeper/SKILL.md","AGENTS.md","packages/bundled-blueprints/README.md","packages/bundled-blueprints/blueprints/workspace-docs/files/README.md","packages/bundled-blueprints/blueprints/workspace-sheets/files/README.md","packages/bundled-blueprints/blueprints/workspace-slides/files/README.md","packages/bundled-blueprints/libraries/README.md","packages/bundled-blueprints/libraries/sync/README.md","packages/bundled-blueprints/libraries/ui/README.md","packages/gatekeeper-cloudflare/README.md","packages/gatekeeper-confluence/README.md","packages/gatekeeper-context/app/skills/README.md","packages/gatekeeper-email/README.md","packages/gatekeeper-github/README.md","packages/gatekeeper-gitlab/README.md","packages/gatekeeper-google/README.md","packages/gatekeeper-homeassistant/README.md","packages/gatekeeper-kit/AGENTS.md","packages/gatekeeper-kit/README.md","packages/gatekeeper-mcp-portal/README.md","packages/gatekeeper-mcp/README.md","packages/gatekeeper-notion/README.md","packages/gatekeeper-scheduler/README.md","packages/gatekeeper-slack/README.md","packages/gatekeeper-spotify/README.md","packages/gatekeeper-supabase/README.md","packages/gatekeeper-websearch/README.md","packages/gatekeeper-x/README.md","packages/gatekeeper-zoominfo/README.md","packages/integration-tests/README.md","packages/mcp-shared/README.md","packages/observability/README.md","packages/ui/AGENTS.md","packages/workshop-frontend/AGENTS.md","packages/workshop-frontend/README.md"],"skills":{".agents/skills/frontend-conventions/SKILL.md":"---\nname: frontend-conventions\ndescription: Use for creating, modifying, moving, or reviewing React frontend code anywhere in packages/*, including Workshop pages, gatekeeper management apps, shared UI, components, hooks, forms, interactions, styling, accessibility, and frontend tests.\n---\n\n# Frontend Conventions\n\nApply these conventions to the Workshop SPA, gatekeeper management SPAs, and `@gadgets/ui`.\nPackage-level `AGENTS.md` files add product-specific rules but do not replace this guidance.\n\n## Ownership And Organization\n\nOrganize code by product ownership before implementation type. Feature directories own product\nbehavior and may contain components, hooks, tests, and utilities that change for the same reason.\n\nStart directories flat. Do not introduce `components/`, `hooks/`, `helpers/`, or `tests/`\nsubdirectories merely to classify files. Introduce a responsibility-named subsystem directory only\nwhen several files form a coherent unit or the flat directory becomes difficult to scan.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Colocate `*.test.ts(x)` files with their subject. Feature organization does not replace\nthe one-component-per-file model.\n\nKeep product behavior with its product even when another feature consumes it. Promote code only as\nhigh as its ownership requires:\n\n- Code shared within one feature belongs at the nearest common feature directory.\n- Feature-independent code shared across unrelated areas of one app may live in that app's\n  `components/` or `hooks/` directory.\n- Runtime UI shared by independent frontends belongs in `@gadgets/ui`.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase domain behavior.\n\n## Components And Hooks\n\nCreate a separate component when it owns meaningful state, effects, interactions, accessibility\nbehavior, or reusable responsibility; represents a distinct UI concern; or obscures its parent's\nmain flow. Keep small stateless render helpers private until they develop an independent concern.\n\nExtract a hook when it owns a coherent behavior or external synchronization lifecycle, not simply\nto shorten a file. Keep code together when an extracted child would mostly forward markup or depend\non the parent's refs, setters, and synchronization callbacks.\n\nPrefer named arrow-function components and hooks. Type props directly rather than using `React.FC`.\nGive wrappers such as `memo` and `forwardRef` stable DevTools names.\n\n## Component APIs\n\nRepresent props that are valid only together as an object or discriminated union. A controlled\nvalue requires a change callback; otherwise expose an uncontrolled initial value. Do not copy a\ncontrolled prop into local state with an Effect.\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events.\nAdd `children`, slots, variants, `className`, DOM passthrough, and imperative refs only for current\ncallers, not speculative reuse.\n\nUse context for genuinely application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props.\n\n## Kumo And Styling\n\nUse Kumo components and semantic tokens by default. Check Kumo and `@gadgets/ui` before creating a\ncontrol or interaction pattern. A shared Gadgets component should compose Kumo behavior, not merely\nrename or restyle a primitive.\n\nDo not add custom color literals, arbitrary Tailwind colors, feature-local token systems, or local\nreplacements for Kumo surfaces, borders, text, status, focus, and interaction tokens unless the user\nexplicitly requests them. Existing legacy colors are not precedent.\n\nTailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and\ntypography. Use custom CSS only for technical behavior Kumo and utilities cannot express. Global\nKumo token theming is an application-level decision and must not be changed during ordinary feature\nwork.\n\nWhen Kumo is unsuitable, identify the concrete behavioral or accessibility gap before introducing\na shared abstraction.\n\n## React\n\nTreat Effects as synchronization with external systems, not as derived-state machinery or a way to\nsequence user interactions. Calculate render data during render, keep state near its owner, prefer a\ncomponent `key` for identity resets, and use `useSyncExternalStore` for suitable external stores.\n\nEffects that fetch or subscribe must clean up stale work and remain correct when restarted. Avoid\nchains of Effects and do not synchronize two pieces of React state when one can be derived.\n\nDo not add `useMemo` or `useCallback` without a concrete identity or performance need. Preserve\nkeyboard behavior, focus management, accessible names, announcements, and mouse/touch/hybrid input\nparity. RPC stubs must follow the disposal and React state rules in the root `AGENTS.md`.\n\n## Comments\n\nPrefer names and types that communicate intent. Comments should explain non-obvious constraints,\nsecurity or performance reasons, and deliberate departures from conventions. Do not narrate the\nnext line. Remove or update comments when their constraint changes.\n\n## Tests\n\nTests should protect observable behavior, product rules, accessibility, state transitions, races,\nand failure paths. Do not test React, JavaScript, Kumo, or another framework's own behavior merely\nfor coverage. Avoid assertions coupled only to implementation details or trivial passthrough.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage only\nwhen an extraction exposes important previously untested logic.\n",".agents/skills/write-gatekeeper/SKELETON.md":"# Gatekeeper Implementation Skeleton\n\nReplace all `My`/`my`/`MY` prefixes with the service name.\n\n## Main implementation (`src/<name>.ts`)\n\n```typescript\nimport { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from \"cloudflare:workers\";\nimport {\n  GatekeeperUser,\n  GatekeeperUserVerifier,\n  GatekeeperVendor as GatekeeperVendorIface,\n  Gatekeeper,\n  HookController,        // Remove if no hooks\n  HookInitiator,         // Remove if no hooks\n  HookTargetMetadata,    // Remove if no hooks\n  ResourceDescription,\n  ApprovalQueue,\n  ObservationDescription,\n  VendorDescription,\n  GatekeeperConnectCallback,\n  AccountDescription,\n  SupportedResource,\n  ResourceConfiguratorFrame,\n} from '@gadgets/workshop-shared/gatekeeper';\nimport { MySession, MyHook as MyHookIface } from \"./types\";  // Remove MyHook if no hooks\nimport TYPES_CODE from \"./types.txt\";\nimport MY_CONFIGURATOR_HTML from \"./generated/my-configurator-ui.txt\";\n\nconst NONCE_BYTES = 32;\nconst NONCE_LIFETIME_MS = 10 * 60 * 1000;  // 10 minutes\n\nfunction hexEncode(bytes: Uint8Array): string {\n  return [...bytes].map(b => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction generateNonce(): string {\n  return hexEncode(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n  let encoder = new TextEncoder();\n  let bufA = encoder.encode(a);\n  let bufB = encoder.encode(b);\n  if (bufA.byteLength !== bufB.byteLength) return false;\n  return crypto.subtle.timingSafeEqual(bufA, bufB);\n}\n\ntype Env = Cloudflare.Env & {\n  BASE_URL?: string,\n};\n\nfunction getBaseUrl(env: Env) {\n  return (env.BASE_URL || \"http://localhost:8787/gatekeeper/<name>\").replace(/\\/+$/, \"\");\n}\n\nfunction getBasePath(env: Env) {\n  const path = new URL(getBaseUrl(env)).pathname;\n  return path === \"/\" ? \"\" : path;\n}\n\nconst SELF_CLOSING_HTML = `<!DOCTYPE html>\n<html lang=\"en\">\n  <body>\n    <script type=\"text/javascript\">window.close();</script>\n    <p>Authorization complete. You may close this tab and return to the Gadgets Workshop.\n  </body>\n</html>`;\n\nconst MY_RESOURCE: SupportedResource = {\n  urlPattern: \"https://example.com/*\",\n  title: \"My Resource\",\n  description: \"Access a specific resource.\",\n};\n\n// ---------------------------------------------------------------------------\n// HTTP handler — serves the browser-based auth flow.\n// For a complete OAuth example, see gatekeeper-google/src/google.ts.\n\nexport default {\n  async fetch(req: Request, env: Env, ctx: ExecutionContext) {\n    let url = new URL(req.url);\n    let basePath = getBasePath(env);\n    if (!url.pathname.startsWith(basePath + \"/\") && url.pathname !== basePath) {\n      throw new Error(`Request path ${url.pathname} does not match BASE_URL path ${basePath}`);\n    }\n    let relPath = url.pathname.slice(basePath.length);\n    let path = relPath.slice(1).split(\"/\");\n\n    if (path.length === 2 && path[0].length === 64 && path[1].length === NONCE_BYTES * 2) {\n      // Auth initiation: the user has visited the connect URL.\n      let doId = path[0];\n      let nonce = path[1];\n      let stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId));\n      if (!await stub.verifyNonce(nonce)) {\n        // Show a friendly error page for expired/replayed links.\n        return new Response(\"TODO: error HTML\", {\n          headers: { \"Content-Type\": \"text/html; charset=utf-8\" }\n        });\n      }\n\n      // TODO: Redirect to external auth provider, or present an auth form.\n      // For OAuth, generate a second nonce for the `state` parameter here —\n      // see gatekeeper-google for the full pattern.\n      throw new Error(\"TODO: implement auth initiation\");\n    } else {\n      return new Response(\"Not Found\", {status: 404});\n    }\n  }\n};\n\n// ---------------------------------------------------------------------------\n// Vendor — top-level API exposed to the Workshop\n\nexport class GatekeeperVendor extends WorkerEntrypoint<Env> implements GatekeeperVendorIface {\n  async describe(): Promise<VendorDescription> {\n    return {\n      displayName: \"My Service\",\n      url: \"https://example.com\",\n      logo: { url: \"TODO: data URL or hosted image URL\" },\n      color: \"#f0f4ff\",\n      tagline: \"Short summary of what this connector enables\",\n      description:\n          \"Explain what Gadgets can do with this connector in plain language.\",\n    };\n  }\n\n  // This skeleton has no independently grantable resources, so it ignores\n  // `options.resourceUrlPatterns` and stores an empty requested-resource list. Gatekeepers with\n  // grantable resources should follow gatekeeper-google.\n  async connectAccount(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      _options?: {resourceUrlPatterns?: string[]}): Promise<{url: string}> {\n    let userObjectId = this.ctx.exports.UserAccount.newUniqueId();\n    let nonce = generateNonce();\n    await this.ctx.exports.UserAccount.get(userObjectId).setCallback(callback, nonce, []);\n    return { url: `${getBaseUrl(this.env)}/${userObjectId.toString()}/${nonce}` };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserAccount DO — stores per-user credentials (tokens, API keys, etc.).\n// For a full OAuth implementation with two-phase nonces and reconnect support,\n// see gatekeeper-google.\nexport class UserAccount extends DurableObject<Env> {\n  async setCallback(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      nonce: string,\n      requestedResourceUrlPatterns: string[]) {\n    // Self-destruct if the connect flow is never completed.\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.setAlarm(Date.now() + 3600 * 1000);\n    }\n    this.ctx.storage.kv.put(\"callback\", callback);\n    this.ctx.storage.kv.put(\"nonce\", { value: nonce, expiresAt: Date.now() + NONCE_LIFETIME_MS });\n    this.ctx.storage.kv.put(\"requestedResourceUrlPatterns\", requestedResourceUrlPatterns);\n  }\n\n  // Verify and consume the nonce from the initiation URL. Prevents replay.\n  // Returns false if the nonce is invalid or expired.\n  async verifyNonce(nonce: string): Promise<boolean> {\n    let stored = this.ctx.storage.kv.get<{value: string, expiresAt: number}>(\"nonce\");\n    if (!stored || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, nonce)) {\n      return false;\n    }\n    this.ctx.storage.kv.delete(\"nonce\");\n    return true;\n  }\n\n  // Called when the user completes authorization. Store credentials and notify the workshop.\n  async completeConnection(/* auth result params */) {\n    let callback = this.ctx.storage.kv.get<Fetcher<GatekeeperConnectCallback>>(\"callback\");\n    if (!callback) {\n      throw new Error(\"Authorization timed out. Please try again.\");\n    }\n\n    // TODO: Store credentials obtained from the auth flow\n    this.ctx.storage.kv.put<string>(\"credentials\", \"TODO\");\n\n    let props: MyUserImplProps = { userObjectId: this.ctx.id.toString() };\n    try {\n      await callback.complete(this.ctx.exports.MyUserImpl({ props }));\n    } catch (err) {\n      this.ctx.storage.kv.delete(\"credentials\");\n      throw err;\n    }\n  }\n\n  async getAccessToken(): Promise<string> {\n    // TODO: Return a cached short-lived token, refreshing credentials if needed.\n    let token = this.ctx.storage.kv.get<string>(\"credentials\");\n    if (!token) throw new Error(\"No credentials set.\");\n    return token;\n  }\n\n  async alarm() {\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.deleteAll();\n    }\n  }\n\n  async revoke() {\n    // TODO: Revoke credentials with the external service\n    this.ctx.storage.deleteAlarm();\n    this.ctx.storage.deleteAll();\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserImpl — maps resource URLs to gatekeeper DO classes\n\ntype MyUserImplProps = {\n  userObjectId: string;\n};\n\nexport class MyUserImpl extends WorkerEntrypoint<Env, MyUserImplProps>\n                         implements GatekeeperUser {\n  async describe(): Promise<AccountDescription> {\n    // TODO: Fetch account info from external service using stored credentials\n    return {\n      displayName: \"TODO\",\n      avatar: { url: \"\" },\n    };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async startResourceConfigurator(resourceUrlPattern: string): Promise<ResourceConfiguratorFrame> {\n    if (resourceUrlPattern !== MY_RESOURCE.urlPattern) {\n      throw new Error(`Unsupported resource configurator type: ${resourceUrlPattern}`);\n    }\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    let userAccount = this.ctx.exports.UserAccount.get(id);\n    let ui = new MyConfiguratorUI(async () => {\n      // TODO: Return a short-lived token or construct a read-only API client.\n      // Keep this helper private; do not expose it as an RPC method.\n      return await userAccount.getAccessToken();\n    });\n    return {\n      iframeHtml: MY_CONFIGURATOR_HTML,\n      ui: new RpcStub(ui),\n    };\n  }\n\n  async getGatekeeperClassFor(url: string): Promise<{\n    class: DurableObjectClass<Gatekeeper<any>>;\n    resource: SupportedResource;\n  }> {\n    // Parse URL to determine resource type and extract identifiers.\n    // Return a DO class with props baked in via ctx.exports.<ClassName>({props}).\n    // The Overseer will instantiate this class as a facet.\n    let props: MyGatekeeperImplProps = {\n      userObjectId: this.ctx.props.userObjectId,\n      // TODO: resource-specific fields extracted from URL\n    };\n    return {\n      class: this.ctx.exports.MyGatekeeperImpl({ props }),\n      resource: MY_RESOURCE,\n    };\n  }\n\n  async revoke(): Promise<void> {\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    await this.ctx.exports.UserAccount.get(id).revoke();\n  }\n\n  async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> {\n    return {};\n  }\n\n  // Mint a verifier representing this account (see Observers in SKILL.md). The overseer only ever\n  // hands it back to a gatekeeper of THIS vendor, so MyGatekeeperImpl.addObserver may trust it.\n  // For a strategy-D (low-stakes) gatekeeper, MyVerifier has no methods and this still works.\n  async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n    let props: MyVerifierProps = { userObjectId: this.ctx.props.userObjectId };\n    return this.ctx.exports.MyVerifier({ props });\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Verifier — answers \"can this observer access X?\" against the OBSERVER's own credentials.\n// For strategy D, replace this with a no-op public method; an empty WorkerEntrypoint is not registered\n// in ctx.exports. Keep/extend it for strategy B (single-unit ACL) or C (data-set tracking). See SKILL.md.\n\ntype MyVerifierProps = {\n  userObjectId: string;\n};\n\n// A vendor-specific interface adding non-standard methods to the opaque GatekeeperUserVerifier.\n// addObserver casts the Fetcher back to this; the overseer's same-vendor guarantee makes that safe.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    let account = this.ctx.exports.UserAccount.get(\n        this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId));\n    void account; void resourceId;\n    // TODO: query the service with the observer's own token. Return true on success; return false\n    // for access errors (e.g. 401/403/404); rethrow anything else so the open fails loudly rather\n    // than silently denying.\n    return false;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Resource configurator — narrow iframe helper API\n\nconst configuratorTokenGetters = new WeakMap<object, () => Promise<string>>();\n\nclass MyConfiguratorUI extends RpcTarget {\n  constructor(getToken: () => Promise<string>) {\n    super();\n    configuratorTokenGetters.set(this, getToken);\n  }\n\n  async listResources(query: string): Promise<{ value: string; title: string }[]> {\n    let getToken = configuratorTokenGetters.get(this);\n    if (!getToken) throw new Error(\"Configurator is not initialized.\");\n    let token = await getToken();\n    void token;\n    // TODO: Call read-only external APIs and return bounded search results.\n    return [{ value: query || \"example\", title: query || \"Example\" }];\n  }\n\n}\n\n// ---------------------------------------------------------------------------\n// Hook type — remove this section (and the HookController/HookInitiator/HookTargetMetadata\n// imports, the `subscribe` method, the `hookTsType` field, and MyHookControllerImpl) if the\n// gatekeeper doesn't push events.\n//\n// The hook interface from types.d.ts is implemented by the Gadget as an RpcTarget. Intersect it\n// with RpcTarget so it satisfies the HookController/HookInitiator generic constraints (Hook\n// extends RpcTarget).\n//\n// Note that you can also use a plain function as a hook type, e.g. `RpcStub<() => Promise<void>>`.\n// In that case you would not need to merge the type with `RpcTarget`.\n\ntype MyHook = RpcTarget & MyHookIface;\n\n// ---------------------------------------------------------------------------\n// GatekeeperImpl DO — per-resource instance, runs as a facet of the Overseer\n\ntype MyGatekeeperImplProps = {\n  userObjectId: string;\n  // ... resource-specific fields (e.g., documentId, repoOwner)\n};\n\nexport class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>\n    implements Gatekeeper<MySession> {\n\n  async describe(): Promise<ResourceDescription> {\n    return {\n      url: \"TODO: canonical resource URL\",\n      title: \"TODO\",\n      snippet: \"TODO\",\n      suggestedBindingName: \"MY_RESOURCE\",  // Based on type, not instance\n      tsType: \"MySession\",\n      hookTsType: \"MyHook\",  // Remove if hooks are not supported\n    };\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n\n  async startSession(approvalQueue: RpcStub<ApprovalQueue>): Promise<MySession> {\n    return new MySessionImpl(\n      approvalQueue.dup(),  // Always dup() before storing\n      this.ctx,\n      // ... API client, props, etc.\n    );\n  }\n\n  async applyAction(actionId: number): Promise<void> {\n    // Look up the action by ID from this gatekeeper's own storage, then perform it\n    // against the external service.\n    // TODO: Implement action lookup and execution.\n    throw new Error(`Unknown action: ${actionId}`);\n  }\n\n  async rejectAction(actionId: number): Promise<void | { restart?: boolean }> {\n    // Clean up simulation state for this action.\n    // Return { restart: true } if the session can't recover from rejection.\n  }\n\n  revertAction(actionId: number):\n      Promise<void | { message?: string; canRetry?: boolean; restart?: boolean }> {\n    // TODO: Undo the action (look up what was done from own storage)\n    throw new Error(\"Revert not implemented\");\n  }\n\n  // Observers (see SKILL.md \"Observer verification\"). This skeleton shows strategy B — ACL check on\n  // a single atomic resource. The overseer calls addObserver on EVERY open by every observer, so it\n  // re-verifies live access; throw to deny.\n  //   - Strategy A (private-only): `async addObserver() { throw new Error(\"...not shareable...\"); }`\n  //   - Strategy D (low-stakes):   make both methods no-ops.\n  //   - Strategy C (data-set tracking): record observed sets + store verifiers, and route every read\n  //     through an authorizeSetObservation helper that sets `excludeObservers` (see SKILL.md).\n  async addObserver(_id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void> {\n    let verifier = user as unknown as Fetcher<MyVerifierApi>;\n    if (!(await verifier.hasResourceAccess(/* this.ctx.props.resourceId */ \"TODO\"))) {\n      throw new Error(\n        \"This collaborator does not have access to the bound resource, so they cannot observe \" +\n        \"data the Gadget read from it.\");\n    }\n  }\n\n  // Idempotent: ignore unknown ids. A no-op for strategy A/B (nothing is tracked).\n  async removeObserver(_id: string): Promise<void> {}\n}\n\n// ---------------------------------------------------------------------------\n// HookController — remove if the gatekeeper doesn't push events.\n//\n// A WorkerEntrypoint the overseer uses to enable/disable the hook after the user approves it.\n// It is constructed with `props` carrying the specifics of that particular registration, so it\n// needs no other state. If your gatekeeper offers several kinds of hooks, give each its own\n// controller class.\n\n// Bind-time details for a single hook registration, baked into the controller's props.\ntype MyHookProps = {\n  // TODO: e.g. an event kind, a filter, a sub-resource id, etc. — whatever the registration\n  // method received and the controller/event source will need later.\n  filter?: string;\n};\n\ntype MyHookControllerImplProps = MyGatekeeperImplProps & MyHookProps;\n\nexport class MyHookControllerImpl extends WorkerEntrypoint<Env, MyHookControllerImplProps>\n    implements HookController<MyHook> {\n  // Called when the user enables the hook. Store `initiator` somewhere it can be reached when an\n  // event arrives — typically an event-source DO. Don't store other state until now; everything\n  // else is already in `this.ctx.props`. If already enabled, replace the previous initiator.\n  //\n  // `target` identifies where the hook delivers (workspace, and gadget when pinned to one). Store\n  // it too if you display or link to the target; otherwise ignore it, but keep the parameter\n  // declared — RPC argument validation rejects arguments the receiver doesn't declare.\n  async enable(initiator: Fetcher<HookInitiator<MyHook>>, target: HookTargetMetadata): Promise<void> {\n    // TODO: persist `initiator` (e.g. forward it to an event-source DO keyed by props).\n  }\n\n  // Called when the hook is disabled or deleted. Forget the stored initiator and clean up all\n  // related state. May never be called again, though the overseer may later call enable() afresh.\n  async disable(): Promise<void> {\n    // TODO: forget the stored initiator.\n  }\n}\n\n// Event delivery (sketch). When the external event arrives — e.g. in the event-source DO that\n// holds the `initiator` — invoke the hook like so:\n//\n//   async onEvent(initiator: Fetcher<HookInitiator<MyHook>>, event: MyEvent) {\n//     // startHook() begins a fresh session and returns the callback (re-bound to it) plus an\n//     // ApprovalQueue. `using` disposes the result (and its stubs) at end of scope.\n//     using result = initiator.startHook();\n//\n//     // A hook event is almost always an observation. (Register actions too if invoking the\n//     // callback can cause side effects.) Pipeline through the not-yet-resolved promise.\n//     await result.approvalQueue.authorizeObservation({\n//       title: \"TODO: short event summary\",\n//       description: \"TODO: details about the event being delivered\",\n//     });\n//\n//     // Deliver the event to the Gadget's callback.\n//     await result.callback.onMyEvent(event);\n//   }\n\n// ---------------------------------------------------------------------------\n// SessionImpl — the RPC interface exposed to the Gadget\n\nclass MySessionImpl extends RpcTarget implements MySession {\n  #approvalQueue: RpcStub<ApprovalQueue>;\n  #ctx: DurableObjectState<MyGatekeeperImplProps>;\n\n  constructor(\n      approvalQueue: RpcStub<ApprovalQueue>,\n      ctx: DurableObjectState<MyGatekeeperImplProps>) {\n    super();\n    this.#approvalQueue = approvalQueue;\n    this.#ctx = ctx;\n  }\n\n  [Symbol.dispose]() {\n    this.#approvalQueue[Symbol.dispose]();\n  }\n\n  // Example: observation (read). Fetch data, then authorize before returning.\n  async getData(): Promise<string> {\n    let result = \"TODO: fetch from service or cache\";\n\n    await this.#approvalQueue.authorizeObservation({\n      title: \"Read data\",\n      description: \"Fetched data from the service.\",\n    });\n\n    return result;\n  }\n\n  // Example: action (side effect). Submit for approval; do NOT perform here.\n  // Assign a sequential action ID, store the action details in the gatekeeper's\n  // own storage, then submit the ID to the approval queue. If you read current state to build\n  // the description or capture revert info, do NOT log it as an observation: nothing is\n  // returned to the caller.\n  async updateData(newValue: string): Promise<void> {\n    let actionId = /* assign next sequential ID and store action details */ 0;\n\n    await this.#approvalQueue.submitAction(actionId, {\n      title: \"Update data\",\n      description: `Update value to: ${newValue}`,\n      implementsRevert: true,\n    });\n\n    // TODO: Update cache/simulation state so subsequent reads reflect this\n  }\n\n  // Example: hook registration — remove if the gatekeeper doesn't push events.\n  // `callback` is a persistent stub the Gadget created with ctx.restore(). Construct a controller\n  // whose props capture the specifics of THIS registration (e.g. `filter`), then hand it, the\n  // callback, and a user-facing description to the overseer via bindHook(). For multiple hook\n  // kinds, pick the appropriate controller class here. Do NOT store the callback yourself — it is\n  // bound to this session and would be revoked when the session ends.\n  async subscribe(callback: RpcStub<MyHook>, filter?: string): Promise<void> {\n    let controller = this.#ctx.exports.MyHookControllerImpl({\n      props: { ...this.#ctx.props, filter },\n    });\n    await this.#approvalQueue.bindHook(controller, callback, {\n      title: \"TODO: short hook title\",\n      description: `TODO: what events this hook delivers${filter ? ` (filter: ${filter})` : \"\"}`,\n    });\n  }\n}\n```\n\n## `cloudflare.config.ts`\n\n```typescript\nimport {\n  DEFAULT_GATEKEEPER_WRANGLER, OBSERVABILITY, defineGadgetsWorker, type DurableObjectMigration,\n} from \"@gadgets/scripts/worker-config\";\n\nexport default defineGadgetsWorker({\n  name: \"gatekeeper-<name>\",\n  entrypoint: \".wrangler/validate/src/<name>.ts\",\n  compatibilityFlags: [\"allow_irrevocable_stub_storage\"],\n  observability: OBSERVABILITY,\n});\n\nexport const wrangler = DEFAULT_GATEKEEPER_WRANGLER;\n\nexport const migrations: DurableObjectMigration[] = [\n  { tag: \"v0\", new_sqlite_classes: [\"UserAccount\", \"MyGatekeeperImpl\"] },\n];\n```\n\nRun `pnpm configs:generate`; the generated `wrangler.jsonc` is committed and never edited by hand.\n\nOnly Durable Object classes go in `new_sqlite_classes`. `MyVerifier` and `MyHookControllerImpl` are `WorkerEntrypoint`s, so they need no `migrations` entry — but, like all entrypoints, they must be `export`ed from the worker's main module (so `ctx.exports.MyVerifier(...)` resolves). If your hook uses a dedicated event-source DO to hold the `initiator`, add that DO here too.\n\n## Creating the `types.txt` symlink\n\n```bash\ncd packages/gatekeeper-<name>/src\nln -s types.d.ts types.txt\n```\n\n## Resource configurator files\n\nAdd per-resource configurator types and UI modules:\n\n```\nsrc/configurator/my-configurator-types.d.ts\nsrc/configurator/my-configurator-types.txt -> my-configurator-types.d.ts\nsrc/configurator/my-configurator-ui.tsx\n```\n\n`my-configurator-types.d.ts` should describe the iframe helper API. The UI module's own `resourceUrl()` hook returns the resource URL to Workshop.\n\n```typescript\nexport interface MyConfiguratorUI {\n  listResources(query: string): Promise<{ value: string; title: string }[]>;\n}\n```\n\nAdd package scripts:\n\n```json\n{\n  \"scripts\": {\n    \"deploy\": \"vp run --no-cache build:configurator && wrangler deploy\"\n  },\n  \"dependencies\": {\n    \"@gadgets/configurator-ui\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"@gadgets/scripts\": \"workspace:*\"\n  }\n}\n```\n\n`@gadgets/scripts` has to be declared, not just imported. The shared task configs and the\n`gadgets-*` bins that run them belong to that package, and the workspace root depends on it too —\nso its bins are already on `PATH` and a `vite.config.ts` importing it resolves even with no\ndeclaration here. That is a phantom dependency: it works until this package is built somewhere that\ndoes not happen to hoist it.\n\nNo `build` script: `build` is one of the shared Vite+ tasks below, and vp forbids a task and a\nscript sharing a name. Build this one package with `pnpm exec vp run -F <package-name> build` —\n`pnpm --filter` cannot see a task, so `pnpm --filter <package-name> build` reports nothing to run;\nthe workspace-wide `pnpm build` picks it up as usual. `deploy` goes through the task rather than\ncalling\n`build-gatekeeper-configurator.ts` itself, so the codegen command lives in one place and cannot\ndrift from the task that declares its env — `wrangler deploy` stays outside vp, since it has side\neffects and needs real credentials.\n\nAlso add a `vite.config.ts` re-exporting the shared `build` and `build:configurator` Vite+ tasks —\nthe latter is what the `pnpm dev-server` pre-flight builds (`vp run -r --cache build:configurator\n--dev`). Both are tasks rather than package.json scripts so `VITE_FRONTEND_ERROR_REPORTING` is\npassed through and cache-fingerprinted; a `build` script running the builder with `&&` would bypass\nthat and silently bake the wrong value into the shipped HTML (do not add either script back):\n\n```typescript\n// Vite+ per-package settings. The build:configurator task definition is shared by all gatekeepers\n// with a configurator UI and ships as an export of `@gadgets/scripts`, alongside the builder it\n// runs.\nexport { default } from '@gadgets/scripts/gatekeeper-configurator'\n\n// ...or, if the gatekeeper has tests, `withTests` instead: the same settings plus the shared vitest\n// `test` task. Add a `\"test:run\": \"vitest run\"` script too, for iterating without the cache.\nexport { withTests as default } from '@gadgets/scripts/gatekeeper-configurator'\n```\n\nKeep tokens and broad API clients out of public `RpcTarget` properties; use closures, `#private`, or `WeakMap` state and expose only narrow read-only helper methods.\n",".agents/skills/write-gatekeeper/SKILL.md":"---\nname: write-gatekeeper\ndescription: Guides implementation of Gatekeeper Workers that bridge Gadgets to external services. Covers auth, capability-based API design, approval queue integration, caching, and action simulation. Load when creating, modifying, or reviewing the implementation of a gatekeeper.\n---\n\n# Writing a Gatekeeper\n\nA Gatekeeper is a Cloudflare Worker that mediates all access between a Gadget and an external service. It implements a three-tier hierarchy:\n\n- **Vendor** (`GatekeeperVendor`, a `WorkerEntrypoint`) — top-level entry for the service. One per service.\n- **User** (`GatekeeperUser`, a `WorkerEntrypoint` with `ctx.props`) — a human user's authenticated connection.\n- **Instance** (`Gatekeeper<Session>`, a DO facet of the Overseer) — per-resource, per-Gadget binding that provides the Session API.\n\nRead `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces and detailed JSDoc.\n\n## Seven responsibilities\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\n4. **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. 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)).\n\n5. **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.\n\n6. **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.\n\n7. **Observer verification** — When a Gadget is shared, collaborators may \"observe\" data the Gadget previously read through the gatekeeper. The gatekeeper must ensure each collaborator could access that data themselves, via `getVerifier()` / `addObserver()` / `removeObserver()`. The interface methods are mandatory — a gatekeeper won't type-check without them — so include at least minimal versions in Phase 1; but *choosing and implementing the right strategy* is a Phase 2 security concern, like logging/approvals. See [Observer verification](#observer-verification).\n\n## Phase 1: Core implementation\n\nIn the first phase, focus only on responsibilities 1 - 3, though keeping in mind that 4 - 7 will need to be implemented later. Note the observer methods of responsibility 7 (`getVerifier`/`addObserver`/`removeObserver`) are required for the code to type-check, so the skeleton includes minimal versions; you flesh out the actual strategy in Phase 2.\n\n### Step 1: Understand the external service\n\nStudy the service's API docs. Identify:\n- Auth model (OAuth 2.0, API keys, etc.)\n- Resources to expose and what access granularities make sense\n- Which operations are observations (read-only) vs. actions (side effects)\n\n### Step 2: Design the Session types\n\nCreate `src/types.d.ts` defining the Session interface (and Hook interface if the service pushes events — see [Hooks](#hooks-push-notifications)).\n\nBefore 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.\n\nDesign principles:\n- One interface per logical resource type, not a god-object\n- Methods return structured data, not raw API responses\n- 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\n- Simplify API complexities that are not likely to matter to agents and gadgets; design for a more novice user and common use cases\n- Consider what URL patterns `getGatekeeperClassFor()` should match — each pattern maps to a resource granularity\n- Include JSDoc comments; these types serve as the agent's API documentation. See [Documenting the API](#documenting-the-api-typesdts) below.\n\n#### Documenting the API (`types.d.ts`)\n\nThis 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.\n\nDo 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`.\n\nThe 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.\n\n### Step 3: STOP — Present API for review\n\n**Do not proceed without operator approval.**\n\nPresent 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.\n\nAlso 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.\n\n### Step 4: Implement\n\nSee [SKELETON.md](SKELETON.md) for a complete implementation template.\n\nPackage structure:\n```\npackages/gatekeeper-<name>/\n├── src/\n│   ├── configurator/         # Optional resource-picker UI modules and UI-facing types\n│   ├── <name>.ts              # Vendor, UserAccount, UserImpl, GatekeeperImpl, SessionImpl\n│   ├── types.d.ts             # Session/Hook types (compile-time)\n│   ├── types.txt -> types.d.ts  # Symlink (runtime, for getTypeScriptTypes())\n│   └── <name>-api.ts          # (optional) Helper wrapping the service's HTTP API\n├── cloudflare.config.ts      # wrangler.jsonc is generated from it\n├── package.json\n└── tsconfig.json\n```\n\n### Step 5: Register\n\nThere 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`).\n\n### Step 6: Add resource selection UI\n\nAdd 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.\n\n- 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.\n- Return `iframeHtml` of the selection UI and `ui` for any RPCs that UI needs.\n- When the user selects \"Add connection\", Workshop asks the iframe for the selected resource URL.\n- Group types (`SupportedResource.groupedWith`) when they are one choice at different granularities, such as a whole drive, a folder or a file. Each type is still granted separately, and a grouped card asks for the missing ones in one consent request, so group only types that need the same authorization. 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.\n\nKeep the iframe-facing capability narrow, only what's necessary to provide desired interface to help user find and select the resource.\n\n#### Optional helper: `@gadgets/configurator-ui`\n\nFor 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`.\n\nComponents: `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; breadcrumbs, optional search, paging and `loadItem`, which pre-filling anything but the root needs; nodes name icons the configurator supplies in `icons`; 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), `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). The runtime strips control and bidi-formatting characters from titles, subtitles and meta it shows, since those come from third parties.\n\nIf you use this:\n\n- UI modules live in `src/configurator/*-ui.tsx`.\n- `resourceUrl()` returns the selected resource URL.\n- 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.\n- `src/configurator/*-types.d.ts` describes the iframe-facing `ui` API.\n- 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.\n- 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`.\n- `scripts/build-gatekeeper-configurator.ts` generates `src/generated/*.txt`, using the runtime and build in `packages/configurator-ui` (`src/runtime/`, `build/`).\n- Nothing invokes `build-gatekeeper-configurator.ts` by hand. `vite.config.ts` re-exports the\n  shared `build` and `build:configurator` Vite+ tasks from\n  `scripts/gatekeeper-configurator-vite-config.ts`; `build` is just `tsc` and depends on\n  `build:configurator`, which carries `VITE_FRONTEND_ERROR_REPORTING` in its fingerprint, and\n  `deploy` runs `vp run --no-cache build:configurator && wrangler deploy` — deploys never replay a\n  cached artifact. There is no `build` script\n  and no direct builder call, because a script running the builder gets vp's stripped environment\n  and bakes the wrong flag into the shipped HTML.\n\n##### Pre-filling the form from a known resource URL\n\nWhen 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:\n\n- 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.\n- 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`):\n\n  ```ts\n  initialValuesFromResourceUrl({ resourceUrl }) {\n    const [owner, repo] = new URL(resourceUrl).pathname.split(\"/\").filter(Boolean);\n    return owner && repo ? { repoFullName: `${owner}/${repo}` } : {};\n  },\n  ```\n\n  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.\n\n### Step 7: STOP — Ask operator whether to proceed to phase 2\n\nThe operator may prefer to implement phase 2 later, perhaps in a new context. Stop here and ask the operator whether to proceed.\n\n## Phase 2: Logging, approvals, caching, simulation, and observers\n\nIn this phase, we focus on responsibilities 4-7. These are typically added as a second pass, after the core gatekeeper works. They may be implemented in a separate session.\n\n### Logging and approvals\n\nGo through all the API methods and decide where to insert calls to the `ApprovalQueue`.\n\n- Any operation which reads external data and returns it to the caller (but with no side effects) must call authorizeObservation().\n- Any operation which has visible side effects on the world must call submitAction(), and must not actually apply the action until approved.\n\nStudy the `ApprovalQueue` API in `gatekeeper.ts` for details.\n\n#### What counts as an observation\n\nThe 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.\n\nDo **not** call `authorizeObservation()` for:\n\n- **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\".\n- **Session setup.** `startSession()` resolving the account, a bound label, and so on returns only a session stub.\n- **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.\n- **Information that leaks only through a validation error** (\"label already exists\", \"branch is not a fast-forward\").\n\n**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.\n\nReturning 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.\n\n**Exception: observer tracking.** Under observer strategy C (below), `authorizeObservation()` does more than log. Its `excludeObservers` and set-tracking side effects gate which collaborators may see the workspace, and `containsRestrictedData` / `ownerInvitesOnly` change workspace state. A call that carries any of these is a security control, and removing it is a security decision, not a logging cleanup (see the Drive session's \"Check Google Drive folder\" fences).\n\nIt's critically important that you add `ApprovalQueue` to all API operations that interact with the outside world, otherwise the gatekeeper security model is broken.\n\n### Caching\n\nStore fetched data in the gatekeeper's DO storage (`this.ctx.storage`) to avoid redundant API calls. The cache also enables a better API shape when the service's native data model is awkward — e.g., Gmail's list API returns thread IDs without metadata, but with caching the Session can return richer summaries directly.\n\n- Use TTLs or revision IDs to keep the cache fresh.\n- Cache transformed data (e.g., Markdown) rather than raw API responses when the transformation is expensive.\n\nPROTIP: The relatively new API `this.ctx.storage.kv` provides synchronous versions of the traditional Durable Object storage API, e.g. `get` and `put`. Use these instead of the old asynchronous methods. (Note that the synchronous API does not provide \"batch\" versions of `get()` and `put()`, but you don't really need them since simply making multiple calls is efficient.)\n\nPROTIP: `this.ctx.storage.sql` gives you access to a full, private SQLite database. Use this when the full power of SQL is useful, but prefer KV for simple things.\n\nThe full Durable Objects storage API (including synchronous KV and SQLite) is documented at: https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/\n\n### Simulation\n\nWhen `submitAction()` has been called but `applyAction()` hasn't, reads should reflect the pending action. This allows the calling agent or gadget to be unaware of the approvals mechanism, and proceed with follow-on work immediately. The end user is able to approve a whole batch of changes at once, later on.\n\nTwo possible implementation approaches include:\n\n1. **Mutate the cache** — Apply the action's effects to cached data on submit. On `rejectAction()`, invalidate or rebuild the cache. Simple; works well when the cache is already a transformed view. Don't forget to re-apply any queued actions when updating the cache.\n\n2. **Overlay at read time** — Store pending actions separately; merge them into read results on demand. Cleaner separation; better when the overlay logic is straightforward.\n\nChoose based on the service's data model and the complexity of simulating each action type.\n\nKeep in mind that the agent calling the API (or the agent writing a gadget to call it) is generally not aware that actions do not take place immediately. If the simulation is correct, the agent doesn't need to be aware. If the simulation has gaps, you may want to mention it in your API's doc comments, so that the calling agent knows to work around them — but ideally there are no gaps and the calling agent does not need to think about it.\n\nFor concrete examples, see the Google gatekeeper's Google Docs simulation/cache handling and BigQuery dry-run scope enforcement.\n\n### Observer verification\n\nThis is responsibility 7. When a Gadget is shared, each non-owner collaborator becomes an **observer** of every gatekeeper bound to the Gadget, and may see data the Gadget previously read. The gatekeeper's job is to refuse — or forward-restrict — observers who couldn't access that data themselves.\n\nThree methods implement this (full JSDoc in `gatekeeper.ts`):\n\n- `GatekeeperUser.getVerifier()` — mints a `GatekeeperUserVerifier` (a persistent service stub) representing *this* user's account. The overseer mints one per open and **only ever passes it back to a gatekeeper of the same vendor**, so the gatekeeper may trust whatever it learns from it.\n- `Gatekeeper.addObserver(id, verifier)` — must **throw** if the user represented by `verifier` is not allowed to observe everything read through this gatekeeper so far. The overseer calls it on **every open by every authorized observer** (re-verification, so revoked access is caught at the next open); cache as needed if the check is expensive. `id` is an opaque, stable per-(user,gadget) string.\n- `Gatekeeper.removeObserver(id)` — idempotent; drop a tracked observer.\n\n#### The verifier \"non-standard method\" pattern\n\n`GatekeeperUserVerifier` has no methods of its own — it's an opaque token. To actually answer \"can this observer access X?\", define a vendor-specific interface that **extends `GatekeeperUserVerifier`** with your own methods, implement it on a `WorkerEntrypoint` that queries the service using the **observer's own token**, and cast the `Fetcher` back to that interface inside `addObserver`. The overseer's same-vendor guarantee is what makes the cast safe.\n\n```typescript\n// In types/impl: a verifier interface with non-standard methods.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\ntype MyVerifierProps = { userObjectId: string };\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    // Query the service with the OBSERVER's own token (this.ctx.props.userObjectId).\n    try {\n      await myApiForObserver(this.ctx).getResource(resourceId);\n      return true;\n    } catch (error) {\n      // Distinguish \"no access\" from \"transient failure\":\n      //   - auth/permission/not-found (401/403/404) → false (they can't see it)\n      //   - anything else → rethrow, so the open fails loudly rather than silently denying\n      if (isNoAccessStatus(statusOf(error))) return false;\n      throw error;\n    }\n  }\n}\n\n// In GatekeeperUser:\nasync getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n  return this.ctx.exports.MyVerifier({ props: { userObjectId: this.ctx.props.userObjectId } });\n}\n```\n\n`MyVerifier` is a `WorkerEntrypoint`, so it needs **no migration entry**, but (like all entrypoints) it must be `export`ed from the worker's main module so `ctx.exports.MyVerifier(...)` resolves.\n\n#### Choosing a strategy (per resource type / binding)\n\nStrategy is chosen **per `Gatekeeper` DO class / binding**, not per package — one package may use several (e.g. Google: Gmail=A, Doc=B, BigQuery=C).\n\n- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox). For truly sensitive data, also mark each observation with `ObservationDescription.containsRestrictedData: true`: because nobody can open the workspace without passing `addObserver()`, and with strategy A nobody ever does, the first such observation makes the workspace effectively unshareable — and it permanently sets the workspace's `containsRestrictedData` flag, whose restricted mode blocks all actions and public web fetches, so the data cannot leak back out through other gatekeepers.\n- **B — ACL check (single unit).** The binding is one atomic resource; sub-resources inherit its ACL. `addObserver()` calls a verifier method to confirm the observer can access it and throws otherwise; `removeObserver()` is a no-op; nothing is tracked and no `excludeObservers` is ever needed. Use for repo / document / page / team / single-project bindings.\n- **C — Data-set tracking.** The binding spans sub-resources with **distinct ACLs**, and there is a **per-observer access oracle** for each. The DO logs the data sets actually observed and the current observers; `addObserver()` verifies the observer against **every** logged set (plus a coarse membership baseline) and **stores their verifier**; each later observation that first touches a **new** set re-checks all stored observers and sets `excludeObservers` for any who fail. Use for workspace / organization / dataset-spanning bindings.\n- **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier with a no-op public method such as `verify(): void {}` (an empty `WorkerEntrypoint` is not registered in `ctx.exports`). Use when any collaborator may observe (personal, low-stakes services).\n\nThe **B-vs-C decision** (the \"broad binding\" lens): use C only when **both** (1) the binding spans sub-resources with distinct ACLs *and* (2) there's a per-observer oracle to check each against. If one ACL covers everything → B. If there's no oracle → A or D.\n\n#### Implementing strategy C\n\nRoute **every data-revealing observation** through a helper that takes the set id(s) the observation reveals, instead of calling `authorizeObservation()` directly:\n\n```typescript\n// On the Gatekeeper DO. `setIds` are the data sets this observation reveals.\nasync authorizeSetObservation(\n    queue: RpcStub<ApprovalQueue>, setIds: string[], description: ObservationDescription) {\n  const check = setIds.length > 0\n      ? await this.#prepareSetObservation(setIds)\n      : { pendingSets: [], excludeObservers: undefined };\n  await queue.authorizeObservation({ ...description, excludeObservers: check.excludeObservers });\n  for (const setId of check.pendingSets) this.#markSetObserved(setId);\n}\n\nasync #prepareSetObservation(setIds: string[]) {\n  const pendingSets = [...new Set(setIds)].filter(id => !this.#isSetObserved(id));\n  if (pendingSets.length === 0) return { pendingSets, excludeObservers: undefined };\n  // This synchronous state change is visible to addObserver() before verifier RPCs can interleave.\n  for (const setId of pendingSets) this.#markSetPendingIfUnknown(setId);\n  const excluded = new Set<string>();\n  for (const [id, verifier] of this.#listObservers()) {\n    for (const setId of pendingSets) {\n      if (!(await verifier.hasSetAccess(setId))) { excluded.add(id); break; }\n    }\n  }\n  return {\n    pendingSets,\n    excludeObservers: excluded.size > 0 ? [...excluded] : undefined,\n  };\n}\n```\n\nKey points for C:\n\n- **Use two durable states: pending and observed.** Mark unknown sets pending before the first await, recheck pending sets on every retry, and promote them only after `authorizeObservation()` succeeds. A failed authorization leaves them pending. `addObserver()` must check both states and loop until no unchecked sets remain before synchronously storing the verifier; this also closes admission races in either request ordering.\n- **The session impls must route through this helper**, not `approvalQueue.authorizeObservation()`. If sessions hold the raw queue (not the DO), thread a small prepare hook/callback into each session and any sub-sessions it spawns, and expose a completion step that promotes its pending sets after authorization. For a single broad binding the hook is active; for the narrow (B) sibling binding it is absent (passthrough). See Linear/Notion for the shared-session-impl case and Supabase for the context-object case.\n- **One observation may reveal several sets** (e.g. a workspace-wide list whose rows belong to different sub-resources). Pass all of them; union the exclusions over the newly-seen ones. Reads that reveal *no* set (workspace name, member directory, a bare \"open\") pass an empty list and rely on the membership baseline.\n- **`addObserver` baseline:** verify the coarse membership (e.g. same org/workspace) that gates the set-independent reads, then verify each already-observed set, then store the verifier. Fail closed if a needed identity is unknown (e.g. an account connected before you began persisting the workspace id → force a reconnect).\n\n#### `excludeObservers` semantics (why conservative is safe)\n\nWhen `authorizeObservation()` is given `excludeObservers`, the overseer **blocks the observation** if any named observer is still authorized, and only lets it proceed (tearing down the observer) if they've already lost access. So erring toward listing an observer is never a leak — at worst it blocks an observation that could in principle have been allowed. The leak-relevant gate is always the live sharing graph, so stale observer state self-heals on the next open.\n\n## Hooks (push notifications)\n\nSome services can push events to the Gadget (inbound email, webhooks, chat messages, etc.). A gatekeeper exposes this as a **hook**: the Gadget registers a callback, and the gatekeeper later invokes it when an event arrives. Hooks are persistent — they survive across sessions and server restarts — and are subject to the same observation/action approval model as everything else.\n\n`gatekeeper-email` is the canonical reference implementation. Read it alongside the `HookController`, `HookInitiator`, and `ApprovalQueue.bindHook()` JSDoc in `gatekeeper.ts`.\n\n### The pieces\n\n- **Hook interface** (in `types.d.ts`): the methods the Gadget implements to receive events, e.g. `EmailHook.receiveEmail(email)`. It is implemented by the Gadget as an **`RpcTarget`** (or a plain function), *not* a `WorkerEntrypoint`. Reference it from `describe()` via `hookTsType`.\n- **Session method**: a method like `subscribe(callback)` that the Gadget (or, more commonly, an agent in a one-off `executeCode` call) uses to register interest. The `callback` is a **persistent stub** (created by the Gadget with `ctx.restore()`), so it can be stored and re-invoked long after the session ends.\n- **`HookController`** (a `WorkerEntrypoint` you implement): lets the overseer `enable()` / `disable()` the hook. All the state it needs must live in its `props`, so it is constructed via `this.ctx.exports.MyHookControllerImpl({props})` **at bind time**, immediately before calling `bindHook()` — see below.\n- **`HookInitiator`** (provided to you by the overseer): you call `startHook()` on it when an event arrives.\n\n### Lifecycle\n\n1. **Register.** The Gadget calls your Session method (e.g. `subscribe(callback, filter)`). Inside it, construct a `HookController` whose `props` capture the specifics of *this* registration, then call `approvalQueue.bindHook(controller, callback, description)`. The overseer stores the callback and records the hook (initially **disabled**). Do **not** store the callback yourself — it is bound to the current session and would be revoked when the session ends.\n2. **Enable.** When the user approves the hook in the Workshop UI, the overseer calls `controller.enable(initiator, target)`. Store the `initiator` Fetcher somewhere it can be reached when events arrive (e.g. an event-source DO). `target` identifies where the hook delivers (workspace, plus gadget when the hook is pinned to one); persist it alongside the initiator if you display or link to the target — the IDs are fixed when the hook is bound, so there is nothing to refresh. A gatekeeper that doesn't need it still has to declare the parameter, since RPC argument validation rejects arguments the receiver doesn't declare. Avoid storing any other state until enabled; everything else should already be in the controller's `props`.\n3. **Deliver.** When the event occurs, call `initiator.startHook()`. This returns `{callback, approvalQueue}` bound to a fresh session. Call `authorizeObservation()` (a hook event is almost always an observation; register actions too if the callback's return value triggers side effects), then invoke the `callback` to deliver the event to the Gadget.\n4. **Disable / delete.** The overseer calls `controller.disable()`. Forget the stored `initiator` and clean up all related state — `disable()` may never be called again, though the overseer may later call `enable()` afresh.\n\nBecause the callback is a persistent stub tied to a session, the gatekeeper never stores it directly; the overseer hands it back (re-bound to a new session) each time you call `startHook()`. See the SKELETON for the full code shape.\n\n### Documentation\n\nWhen defining a session interface with hooks, it's important to include comments that clearly state when a method expects to be passed a *persistent* stub created with `ctx.restore()`, as opposed to a regular RpcStub. The caller needs to do extra work to make sure the stub they provide you is persistent.\n\n## Reviewing a gatekeeper\n\nReviews tend to stop at general correctness. The most important review is of the agent-facing API, so pressure-test it:\n\n- **Shape.** Read `types.d.ts` as the agent does, as its only documentation. Does the shape make sense? What would you do differently?\n- **User stories.** List the most common things a user would ask an agent to do with this service and walk each through the API. Flag any that are impossible, need sequential round trips that promise pipelining can't remove, or need knowledge the JSDoc doesn't give. Flag dead ends too: anything the grant includes that the agent can't navigate to, such as child pages it can list but not open.\n- **Seams.** Check that resource types, bindings and sessions partition authority along the lines users actually grant:\n  - Each `getSupportedResources()` granularity is a grant a user would make, and together they cover the common ones (broad and narrow).\n  - A narrow binding's session reaches nothing outside its resource: sub-resources come from stubs on their parent, and no method or option field (`parentId`, `projectId`) takes an ID that escapes the binding.\n  - Each method sits on the object whose authority it needs. A cross-resource operation (move, copy, link) must not quietly require, or grant, a broader binding than the user picked.\n  - Each binding's types expose only what that binding can do and stay small, and its observer strategy fits how the binding is partitioned (see [Choosing a strategy](#choosing-a-strategy-per-resource-type--binding)).\n\n## Tips\n\n- `types.txt` must be a **symlink** to `types.d.ts`, never a copy. Because it is delivered verbatim, `types.d.ts` must not import from other modules — inline any shared types (see [Documenting the API](#documenting-the-api-typesdts)).\n- Call `.dup()` on `approvalQueue` stubs before storing in a session, since Cap'n Web automatically disposes all stubs in parameters to an RPC call when the call returns.\n- `suggestedBindingName` in `describe()` reflects the resource **type** (e.g. `\"GMAIL_INBOX\"`), not the specific instance.\n- For read-only or push-only gatekeepers, `applyAction()` / `rejectAction()` / `revertAction()` can simply throw (they'll never be called since the gatekeeper never submits actions).\n- For `WorkerEntrypoint` and `DurableObject` subclasses, pass credentials and resource IDs via `ctx.props`, not constructor arguments. RPC stubs pointing to these types can be stored in long-term storage and restored later, creating a new instance based on the same `props`.\n- If the gatekeeper implements multiple unrelated resource types with disjoint APIs, each may have its own `.d.ts` file, so that the `getTypeScriptTypes()` method of the specific `Gatekeeper` implementation only returns the types that matter for it. The `getTypeScriptTypes()` method on the top-level `GatekeeperVendor` should return the concatenation of all of these.\n- All DO classes must appear in `cloudflare.config.ts`'s `migrations` export under `new_sqlite_classes`.\n- Set a self-destruct alarm in `UserAccount.setCallback()` in case the OAuth flow is never completed.\n- `authorizeObservation()` may be called *after* fetching data (so the description can include details about what was fetched) but must be awaited *before* returning anything to the caller.\n- `getVerifier()` / `addObserver()` / `removeObserver()` are **mandatory** — the gatekeeper won't type-check without them. Even a read-only or push-only gatekeeper needs them (sharing is independent of whether the gatekeeper has actions). Pick a strategy per [Observers](#observer-verification): a low-stakes one can be A or D; otherwise B/C.\n\n## Reference implementations\n\n- `packages/gatekeeper-google/` — OAuth, multiple resource types (Gmail, Google Docs, BigQuery), actions, caching/simulation examples, multiple Session types. **Observers:** all three strategies in one package — Gmail=A (always throw), Doc=B (single-unit ACL via `GoogleVerifier.hasDocAccess`), BigQuery=C (dataset tracking via `hasDatasetAccess`).\n- `packages/gatekeeper-email/` — Hook-based push notifications, no actions, email address claiming. **Observers:** strategy D (low-stakes no-ops + trivial verifier).\n- `packages/gatekeeper-github/` — **Observers:** clean strategy B example — `GitHubVerifier.hasRepoAccess` plus a one-method `addObserver`.\n- `packages/gatekeeper-gitlab/` — A **mirror** of gatekeeper-github for a provider with instance configuration (self-hosted URLs, an optional Cloudflare Access service token on upstream requests) and **rotating credentials** (expiring access tokens, single-use refresh tokens refreshed under a mutex). Reads the same `plans/worktrees.md` §3 git contract through `gatekeeper-kit`'s `git-transport`/`git-objects`/`git-diff`. **Observers:** strategy B with a role-aware probe (`membershipGrantsFullRead`): Reporter membership whatever the visibility, since a public project can still hold confidential issues and internal notes, read from `members/all?user_ids[]=` because the project's `permissions` object omits group-inherited access. Its `plans/gitlab-gatekeeper.md` divergence table is a worked example of porting one gatekeeper's API to another provider's vocabulary.\n- `packages/gatekeeper-supabase/` — **Observers:** strategy C with a per-session context object (`authorizeProjectObservation`) — good when sessions already hold a shared context.\n- `packages/gatekeeper-linear/` & `packages/gatekeeper-notion/` — **Observers:** strategy C where the page/team session impls are shared between the narrow (B) and broad (C) bindings, threading an `observe` hook through sub-sessions; both also handle one observation revealing multiple sets.\n- `packages/workshop-shared/src/gatekeeper.ts` — Canonical interfaces with detailed JSDoc (`getVerifier`, `addObserver`, `removeObserver`, `GatekeeperUserVerifier`, `ObservationDescription.excludeObservers`).\n","AGENTS.md":"This project is building a platform for \"vibe coded\" personal applications and AI agents that run inside a strong sandbox.\n\nThe following files are commonly important to reference:\n\n* packages/workshop-shared/node_modules/capnweb/README.md: Explains how to use Cap'n Web RPC, which is used extensively for client-server communications.\n* packages/workshop-shared/src/api.ts: Defines the RPC API used between the frontend and backend.\n\nThe project structure is:\n\n* packages/workshop-frontend: The Gadgets Workshop UI.\n    * This is a pure single-page app, running entirely client-side.\n    * It speaks to the backend using an RPC API over a persistent WebSocket connection.\n    * Uses React, Kumo UI (https://kumo-ui.com/api/component-registry), Phosphor icons, and Vite.\n* packages/workshop-backend: The Gadgets Workshop server.\n    * Runs on Cloudflare Workers.\n    * This is the **kernel**: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read *every line* of `workshop-backend` and of API changes in `workshop-shared`, so keep diffs here small and elegant. Concretely: doc-comment **every** exported member of the `workshop-shared` public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes \"ambient\" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits so `workshop-backend`/`workshop-shared` can be reviewed apart from UI), since fewer kernel lines = easier review.\n    * Installs the **bundled blueprints** from `@gadgets/bundled-blueprints` on a deployment's first `/api` request, and offers them as the deployment's standard output formats. `scripts/build-bundled-blueprints.ts` generates the gitignored `src/generated/bundled-blueprints.ts` they are installed from, which holds each blueprint's manifest and built files (`BUNDLED_BLUEPRINTS_DIR` overrides the source directory), so `build` and `test` both run the generator first; `pnpm import:bundled-blueprint` updates or adds a blueprint from a Workshop export, of either `.gadget` version. Installing one (`src/bundled-blueprints.ts`) stores it as any published blueprint is stored: a git commit of its files, as a packfile at `<blueprintId>/<commitId>` in R2, named by `metadata.commitId` in KV (see `docs/blueprints.md`). The commit is a *snapshot release* (`buildSnapshotRelease` in `src/blueprint-release.ts`): parentless, with a fixed author, date and message, so it depends on the files alone. Every deployment therefore derives the same commit from the same files, and the installer never reads what was installed before. The cost is that a bundled blueprint's versions are not chained to one another as a published blueprint's releases are. A gadget made from one still takes its updates, merged against the release the gadget last took.\n    * Agent turns emit the Workers trace spans Cloudflare's Agents dashboard reads (`src/agent-tracing.ts`): `invoke_agent` per turn, `chat` per model request, `execute_tool` per tool call, and `tool_approval` when an agent's action waits for a manual decision and when it is approved or denied. They carry token counts, including cache reads and writes, but never prompts, responses or tool data. A span belongs to the invocation that opened it, and once a Durable Object invocation has returned, the next event the object receives ends its trace. So a turn keeps all its spans only while the request that started it (usually the browser session) stays open.\n* packages/bundled-blueprints: The blueprints the deployment ships with, `blueprints/<name>/` holds each blueprint's `blueprint.json` plus its gadget code under `files/`, which may be TypeScript.\n* packages/workshop-shared: Shared API definitions between client and server.\n    * This defines the application's RPC interface.\n    * The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details.\n* packages/configurator-ui: The authoring API (`src/index.ts`, type-only stubs) for optional gatekeeper resource configurator UI modules, the sandbox runtime that renders them (`src/runtime/`: Kumo components on `preact/compat`), and the build that bundles both into each configurator's frame (`build/`).\n    * Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.ts` as part of package builds. Each frame is one self-contained HTML document: the minified runtime, its Tailwind/Kumo CSS, and the module, under a content security policy that admits only those scripts by hash and allows no network access. Kumo and Base UI support only React, so the browser suite (`vitest.browser.config.ts`, Chromium) is what catches an upgrade that stops working on Preact; `build/frame.test.ts` drives a generated frame over the real Cap'n Web host protocol.\n    * `pnpm dev-server` rebuilds a gatekeeper's frames when its `src/configurator/` or the runtime's `src/runtime/` changes; restart it after editing `build/`, which the watcher has already loaded.\n* packages/ui: Shared runtime React UI used across the Workshop and gatekeeper management apps.\n    * Kumo remains the primitive and token foundation. Reusable Gadgets interaction patterns and composed controls belong here rather than being reimplemented in individual apps.\n* packages/gatekeeper-*: Gatekeeper workers for external service integrations.\n    * Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a `gatekeeper-*` package with **no `wrangler.jsonc` is a library, not a worker** (`gatekeeper-kit` here; `gatekeeper-shared` in the internal repo). Deployable discovery is config-gated, not name-gated — `readDeployablePackages` in `scripts/release/manifest-lib.ts` keys solely on the presence of `wrangler.jsonc`, and `run-dev-server.ts` requires it too — so adding one to a library package is what would make it deployable, at which point `workerKind` would classify it a gatekeeper by prefix and the deploy wizard would demand `CLIENT_ID`/`CLIENT_SECRET` for it. `manifest-lib.test.ts` fails first if that ever happens.\n    * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml`, which sends the popup to the Workshop's `/connect/handoff` page, and that page redeems the single-use ticket over the popup's own session together with a per-flow nonce (see `docs/connect-handoff.md`); a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone.\n    * A gatekeeper may declare `VendorDescription.autoProvisionsAccount`: it can mint a connected account with no OAuth flow (via `GatekeeperVendor.createAccount()`, which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — **disabled** / **optional** / **enabled** (default **optional**) — resolved in `provisioning-policy.ts`: `enabled` auto-provisions the account for every user (forced, and hidden from the Connectors list), `optional` lets each user opt in from the Connectors page or during onboarding (where each starts switched on and is provisioned when the user finishes), and `disabled` offers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The **account** (a `GatekeeperUser`) declares in its `AccountDescription` whether it provides an agent **singleton** (`singleton: { tsType }`) and/or a **management UI** (`providesUi`). The Workshop auto-provides the singleton to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's env as a **named chat binding** (named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in overseer.ts) that the agent reads in `executeCode` (`getSession`/`getAgentCatalog`), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it. The UI is hosted at `/gatekeepers/$appId` (the gatekeeper's vendor id, e.g. `/gatekeepers/context`) via `startAppUi({ isAdmin })`. The two are orthogonal — an account can declare either, both, or neither.\n* packages/mcp-shared: Shared implementation behind the two MCP gatekeepers — `gatekeeper-mcp` (endpoints a user pastes) and `gatekeeper-mcp-portal` (one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. See `packages/mcp-shared/README.md` and each connector's README.\n    * The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation, everything else is queued for approval, and auto-*applying* a write additionally requires a `vetted` endpoint — which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`.\n    * OAuth uses the official `@modelcontextprotocol/client`; always give SDK OAuth operations `sdkFetch(...)` so every request and redirect retains endpoint and SSRF checks.\n* packages/gatekeeper-context: The Context Library — a gatekeeper whose account provides a singleton read session + a management UI, for authoring collections of context documents that agents read as observations. Collections have one of two visibilities: **private** (created by one account and shareable with others) and **public** (created/edited only by deployment admins, readable by everyone and auto-enabled for all users; not shareable). It owns its state in three Durable Objects (`ContextCollectionDurableObject` for content and a private collection's grants, `UserLibraryDurableObject` for each account's index of collections it created or was given, `LibraryRegistryDurableObject` for the domain's public set) plus a KV namespace. All data is namespaced by a `sharingDomain` (from the binding's props, see `domain.ts`) so multiple workshops sharing one gatekeeper instance stay isolated.\n    * Sharing is whole-collection. A private collection's own grants (`viewer` / `editor` / `owner`, keyed by Context account) and its `everyoneCanView` flag are the sole authority, interpreted only by `collection-access.ts`; every listing, agent read, and observer verification re-checks them live, so a library index entry is discovery and never access. A collection always keeps an owner except when its last owner's account is disconnected while others still hold access; its editors then keep editor permissions only (nobody can delete it or grant ownership), and it is deleted once its last grant goes, even if everyone can view it. Everyone-viewable only grants read access (so sharing a workspace that used the collection is not blocked); it never adds the collection to anyone's library. Shares arrive through the Workshop user picker: the management API redeems the ticket and takes the recipient's account IDs from their Context verifiers (`getAccountId`), never from the browser, and grants all of that person's accounts together. Losing write access revokes the git mirror tokens that account minted, and tokens from before sharing count as the creator's; the collection serializes grant changes, token minting, and deletion so none can race another, and retries a failed revocation from an alarm. Disconnecting marks the account's library first, so a share racing it is refused rather than left behind. `plans/context-collection-sharing.md` is the design record.\n    * Its `GatekeeperVendor` entrypoint (bound as `GATEKEEPER_CONTEXT`) declares `autoProvisionsAccount` and mints a `ContextAccount` via `createAccount()` (no user identity is passed in; the account keys its private data by its own generated `accountId`). The account exposes the agent read session (`getSession()`), collection discovery metadata (`getAgentCatalog()`), and a management UI (`startAppUi({ isAdmin })`). The UI is a single-file React SPA in `app/` (Vite + Tailwind + Kumo) bundled by `build-app.ts` into `src/generated/app.txt`.\n* packages/gatekeeper-scheduler: Scheduled Tasks — an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks plus a read-only management UI. One account-scoped `ScheduleDriver` Durable Object stores enabled schedules and delivers them from a shared alarm; hook enablement remains in the Workshop Connections UI.\n* packages/gatekeeper-websearch: Web Search — an auto-provisioned gatekeeper whose account provides an ambient `WEB_SEARCH` singleton that searches the public web through its own Workers AI binding (`websearch()`, through the AI Gateway its `CF_AI_GATEWAY` var names, provider pinned to Ceramic). The deploy service sets that var to the instance's gateway, the backend's; anywhere else it defaults to the account's `default` gateway. Each query is recorded as an observation *before* it is sent. A workspace that has observed restricted data can still search: unlike a `webFetch` URL, the query can only go to the pinned provider. Preinstalled on every deploy like Scheduled Tasks; every search spends the account's AI Gateway credits, outside the model spend limits, unless that gateway holds a Ceramic key, which Ceramic then bills. The README is the design record.\n* packages/gatekeeper-gitlab: GitLab, for gitlab.com and self-hosted instances — a mirror of `gatekeeper-github` (project / issue / merge request resources, the same action queue and read-time simulation, the same worktree contract) in GitLab's own terms. `plans/gitlab-gatekeeper.md` is the design record, and its divergence table lists every place GitLab's API forced a different shape; the README covers instance configuration (`GITLAB_URL`, `GITLAB_API_URL`, and an optional Cloudflare Access service token).\n* packages/gatekeeper-x: X (formerly Twitter) — account / post / List / read-only profile resources over plain `fetch` (the `twitter-api-v2` package breaks on Workers). `plans/x-gatekeeper.md` is the design record. X's API is pay-per-use and bills every resource a read returns, so cost is part of the design and easy to undo by accident:\n    * Every read goes through the gatekeeper's `#read`, which reserves against the connection's daily read limit (`X_DAILY_READ_LIMIT`, counted per UTC day in the `UserAccount` DO across all of the connection's bindings) and settles with what X returned. Listings use `XCursor`, one X request per `next()`, never the kit's `TokenCursor`, which refills a short page with up to ten more billed requests.\n    * Only bookmark, mute and hide are `autoApprovable`: X's developer policy forbids automating likes, follows, reposts and posts. Do not widen that list.\n    * A connection is pinned to the X user it was made as (a reconnect as anyone else is refused and its new grant revoked), and actions are fenced on that X user's ID rather than the connection generation, so a same-user reconnect to widen scopes does not strand pending actions.\n    * Publishing is `claimBeforeApply`, and a send whose outcome X never reported is reconciled inline on the next apply (an attempt marker, then a look at the account's posts from around the attempt, binding only a unique match on text, links, media and reply parent), because the kit's `ActionOutcomeUnknownError` is terminal. Creating a List is reconciled the same way. Rejecting a publish or List creation that may already be on X is refused.\n    * X is renaming \"tweet\" to \"post\" on the wire and both spellings occur; `x-normalize.ts` reads both. The configurators copy URL grammar from `x-urls.ts`, and `__tests__/configurator-url.test.ts` keeps the copies in step.\n* packages/gatekeeper-cloudflare: Cloudflare OAuth, serving three unrelated purposes from one connected account — sign-in (`AUTH_GATEKEEPERS`), AI Gateway billing, and **Workers Observability** read-only telemetry resources. Two resource granularities (whole account, or one Worker) both map to the single indivisible `workers-observability.read` scope, so the capability boundary is the *binding*, not the grant.\n    * Layering: `observability-api.ts` is the only place that talks HTTP; `observability-parse.ts` validates every response (no blind casts); `observability-session.ts` is the agent-facing session, where every read funnels through one `#observe` so no path returns data unaudited; `observability-discovery.ts` derives field names/values from a sampled events query.\n    * Scopes fail closed to `BILLING_SCOPES`, and an omitted `resourceUrlPatterns` (\"all resource types\") is distinct from `[]` (\"none\"): a billing-only connection must not silently acquire telemetry access, and recording a wider grant than was made would make `ensureResources` short-circuit into a binding that 403s with no way to re-prompt.\n    * Three provider behaviours return wrong-but-plausible data with no error, so none is trusted (all verified against a live account): `telemetry/keys`/`values` **ignore** the `filters` they accept, so a constrained discovery call is answered from a filtered `telemetry/query` sample or it would disclose the whole account; an unknown filter key matches nothing, which bites because a log's own fields are returned nested under `source` but indexed under their *bare* name (`{event: \"x\"}` → `source.event`, queried as `event`, hence `observabilityFieldKey` accepting the `source.` alias wherever a caller names a field); and `/accounts` pages at 20 by default, so it must be walked to the end.\n    * Worker-scoped bindings prepend an immutable `$metadata.service` filter *and* re-filter the response, since a filter the provider silently ignored would leak another Worker's telemetry. A dropped event proves the filter was not applied, so the provider's own `count` is then withheld rather than reported (it would count the whole account) and the drop is logged at `error`; `statistics` is kept, since it describes what the query cost rather than how much matched. Pagination cursors come from the provider's raw events rather than the surviving ones, so a fully-foreign page can't stall pagination and hide the caller's own older data. Trace *summaries* are account-only (their shape describes the whole cross-service trace); a Worker binding can still fetch its own events for a known trace id. `calculate()` is the one read with no second line of defence — an aggregate can't be un-mixed — so it rests solely on the injected filter; that is accepted and documented on the method, with the group-by fix left as a follow-up.\n    * A provider error message can quote a caller-supplied filter value back, so only its numeric `codes` are logged — filter *values* stay out of the audit trail (`summarizeFilter`), and the message travels to the caller who caused it.\n    * Tests are two vitest projects: `vitest.config.ts` (Node, pure logic) and `vitest.worker.config.ts` (workerd, for `RpcTarget`/`RpcStub`/Durable Objects). The workerd suite reaches the gatekeeper through a `TestHooks` Durable Object because a `DurableObjectClass` carrying `ctx.props` is only reachable via `ctx.facets` — the way the overseer instantiates it.\n    * `src/configurator/*.tsx` duplicate the resource-URL grammar from `resources.ts` and **must**: `build-gatekeeper-configurator.ts` transpiles each per-file, stripping only `@gadgets/configurator-ui` and type-only imports, so they cannot import runtime helpers. `__tests__/configurator-url.test.ts` keeps the copies in step, and `configurator-fields.test.ts` drives `render` against a mocked runtime — the runtime's `clearFields` only drops an autocomplete's typed query, so a dependent field must *also* be nulled through `setValues` or the stale value silently survives into the resource URL.\n* packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix: `/api/*` and `/blueprint-screenshot/*` to the workshop backend, `/gatekeeper/<name>/*` to whichever gatekeepers are bound (discovered by scanning its own `GATEKEEPER_*` service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (`pnpm dev-server`): with no `ASSETS` binding it proxies frontend requests to the Vite dev server instead.\n\nFrontend conventions (Workshop, gatekeeper management apps, and shared UI):\n\n* IMPORTANT: Load the `frontend-conventions` skill before creating, materially changing, moving, or reviewing React frontend code anywhere under `packages/`. The bullets below are the mandatory summary; the skill contains the complete conventions and examples.\n\n* Organize code by product ownership before implementation type. Keep feature-owned components, hooks, tests, and utilities together; start directories flat and introduce responsibility-named subdirectories only when a subsystem grows.\n* Use PascalCase filenames for components and camelCase filenames for hooks and non-component modules. Colocate `*.test.ts(x)` files with their subject.\n* Keep small stateless render helpers private. Extract a component or hook when it owns meaningful state, effects, interactions, accessibility behavior, or a reusable responsibility.\n* Cross-application UI belongs in `@gadgets/ui`. An app's local `components/` directory is shared only within that app. Product-specific compositions stay with the product even when they use shared primitives.\n* Use Kumo components and semantic Kumo tokens by default. Check Kumo and `@gadgets/ui` before creating a control or interaction pattern. Do not add custom color literals, feature-local token systems, or wrappers that only restyle Kumo.\n* Consult the installed Kumo API before implementing or changing Kumo UI: run `npx @cloudflare/kumo docs <Component>` (for example, `npx @cloudflare/kumo docs Select`).\n* Tailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and typography. Custom CSS is for technical behavior Kumo and utilities cannot express.\n* Represent props that are valid only together as an object or discriminated union. Controlled values require a change callback. Name callbacks `on<Action>` and pass domain values rather than React setters or browser events.\n* Add slots, variants, DOM passthrough, imperative refs, and other customization surface only for current callers. Do not generalize based on speculative reuse.\n* Treat Effects as synchronization with external systems, not as derived-state machinery. Keep state close to its owner, calculate render data during render, clean up subscriptions, and avoid chains of Effects.\n* Prefer named arrow-function components and hooks. Do not add `useMemo` or `useCallback` without a concrete identity or performance need.\n* Preserve keyboard behavior, focus management, accessible names, and announcements when building or extracting interactions.\n* Tests should protect observable behavior, product rules, accessibility, state transitions, races, and failure paths. Do not test framework behavior or implementation details merely for coverage.\n* Prefer code that communicates intent through names and types. Comments should explain non-obvious constraints and reasons, not narrate the next line.\n\nDeployment admin settings (the `/admin` panel) follow a few conventions worth knowing when extending them:\n\n* `packages/workshop-backend/src/storage-schema/admin-settings-storage.ts` defines `AdminConfig` (its helpers live in `src/admin-config.ts`) — the deployment's \"soft\" customizations: agent instructions, banners/theme, and which gatekeeper connectors/resources are offered (plus the three-state mode for auto-provisioning gatekeepers, see `provisioning-policy.ts`). Connectors/resources default to enabled and the admin UI opts them *out*; auto-provisioning gatekeepers default to *optional*. **Authentication/authorization config (sign-in providers via `AUTH_GATEKEEPERS`, password login via `DISABLE_PASSWORD_AUTH`) is deliberately NOT here** — it stays env-var driven (`auth/config.ts`) so it can't be changed by a compromised admin session.\n* The `AdminSettings` durable object owns the authoritative `AdminConfig` and mirrors it to a single reserved KV key (`.adminConfig`, see `isReservedBlueprintKey()`), so hot-path code (connect/agent) reads it with one cheap KV get via `readAdminConfig(env)`. The DO is the only writer (`updateAdminConfig(patch)`).\n* Admin operations are exposed as an `AdminApi` capability obtained via `AuthenticatedApi.getAdminApi()` (returns null for non-admins). The `#isAdmin()` check happens once when the capability is minted, so the individual methods don't re-check.\n* `user.ts:#assertResourceEnabled()` is the single core chokepoint where disabled gatekeepers/resources are enforced before a capability is minted. Both mints call it: `getGatekeeperClassFor()` (gadget/agent code can't reach it directly) and `createResourceGatekeeper()` (reached only through the agent's `createExternalResource` tool).\n* In AI Gateway mode the deployment's models are admin settings too (`AdminConfig.modelModes`, `addedModels`, `modelSettings`, `addedProviders`, `userModelsEnabled`). `GatewayModels` in `ai-gateway.ts` is the only place that merges them with `SUGGESTED_MODELS`, and *disabled* is enforced where a model is about to run: `user.ts:getChatContext()` and `LanguageModelGatekeeper.startSession()`.\n\nWorker configs: each Worker's `cloudflare.config.ts` (`@cloudflare/config` format, built from the shared factory in `scripts/worker-config.ts`) is the source of truth. The `wrangler.jsonc` beside it is committed but generated by `pnpm configs:generate` (`scripts/generate-worker-configs.ts`) and never edited by hand, because Wrangler's new-config support cannot drive the multi-worker dev server, `wrangler types`, the vitest pools or the integration harness. Settings `@cloudflare/config` has no field for (`build`, module `rules`, KV `preview_id`, `assets.directory`) go in the config's `wrangler` export, and Durable Object migrations stay tagged in its `migrations` export, because the release manifest ships them to the deploy service verbatim. `pnpm configs:check` (part of `pnpm lint` and CI) fails on a stale or hand-written file; `pnpm dev-server` regenerates before starting.\n\nRelease pipeline (`scripts/release/`) — how customer instances get deployed:\n\n* `build-release.ts` bundles every deployable worker byte-identically (wrangler dry-run with the pinned wrangler), builds the Access-mode frontend asset build, and generates the release manifest — the contract between this repo's CI and the deploy service, produced by `manifest-lib.ts` from each package's wrangler.jsonc with account-specific values replaced by placeholders (`$ACCOUNT_ID`, `$WORKER_NAME(...)`, `$SECRET(...)`, `$PUBLIC_BASE_URL`, ...).\n* `upload-release.ts` mirrors the release to R2 content-addressed, manifest last; with `--candidate` the manifest lands under `candidates/<id>/` (invisible to the deploy service) so e2e can verify it, and `promote-release.ts` then copies it to `releases/<id>/` — publishing is that single all-or-nothing manifest copy. The copy is not isolated against concurrent promotions, so CI serializes promote runs (a GitLab resource group) and the script's newer-release guard skips candidates that a later release has already superseded.\n* The manifest is covered by a golden-file test; after an intentional manifest change, regenerate with `UPDATE_GOLDEN=1 node --test scripts/release/manifest-lib.test.ts` and review the golden diff.\n* Running the flow by hand (upload and promote need `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`):\n    * `node scripts/release/build-release.ts --out release-out` — build everything into `release-out/` (id defaults to `r<CI_PIPELINE_IID>-<sha7>` in CI, `dev-<timestamp>` locally; override with `--release-id <id>`).\n    * `node scripts/release/upload-release.ts --release release-out --candidate` — mirror to R2; omit `--candidate` to publish directly (bypasses the gate — CI never does this).\n    * `node scripts/release/promote-release.ts --release-id <id>` — copy the verified candidate's manifest into `releases/<id>/`.\n* Deploy-wizard configuration: an installable gatekeeper's user-supplied inputs default to OAuth `CLIENT_ID`/`CLIENT_SECRET` secrets; a per-package `deploy-inputs.json` overrides them, and `NO_DEFAULT_CRED_INPUTS` in `manifest-lib.ts` opts out gatekeepers that take no third-party OAuth app credentials (the wizard blocks Install on unfilled secret inputs, so a spurious default makes a gatekeeper uninstallable). Backend instance-state vars (`ADMINS`, `CLOUDFLARE_OS_DEPLOYMENT`, ...) are injected by the deploy service at PUT time, never manifest-templated.\n\nDevelopment checks:\n\n- Run `pnpm lint` before pushing; it includes script types, generated-config checks, and the workspace build.\n- `pnpm build` runs type-checks and codegen. For one package use `pnpm exec vp run -F <package> build`; most builds are tasks, not package.json scripts.\n- Use `pnpm --filter <package> test:run` while iterating and `pnpm test` for workspace verification. Cached tasks may replay results; cold-checkout tests must generate their own prerequisites on both entrypoints.\n- Cached Vite+ tasks strip undeclared environment variables. Declare output-affecting variables in `cache.env`; a path value does not fingerprint the external files it names.\n- For slow CI/container runs, keep the local watchdog defaults and explicitly choose longer limits. See [testing guidance](docs/testing.md) for commands and timeout policy.\n- Read [build-system guidance](docs/build-system.md) when changing task graphs, codegen, caching, or dev-server startup; [integration testing](docs/integration-testing.md) covers the workerd harness.\n\nIMPORTANT: This repository uses pnpm, not npm. Always use pnpm.\n\nIMPORTANT: Remember when using RPC to use promise pipelining whenever possible. Cap'n Web implements promise pipelining (similar to Cap'n Proto). This means that if an RPC returns a stub, it's not necessary to await the RPC -- the promise itself can be used in place of the stub. Also, Cap'n Web lets you use the promise for a future result (even if it isn't a stub) in the arguments for another call; the promise will be replaced with its resolution on the server side before delivering the arguments. See the Cap'n Web README.md for more details.\n\nIMPORTANT: When using React's useState(), the state value cannot be an RPC stub. At runtime, all stubs appear to be callable (because the system doesn't actually know if the stub points to a function on the server side or not). But the setter returned by useState() has different behavior if passed a function (including any callable object): it calls the function in order to get the state. In order to avoid this problem, whenever a useState() state will contain an RpcStub, it's important to wrap the stub in an object, and set the state to that object instead.\n\nIMPORTANT: RPC stubs must be disposed to prevent resource leaks on the server side. Call `stub[Symbol.dispose]()` when the stub is no longer needed (or use a `using` declaration where possible). In particular, when a React component obtains a stub in a useEffect, the cleanup function should dispose the stub.\n\nIMPORTANT: All RPC interface implementations should use the annotation `@validateRpc()` to apply capnweb-validate, which installs auto-generated runtime type validation matching the interface's TypeScript signatures. Do not write redundant validation code that duplicates the checks capnweb-validate already covers.\n\nIMPORTANT: Server-side logging uses `@gadgets/observability/logger` (frontend browser `console.*` is out of scope):\n- Define a package-owned field type and module-scoped logger with a stable dot-separated `component`\n  and, for gatekeepers, `vendorId`:\n  `const logger = createLogger<GitHubLogFields>({ component: \"gatekeeper.github\", vendorId: VENDOR_ID });`.\n- Emit concrete event names and relevant typed fields, for example:\n  `logger.warn(\"failed to notify credential expiry\", { event: \"credentials.expiry.notify.failed\", error: err });`.\n  Each call emits one indexed object; module/child fields such as `vendorId` are inherited.\n- Use immutable `logger.with(fields)` for object-owned or nearby context. Prefer module/object loggers\n  over logger parameters, and do not replace a shallow child logger with ambient context just to\n  remove a local variable.\n- For bounded operation context needed by deep helpers, independent loggers, or other observability\n  consumers, use `createObservabilityContext` from `@gadgets/observability/observability-context`.\n  Re-establish it per operation;\n  it does not cross RPC, hibernation, or restart, and requires `nodejs_als` or `nodejs_compat`.\n- Pass caught values as `error`. The helper stringifies `Error` instances and primitives, uses an\n  own string `message` for plain objects, omits `undefined`, and adds stacks to all `Error` logs.\n  Keep this normalization deliberately small; do not traverse causes or copy arbitrary properties.\n- Extend field vocabularies locally. Levels: `error` needs attention, `warn` continues best-effort,\n  `info` is notable lifecycle, and `debug` is noisy breadcrumbs. Never log secrets, prompts, headers,\n  tokens, or request/response bodies.\n- To also dispatch a failure to the optional external issue Reporter (in addition to logging it),\n  call `reportIssue(failureSite, caught, options?)` from\n  `@gadgets/observability/error-reporting`. Attach ambient fields from the package's observability\n  context and augment them with capture-site fields:\n  `reportIssue(\"overseer.catalog-fallback\", err, { handled: true, attributes: { ...obsContext.get(), gatekeeperId } });`.\n  It is a no-op when the `ERROR_REPORTER` binding is absent (local dev / deployments without an issue\n  destination). Only bounded scalars are retained as attributes; reported context obeys the same\n  no-secrets rules as log fields.\n- Usage metrics go to the optional `METRICS` Analytics Engine dataset through\n  `@gadgets/observability/metrics`, whose column layout (`metrics-schema.ts`) is also what\n  dashboards query; Workshop product events reach it through `recordAnalytics`.\n\nIMPORTANT: Frontend error reporting is a separate, opt-in path:\n- `@gadgets/error-reporting` owns the vendor-neutral browser/Worker event contract and tolerant,\n  bounded normalization. `VITE_FRONTEND_ERROR_REPORTING=true` enables trusted frontend producers\n  and their hidden source maps at build time; deployments without reporting should leave it unset.\n- The Workshop browser sends best-effort reports to the same-origin `POST /api/client-errors`\n  endpoint. The backend dispatches only when both `FRONTEND_ERROR_REPORTER` and\n  `FRONTEND_ERROR_RATE_LIMITER` are bound; otherwise the endpoint is an intentional no-op.\n- Gatekeeper management/configurator UIs run as Workshop-owned opaque-origin `srcDoc` frames. They\n  send bounded reports with `postMessage`; the host accepts them only from the known frame window\n  with origin `null`, adds host-owned surface/vendor context, and performs the same-origin POST.\n  Do not add direct cross-origin reporting from a gatekeeper Worker domain.\n- Frontend reports never convey authority. `reportedUserId` is supplied by the client and unverified\n  — the name records that it is a report, not a finding — so it is a diagnostic label only and must\n  never be read to make a decision. `pageLocation` is origin and pathname only, rebuilt by\n  `normalizePageLocation` rather than trusted from producers, because a share link's fragment is a\n  bearer capability and an `href` also retains credentials; non-`http(s)` URLs are dropped entirely.\n- Install automatic capture only in trusted first-party surfaces, never gadget/user-authored code.\n  Exception messages and stacks reach the external Reporter, so never intentionally put secrets,\n  prompts, tokens, headers, or request/response bodies in thrown errors or report metadata.\n","packages/bundled-blueprints/README.md":"# Bundled blueprints\n\nThis package holds the blueprints that ship with this repo, the gadget libraries they import, and\nthe build that turns a blueprint directory into the files the Workshop backend installs. A fresh\ndeployment installs the blueprints into BLUEPRINTS KV and BLUEPRINT_CONTENT R2 on its first `/api`\nrequest, after which they are ordinary blueprints, and promotes them as its standard output formats.\n\"Bundled\" is what this package holds; \"format\" is a curation state an admin controls at runtime --\na bundled blueprint can be taken out of the formats menu, and an unbundled one promoted into it.\n\nNothing here is deployed on its own. The Workshop backend's `scripts/build-bundled-blueprints.ts`\nimports `src/` to generate the gitignored `src/generated/bundled-blueprints.ts` it compiles the\nblueprints into, and `pnpm import:bundled-blueprint` (the same package) writes into `blueprints/`.\n\n## Layout\n\n```\nblueprints/<name>/     one bundled blueprint, committed as reviewable source\n  blueprint.json       install ID, presentation, provenance, bindings, version, revision\n  files/               the gadget's code; may contain nested directories\n    README.md\n    client.ts          import { el } from \"@gadgets/bundled-blueprints/libraries/ui/client\"\n    server.ts          import { MutationQueue } from \".../libraries/sync/server\"\n    lib/protocol.ts    the document, operation and RPC types both sides share\n  __tests__/           vitest, repo-only; never part of the blueprint\nlibraries/<name>/      a gadget library: client.ts, server.ts, src/**, __tests__/** (see libraries/README.md)\nsrc/                   the build: source and archive reader (files.ts), manifest parser\n                       (manifest.ts), module generator (generate.ts); index.ts is what the backend imports\n__tests__/             the build's own tests\n```\n\n`files/` is the gadget's code. `blueprint.json` contains its install ID, presentation, provenance,\nbindings, blueprint `version`, and bundled `revision`. The build embeds the manifest and the files,\nas built, in the generated Worker module. No binary archive is committed, and none is built.\n\nInstalling a blueprint makes a git commit of its files, as publishing one from a Workshop does, and\nstores it as a packfile. The commit has no parent and a fixed author, date and message, so it\ndepends on the files and nothing else: every deployment that installs the same files installs the\nsame commit, and a blueprint whose files have not changed is the same commit however often it is\ninstalled. A bundled blueprint's versions are not chained to one another the way a published\nblueprint's releases are: each is a commit on its own.\n\n### Libraries\n\nA blueprint may import the shared **gadget libraries** in `libraries/` (see [its\nREADME](libraries/README.md)) by this package's name and the subpath its `package.json` exports:\n`@gadgets/bundled-blueprints/libraries/<name>/client` from its client, `.../<name>/server` from its\nserver. The build resolves each to the library's entry and inlines what the entry uses into the\nshipped `client.js` / `server.js`, as it does a `lib/` module, so the blueprint stays\nself-contained and a gadget created from it carries its own copy of the library as of its\ninstantiation. A client may not import a library's server side (it would drag a Durable Object into\nthe iframe), and a library, reached by that subpath, is the one thing an import may reach outside\n`files/` for. The importer has to be TypeScript: a JavaScript module ships as written, with nothing\nat runtime to resolve the package name against, so it cannot import a library, and the build rejects\nthe attempt. The Docs, Sheets and Slides blueprints are built on the `ui` and `sync` libraries, with\ntheir own domain code in `files/`.\n\n### TypeScript sources\n\n`files/` may be written in TypeScript: `client.ts` and `server.ts` are the entry points, and\n`lib/**/*.ts` holds the modules they import (by on-disk name, `./lib/protocol.ts`). The build bundles\neach entry with its imports into the `client.js` / `server.js` the blueprint ships -- readable\nrather than minified -- so the installed gadget, and the agent that later edits it, see exactly one\nJavaScript file per side, the same as for a blueprint written in plain JavaScript. Those `lib/`\nmodules are build input only and are not stored, and `.d.ts` files are dropped. The bundled Docs,\nSheets and Slides blueprints are written this way, each with a `lib/protocol.ts` holding the types\nits client and server share (imported type-only, so nothing of it ships).\n\nEverything else under `files/` (the README, assets) passes through unchanged, and may be imported\nfor its contents where the bundler can inline it: JSON is; a stylesheet is not, and\n`import \"./styles.css\"` fails the build (a gadget carries its CSS in the module that injects it). A file a bundle inlined is still shipped, because\nonly TypeScript is build input. A blueprint is written in TypeScript or in JavaScript, not both: a\n`.js` module in a tree that holds `.ts` is rejected, since it would ship as written beside bundles\nit cannot share code with. Non-module files pass through either way.\n\n`cloudflare:workers` is the only import left for the runtime to resolve, and only on the server:\nthe client is loaded as an ES module in a sandboxed iframe with nothing to resolve a bare import\nagainst, and the server as a Durable Object whose module map holds the gadget's own files and whose\nloader supplies no other `cloudflare:` module. Everything else a blueprint imports must be a file it\nowns or a library, so `import \"yjs\"` is a build error rather than a module that goes missing inside\nthe sandbox, and tree-shaking annotations are ignored, so a `\"sideEffects\": false` in an enclosing\npackage.json cannot drop a side-effect-only import from the bundle.\n\nThe build rejects a tree that would otherwise ship something other than what was written: a\nJavaScript module in a TypeScript blueprint, a `package.json` in a TypeScript blueprint (its `browser`\nfield or `imports` map would steer the bundler's resolution), an import of one of the blueprint's\nmodules that the bundler resolved to something other than the module TypeScript resolves it to (a\n`package.json` above the blueprint steering it, or an extensionless file the bundler takes first), a\n`.ts` file outside the entry/`lib/`\nlayout, TypeScript spelled `.tsx`/`.mts`/`.cts`, declarations included (neither runtime has a loader\nfor it), a `lib/` module\nno entry imports, an import that reaches outside `files/` by any path other than a library's exported\nsubpath (a relative or absolute path into `libraries/`, a `src/` module, the package root, a bare\nspecifier some `node_modules` resolves), a library import of the wrong side or of a library that does\nnot exist, a\ndynamic `import()` or `require()` of anything but a string literal, refused before the bundler runs\n(it would leave a computed path unchecked, and expand a template literal or concatenation into every\nfile the pattern matches, wherever that reaches), a module that binds the name `require` (the scan\nreads a bare `require(...)` as the module loader, as the bundler does only while the name is\nunbound), a generated `client.js`/`server.js` that collides\nwith a file or directory already in the tree, or a shipped JavaScript module that imports a library,\nwhich only the bundle can inline. Imports are read from each module's syntax tree with the\nTypeScript compiler, so a comment or a string can neither masquerade as one nor hide one.\n\n### Type checks and tests\n\n```\npnpm exec vp run -F @gadgets/bundled-blueprints build   # the five type-check programs\npnpm --filter @gadgets/bundled-blueprints test:run      # the blueprints', libraries' and build's tests\npnpm --filter @gadgets/bundled-blueprints test:watch\n```\n\n`pnpm build` type-checks all of it, through one config per set of globals -- the sets must not see\neach other, since a Durable Object has no `document`, iframe code cannot import\n`cloudflare:workers`, and neither has Node's `fs`:\n\n| config | covers | globals |\n| --- | --- | --- |\n| `tsconfig.client.json` | every `client.ts` (blueprints and libraries) and what it imports | DOM |\n| `tsconfig.server.json` | every `server.ts` and what it imports | Workers |\n| `tsconfig.tests.json` | the blueprints' and libraries' `__tests__/` and what they import | DOM + Node |\n| `tsconfig.server-tests.json` | `__tests__/server.test.ts` and `__tests__/*.server.test.ts`, the tests of a server side | Workers + Node |\n| `tsconfig.node.json` | `src/` and `__tests__/`, the build and its tests | Node |\n\nEach follows its entries' imports, so a `lib/` or `src/` module is checked under the globals of\nwhichever side imports it, and a module both sides import under both -- which is what keeps a\nshared module honest without forcing a server-only one to compile against the DOM. A library import\nis this package referring to itself by name, which tsc resolves through the `exports` in\n`package.json` with no `paths` block, so a blueprint is checked against the real signatures it\nimports.\n\n### What the build does not check\n\nThe bundler and the type check are kept in step by refusing what would let them diverge, not by\nsupporting every way TypeScript can be written. Write a blueprint the plain way -- `.ts` modules\nunder `files/`, relative imports spelled `./name.ts` or `./lib/name.ts`, libraries by the package\nsubpath -- and stay inside these limits, which the build does not enforce:\n\n- A `/// <reference types=\"…\" />`, `lib=\"…\"` or `path=\"…\"` directive loads types the side's tsconfig\n  deliberately leaves out (`types: []`, one `lib` per side), so `Buffer` type-checks in a client and\n  ships as a free global that throws in the iframe. Do not write one: a gadget module sees exactly\n  the globals its runtime supplies, and the type check is only as honest as that isolation.\n- A tree under `BUNDLED_BLUEPRINTS_DIR` is bundled but not type-checked; the programs above cover\n  this package's `blueprints/` and `libraries/` only. Type-check such a tree in its own workspace.\n- The comment naming each inlined library module is rewritten line by line, so the bundle is the\n  same wherever it is built; a template literal whose own line spells exactly such a path would be\n  rewritten with it.\n\nUnit tests of a blueprint's `lib/` modules, or of its server, live in the blueprint's `__tests__/`\nand run under `pnpm test` (jsdom by default; a pure module's or a server's test declares\n`// @vitest-environment node`). They import the module under test by on-disk name, e.g.\n`../files/lib/protocol.ts`, and load a fixture with `readFileSync`. A test of the server side is\nnamed `server.test.ts` or `<topic>.server.test.ts`, which is what puts it under the Workers types\nrather than the DOM's; at run time it gets `cloudflare:workers` as a stub of its base classes\n(`__tests__/stubs/cloudflare-workers.ts`), so a Durable Object can be constructed over in-memory\nstorage. The blueprints the build produces are still installed and inspected inside workerd by the\nWorkshop backend's suite. Only `blueprint.json` and `files/` are read by the build, so `__tests__/`\n(and anything else beside them) is repo-only and never part of the blueprint.\n\n`blueprintId` is the install key. Never change it after deployment: the new ID would install a\nsecond blueprint while the old one remained. (TODO: the bundled IDs keep the historical `format.`\nprefix -- `format.document`, `format.spreadsheet`, `format.slides` -- for that reason; renaming them\nneeds an install-time migration keyed on the old id.) `version` is the blueprint's published content\nversion, a counter for display: the content itself is stored under the commit of its files. The\nbuild fingerprints the files and the rest of the manifest, so direct edits under `files/` reinstall\nautomatically. `revision` remains an explicit reinstall trigger and is bumped by the importer.\n\n## Editing presentation\n\nEdit `blueprint.json` and rebuild. Changes to title, description, output, or author are included in\nthe install fingerprint and do not need a `revision` bump.\n\n## Updating code\n\nBuild the blueprint in a Workshop, export it, then import the export, from `packages/workshop-backend`:\n\n```\npnpm import:bundled-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document\n```\n\nThe importer replaces `files/` and leaves anything beside `blueprint.json` and `files/` alone,\nupdates archive-owned metadata (`created`, `version`, `lastUpdated`, and `bindings`), bumps\n`revision`, rebuilds the backend's `src/generated/bundled-blueprints.ts`, and reports changed files\nand bindings. An export the build rejects is refused before it replaces\nanything. Review the resulting source diff normally.\n\nAn export is a `.gadget` archive in one of two versions, and the importer reads both. Version 1\nholds a snapshot of the blueprint's files. Version 2, which a Workshop writes for anything published\nsince blueprints became git commits, holds a git packfile, and the importer has `git` unpack it.\nOnly the files are taken from either: the bundled blueprint's commit is made from them at install,\nnot copied from the export.\n\nAn export contains the bundled JavaScript, so importing one over a TypeScript blueprint replaces its\nsources with the built `client.js` / `server.js`. Edit a TypeScript blueprint in the repo instead,\nand use the Workshop only to try the result.\n\n## Adding a format\n\n```\npnpm import:bundled-blueprint ~/Downloads/Brief.gadget --new acme-brief\n```\n\nThis extracts the files and writes a valid scaffolded `blueprint.json`. Before deploying, replace\nthe scaffold description and review `output`. Prefer a generic `output.id` such as `document`; the\nOutputs page uses it to group related formats.\n\n## Shipping your own formats\n\n`BUNDLED_BLUEPRINTS_DIR` points the build at another directory in this same extracted layout. It is\nresolved against `packages/workshop-backend`, where the build runs (the variable used to be called\n`FORMAT_BLUEPRINTS_DIR`; that name is no longer read, so a deployment setting it builds the bundled\nset instead):\n\n```\nBUNDLED_BLUEPRINTS_DIR=../../acme-formats pnpm exec vp run build\n```\n\nThe named directory replaces this set rather than extending it. It can be empty to ship no bundled\nformats. The import command honors the same variable. Keeping deployment-owned formats outside this\nrepo avoids modifying it when it is consumed as a submodule.\n\nA `BUNDLED_BLUEPRINTS_DIR` tree may be written in TypeScript like the blueprints here, and the build\nbundles it the same way: a syntax error or an import that does not resolve fails the build, and it\nimports the gadget libraries by the same package name, which the build resolves to this package's\n`libraries/` without the tree having installed anything. It is not type-checked, though. This\npackage's `tsconfig.*.json` programs are static and cover only `blueprints/` and `libraries/`, so a\ntree elsewhere needs its own `tsc` run in the repository that owns it, with\n`@gadgets/bundled-blueprints` linked into that workspace the way `@gadgets/scripts` already must\nbe, so its editor and `tsc` resolve the same `exports`; or it can stay JavaScript.\n\nDirectories using the previous `<name>.gadget` plus `<name>.json` layout remain supported, so an\nexisting deployment can update this repo without coordinating a format conversion. Importing a new\nexport into one of those entries migrates that pair to the extracted layout automatically. That is\nalso the only way to update one: the layout holds version 1 archives, and the build refuses a\nversion 2 export dropped in a `<name>.gadget`'s place.\n\nAdministrators can also publish and promote ordinary blueprints at runtime instead of rebuilding a\ndeployment.\n","packages/bundled-blueprints/blueprints/workspace-docs/files/README.md":"# Docs — real-time collaborative rich-text Gadget\n\nA single-document, Google-Docs-style rich-text editor with Durable Object persistence, live block updates, collaborator presence, and conflict-safe concurrent editing.\n\n## Shared libraries\n\nThis blueprint is built on the shared gadget libraries in `packages/bundled-blueprints/libraries`\n(see that directory's README). The build inlines what it imports into the `client.js` and `server.js` it ships,\nso a gadget created from it carries its own copy of the libraries as of its creation.\n\n- `@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the `el` element builder, the shared icon table, the\n  toolbar's buttons, groups, colour pickers and dropdown, the in-page prompt that stands in for the\n  sandbox's blocked `window.prompt`, the save-status dot, and the reading and downscaling of a\n  picked, dropped or pasted image.\n- `@gadgets/bundled-blueprints/libraries/sync/client` runs the browser's half of the collaboration loop: the debounced,\n  serialized, retrying save scheduler, the presence roster and its throttled reporter, and the\n  `RpcTarget` the server calls back.\n- `@gadgets/bundled-blueprints/libraries/sync/server` runs the object's half: the mutation queue, the subscriber registry with its\n  presence announcements, and the per-block optimistic concurrency.\n\nEverything on top of them is this gadget's own: the editing commands, the paste sanitizer, the block\nmodel and its ordering rule, where a remote caret is drawn, and the export formats.\n\nIn the repository the source is TypeScript under `blueprints/workspace-docs/files/` (`client.ts`,\n`server.ts`, the shared `lib/protocol.ts` contract and the `lib/markdown.ts` export), which the build\nbundles into the `client.js` and `server.js` this gadget runs.\n\n## Architecture\n\n- **server.js** is the authoritative collaboration coordinator. It stores one atomic `document:v2` snapshot containing the title, global revision, ordered blocks, per-block versions, and modification time.\n- **client.js** builds the entire UI around a `contenteditable` surface. Every top-level document element has a stable `data-block-id`. Local typing is optimistic and changed blocks are sent after a short ~220 ms debounce.\n- Clients subscribe with a Cap'n Web `RpcTarget`. The server broadcasts accepted block operations and ephemeral presence events to every connected client.\n\n## Real-time data model\n\nThe persisted document resembles:\n\n```js\n{\n  revision: 42,\n  title: \"Project plan\",\n  blocks: [\n    { id: \"b_…\", html: \"<h1 data-block-id=\\\"b_…\\\">Project plan</h1>\", version: 3 },\n    { id: \"b_…\", html: \"<p data-block-id=\\\"b_…\\\">Draft…</p>\", version: 8 }\n  ],\n  lastModified: 1700000000000\n}\n```\n\nClients send compact operation batches containing only changed/upserted blocks, version-checked deletions, the current block order, and title. The Durable Object:\n\n1. checks each changed block's `baseVersion`;\n2. accepts non-conflicting changes;\n3. increments accepted block versions and the document revision;\n4. persists the resulting authoritative snapshot; and\n5. broadcasts only the accepted operation.\n\nA remote operation patches only affected DOM blocks. The whole editor is not replaced, so typing and selection in unrelated paragraphs remain stable. Ordering is last-writer-wins, while content and deletion changes use per-block optimistic concurrency.\n\n### Same-block concurrency\n\nA remote update to the block currently being edited is queued rather than replacing the user's caret and draft. If the local block is clean on blur, the remote version is applied. If it is locally dirty, the draft is rebased onto the latest server block version and saved as the next version. Server conflicts return the authoritative block/version, and the client automatically retries its visible local draft—no local text is silently discarded.\n\nThis is block-granularity collaboration rather than a character-level CRDT. It provides smooth simultaneous editing across paragraphs with much less complexity. A CRDT/OT layer would still be required for merged character-by-character edits by multiple people inside the exact same paragraph.\n\n## Presence\n\nSelection changes are throttled to ~70 ms and sent as ephemeral presence messages containing collaborator ID, name, color, and anchor/focus block-and-text offsets. Presence is never persisted. A collaborator appears as a slim named caret; selected text is shown with a subtle translucent highlight, including selections spanning multiple blocks. Both are rendered in a fixed overlay without modifying document HTML. There are no header avatars or active-block outlines. Page close uses a best-effort leave message, RPC disconnects broadcast leave events, and client heartbeats expire stale cursors when browsers do not complete unload networking.\n\nRemote carets and selection highlights live in a separate overlay, so presence decoration never enters the editable DOM or persisted document HTML. Serialization also strips transient local image-selection decoration.\n\n## Programmatic population and backward compatibility\n\nAgents and importers should call `setDocument({ title, blocks, senderId })` to populate or replace the document atomically. It works whether the editor has already been opened or not, persists the full snapshot, increments the revision, and broadcasts the result to connected clients. This avoids requiring callers to choose between initialization and incremental operations.\n\n`initializeBlocks()` remains the idempotent bootstrap/migration API. It also handles the startup race where a newly opened browser creates an empty revision-1 shell just before generated content arrives: that exact untouched shell can be replaced by a non-empty initialization payload. It will not overwrite later empty documents, which may represent an intentional user edit.\n\nIf only the former `content`, `title`, and `lastModified` KV keys exist, the first v2 client loads that HTML, normalizes its top-level nodes, assigns stable block IDs, and calls `initializeBlocks()`. Existing documents therefore migrate without data loss.\n\n## Formatting and media\n\nThe toolbar supports paragraph styles, font family/size, bold, italic, underline, strikethrough, text/highlight color, alignment, lists, indentation, links, images, horizontal rules, clear formatting, and undo/redo. Font sizes are in points, as in Google Docs and Word: body text is 12 pt, in the editor and in the HTML and PDF exports alike, so a sized run keeps its proportion to the text around it.\n\nPasted Google Docs/Word HTML is rebuilt into clean semantic markup. Images from file selection, drag/drop, and clipboard are downscaled and embedded as data URLs. Because collaboration transmits changed blocks, an embedded image is no longer resent when unrelated paragraphs change. A future asset store could replace data URLs with image IDs if very large media-heavy documents are needed.\n\n## Implementation notes\n\n- Durable Object storage is the source of truth; in-memory subscriber and presence maps are intentionally ephemeral.\n- Structural DOM normalization handles root text and `<br>` nodes and repairs duplicate/missing block IDs.\n- Remote ordering moves only blocks that are out of place, avoiding unnecessary selection disruption.\n- `execCommand` is deprecated but remains the compact, broadly compatible formatting mechanism for this sandboxed editor.\n\n## Export formats\n\n- **Markdown** exports the persisted rich-text blocks as Markdown, preserving headings, bold,\n  italic, strikethrough (whether written as tags or as the inline styles the toolbar applies, with\n  an inline style overriding its tag and its ancestors, as on the page), links, images, lists,\n  block quotes, code, horizontal rules and line breaks. A paragraph whose emphasis changes inside\n  a word, where Markdown's asterisks could pair differently than written, writes its emphasis as\n  inline `<strong>`, `<em>` and `<del>` tags instead. Markdown has no syntax for underline,\n  colour, size, font or alignment, so those runs export as plain text. An image exports with its\n  `src`, which for an embedded image is its whole data URL.\n- **HTML** and **PDF** use the editor's print layout and omit editing chrome and presence UI.\n","packages/bundled-blueprints/blueprints/workspace-sheets/files/README.md":"# Sheets\n\nA lightweight, persistent spreadsheet Gadget with a familiar grid interface, formulas, formatting, multiple sheets, and live synchronization.\n\n## Features\n\n- Editable spreadsheet title and multiple sheet tabs\n- Add, rename, duplicate, and delete sheets\n- Cell and range selection with keyboard navigation\n- Formula bar and A1-style references\n- Cross-sheet references such as `Sheet2!A1` and `'Sales Data'!B4`\n- 100+ spreadsheet functions across math, statistics, logic, text, lookup, date/time, and information categories\n- Number, currency, percent, scientific, date, and time formatting\n- Bold, italic, underline, strikethrough, text color, fill color, alignment, and wrapping\n- Row and column insertion, deletion, and resizing\n- Range sorting, AutoSum, copy/paste via TSV, and local undo/redo for cell edits\n- Automatic persistent saving with optimistic per-cell conflict detection\n- Real-time operation and presence synchronization in the server architecture\n- Excel workbook and per-sheet CSV export\n\n## Using the spreadsheet\n\n- Click a cell to select it.\n- Double-click a cell, press **F2**, or begin typing to edit it.\n- Start formulas with `=`, for example:\n  - `=SUM(A1:A10)`\n  - `=IF(B2>100,\"High\",\"Low\")`\n  - `=VLOOKUP(E2,A2:C20,3,FALSE)`\n  - `=Sheet2!A1*2`\n- Use the name box to jump to a cell or range such as `D12` or `A1:C8`.\n- Right-click the grid for cut/copy/paste and row or column actions.\n- Double-click a sheet tab to rename it; right-click it to duplicate or delete it.\n\n## Keyboard shortcuts\n\n| Shortcut | Action |\n| --- | --- |\n| Arrow keys | Move the active cell |\n| Shift + Arrow | Extend the selection |\n| Enter / F2 | Edit the active cell |\n| Tab / Shift + Tab | Move right / left |\n| Delete / Backspace | Clear selected cells |\n| Ctrl/Cmd + C, X, V | Copy, cut, paste |\n| Ctrl/Cmd + Z | Undo |\n| Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z | Redo |\n| Ctrl/Cmd + B | Bold |\n| Ctrl/Cmd + I | Italic |\n| Ctrl/Cmd + U | Underline |\n| Ctrl/Cmd + A | Select the whole sheet |\n| Ctrl/Cmd + Arrow | Jump across populated or empty regions |\n| Alt + Enter | Insert a line break while editing |\n\n## Programmatic population\n\nThe spreadsheet can be read and populated through the Gadget's server RPC methods. This is useful for scripts, agents, migrations, or seeding a workbook without using the grid UI.\n\n### Read the workbook\n\nCall `getDocument()` to retrieve the complete persisted workbook:\n\n```js\nconst doc = await gadget.getDocument();\n```\n\nThe returned object contains `revision`, `title`, `sheetOrder`, `sheets`, `cells`, and `lastModified`. Cells are grouped by sheet ID and keyed by A1 reference:\n\n```js\nconst firstSheetId = doc.sheetOrder[0];\nconst a1 = doc.cells[firstSheetId].A1;\n// { value: \"Revenue\", fmt: { b: true }, version: 1 }\n```\n\n### Write cells\n\nUse `applyOperation()` with per-cell operations. For a new cell, use `baseVersion: 0`; when replacing or deleting an existing cell, use its current `version` from `getDocument()`.\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\nconst cells = doc.cells[sheetId];\n\nconst result = await gadget.applyOperation({\n  senderId: \"seed-script\",\n  cellOps: [\n    {\n      sheetId,\n      ref: \"A1\",\n      value: \"Revenue\",\n      fmt: { b: true, bg: \"#fff3a3\" },\n      baseVersion: cells.A1?.version ?? 0,\n    },\n    {\n      sheetId,\n      ref: \"B1\",\n      value: \"=SUM(B2:B10)\",\n      fmt: { nf: \"currency\", d: 2 },\n      baseVersion: cells.B1?.version ?? 0,\n    },\n  ],\n});\n```\n\nA cell's `value` is always stored as text. Begin a value with `=` to create a formula. To delete a cell completely, send both `value: null` and `fmt: null` with the current `baseVersion`.\n\nCheck `result.status` after writing. It is `applied`, `unchanged`, or `conflict`. On a conflict, inspect `result.conflicts`, re-read the document, and retry against the latest cell versions rather than overwriting blindly.\n\n### Replace a whole sheet\n\nFor bulk imports, `sheetReplacements` is more efficient than sending many individual cell operations:\n\n```js\nawait gadget.applyOperation({\n  senderId: \"csv-import\",\n  sheetReplacements: [{\n    sheetId,\n    cells: {\n      A1: { value: \"Name\", fmt: { b: true }, version: 1 },\n      B1: { value: \"Total\", fmt: { b: true }, version: 1 },\n      A2: { value: \"Example\", fmt: null, version: 1 },\n      B2: { value: \"1250\", fmt: { nf: \"currency\", d: 2 }, version: 1 },\n    },\n  }],\n});\n```\n\nA whole-sheet replacement overwrites that sheet's complete cell map, so merge with existing data first if it must be preserved. It does not use per-cell conflict checks.\n\n### Change workbook structure\n\nPass a complete structure snapshot to change the title, sheet order, names, dimensions, or sizing metadata. Because structure uses last-writer-wins semantics, start with the latest document and preserve every sheet that should remain:\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\n\nawait gadget.applyOperation({\n  senderId: \"setup-script\",\n  structure: {\n    title: \"Quarterly plan\",\n    sheetOrder: doc.sheetOrder,\n    sheets: {\n      ...doc.sheets,\n      [sheetId]: {\n        ...doc.sheets[sheetId],\n        name: \"Summary\",\n        colWidths: { ...doc.sheets[sheetId].colWidths, 0: 180 },\n      },\n    },\n  },\n});\n```\n\nOmitting an existing sheet from `sheetOrder` deletes it and its stored cells. Adding a new sheet ID creates empty storage for it. When adding a sheet and its initial data together, include both `structure` and a matching `sheetReplacements` entry in the same operation.\n\nFormatting keys accepted by the server are `b` (bold), `i` (italic), `u` (underline), `s` (strikethrough), `c` (text color), `bg` (fill color), `a` (`l`, `c`, or `r` alignment), `nf` (number format), `d` (decimal places), `fs` (font size), and `wrap`. Colors must be hexadecimal strings such as `#1d1d20`.\n\n## Architecture\n\nBoth sides are built on the shared gadget libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the `client.js` and `server.js` it ships.\n\n`@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the element builder, the shared\ntoolbar icons and controls (icon and segment buttons, groups, colour pickers, the dropdown behind the\nnumber-format menu), the in-page prompt that stands in for the `window.prompt` the sandbox blocks,\nand the save-status dot. `@gadgets/bundled-blueprints/libraries/sync/client` and `.../sync/server` are the\ncollaboration loop: the debounced, serialized, retrying save scheduler, the presence roster and\nheartbeat, the subscriber object the server calls back on, and in the Durable Object the mutation\nqueue and the subscriber registry with its presence announcements (whose broadcasts are never\nawaited, so a callback may re-enter the queue). `@gadgets/bundled-blueprints/libraries/zip/server`\nprovides the streaming ZIP writer used by XLSX export. The cell model, the formula engine, the grid,\nsheet tabs and the export-specific code are this gadget's own.\n\nIn the repository the source is TypeScript under `blueprints/workspace-sheets/files/`\n(`client.ts`, `server.ts`, `lib/protocol.ts`, `lib/formula.ts`, `lib/xlsx.ts`), which the build bundles\ninto the `client.js` and `server.js` shipped here.\n\n### `client.js`\n\nBuilds the entire browser interface in JavaScript. It contains:\n\n- Grid rendering and selection behavior\n- Cell editing, formatting, sorting, and structural operations\n- Formula tokenization, parsing, evaluation, and display formatting\n- Clipboard and keyboard support\n- A local model whose saves are queued per cell and flushed by the library's scheduler\n- RPC callbacks for live server operations and presence events\n\nFormula evaluation happens in the browser. The engine caches computed cells, detects circular references, supports ranges and cross-sheet references, and displays standard errors including `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#N/A`, `#NUM!`, and `#CYCLE!`.\n\n### `server.js`\n\nExports the Durable Object class `Gadget`, which is the authoritative persistence and synchronization layer. It:\n\n- Stores spreadsheet metadata and each sheet's cells in Durable Object storage\n- Serializes mutations and document snapshots through the library's mutation queue\n- Applies per-cell optimistic concurrency using cell versions\n- Uses last-writer-wins semantics for document structure\n- Broadcasts operations and presence events through the library's subscriber registry after the queue releases, best-effort and without awaiting them, so a callback may itself read or write the document and a hung subscriber holds up only its own client\n- Sanitizes titles, dimensions, cell contents, references, and formatting\n- Advertises and produces the server-side workbook and CSV exports\n\n### XLSX and ZIP modules (bundled into `server.js`)\n\nThe XLSX module converts a complete document snapshot into a streaming XLSX workbook: sparse\nworksheet XML with inline strings, deduplicated styles, and frozen panes. The ZIP module is a\ndependency-free streaming ZIP writer (raw DEFLATE via `CompressionStream`, incremental CRC32, data\ndescriptors). Both are bundled into `server.js`.\n\n## Storage model\n\nThe server stores:\n\n- `meta`: revision, title, sheet order, sheet metadata, and modification time\n- `cells:<sheetId>`: a map of A1 references to `{ value, fmt, version }`\n\nDefault sheets have 100 rows and 26 columns. Server validation permits up to 50,000 rows and 702 columns per sheet. Cell values are limited to 8,192 characters, and titles are limited to 200 characters.\n\n## Collaboration notes\n\nThe server and client synchronization code support multiple connected clients and live updates. Each\ntab introduces itself with the guest name and colour the sync library derives from its client ID, and\nreports its selected range on the library's throttle and heartbeat. Remote collaborator badges and\nselection overlays are currently disabled in the UI, so the app presents as a single-user spreadsheet\neven though remote operations still synchronize.\n\n## Current limitations\n\n- There is no file import workflow; clipboard operations use tab-separated text.\n- Structural edits and sorting clear local undo/redo history.\n- Formula reference adjustment during row/column changes is limited to references on the current sheet.\n- Formula support is broad but is not intended to be fully compatible with Excel or Google Sheets.\n- Frozen row/column metadata exists in the model, but the current UI does not expose controls for it.\n\n## CSV export\n\nUp to 31 worksheets are exposed as individual **CSV** export options, leaving one of the platform's\n32 format slots for XLSX. CSV files contain the stored cell values through the worksheet's used\nrange. Formula cells are exported as their raw formulas (for example, `=SUM(A1:A10)`), not as\nbrowser-computed display values. Fields use standard CSV quoting and CRLF line endings.\n\n## XLSX export\n\n**Excel Workbook** exports one XLSX file with the worksheets in workbook order. Cells keep their\nfont, color, fill, alignment, number format, decimal places and wrapping; sheets keep column widths,\nrow heights and frozen panes. Pixel sizes are converted to points (rows, fonts) or approximate\ncharacter widths (columns), so layout is close but not pixel-identical.\n\nCell values follow the grid's own literal rules: a leading apostrophe forces text, a leading `=` is\na formula (whatever the number format), `TRUE`/`FALSE` are booleans, and numbers may carry a sign,\ncommas, a leading `$` or a trailing `%`. Everything else is text; date-looking text is not parsed.\n\nFormulas are written without cached results and the workbook requests a full recalculation on open,\nso Excel evaluates them itself. To keep them valid there, the exporter rewrites cross-sheet\nreferences to the exported worksheet names, prefixes OOXML \"future functions\" (`IFS`, `CONCAT`, ...)\nwith `_xlfn.`, renames `ERRORTYPE()` to `ERROR.TYPE()`, and drops whitespace between a function\nname and its `(`. A formula that is empty, structurally unbalanced (unterminated string or quoted\nname, mismatched parentheses or brackets — the grid's parser tolerates these) or that would exceed\nExcel's 8,192-character limit after rewriting is exported as text, since one such formula makes\nExcel report the whole workbook as damaged. Formula semantics are otherwise not translated (for\nexample `^` associativity differs, and a reference outside the grid such as `XFE1` is empty here\nbut `#NAME?` in Excel), and compatibility with Excel is not claimed beyond this.\n\nWorksheet names are made Excel-safe: invalid characters become `_`, blank names become `Sheet`,\nnames are cut to 31 characters, and case-insensitive collisions get ` (2)`, ` (3)`, ... suffixes.\nReferences resolve to the first worksheet with a matching source name, as in the grid.\n\nFilters, charts, comments and pivot tables are not exported; cells already materialized from them\nexport as ordinary values.\n","packages/bundled-blueprints/blueprints/workspace-slides/files/README.md":"# Workspace Slides\n\nA composable, multiplayer slide deck builder. The deck itself is data —\nslides are arrays of typed blocks — and the editor lets anyone add, remove,\nreorder, and edit slides in real time. The visual language (tokens,\ncomponents, typography) is shared across every block. Edit mode uses light,\nneutral sidebars so the slide canvas remains the visual focus.\n\n## Files\n\n- `server.js` — Durable Object that owns the deck document and broadcasts\n  every mutation to connected viewers. The server is a generic store; it\n  doesn't know what any component does.\n- `client.js` — Design tokens, the component registry, slide renderer,\n  block interactions (drag, resize, inline text edit), and the builder\n  shell (slide list, inspector, palette, control bar, present mode).\n\nIn the repository these two modules are TypeScript (`client.ts`, `server.ts`\nand the shared `lib/protocol.ts` types under\n`blueprints/workspace-slides/files/`), which the build bundles into the\n`client.js` and `server.js` installed here.\n\n## Gadget libraries\n\nThree pieces of this deck are not its own: the element builder, the two\nsteps that read an uploaded image, and the object the Durable Object calls\nback. They are shared with the other document-style gadgets and are\nimported from the libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the two files installed here:\n\n```ts\nimport { el, loadImage, readFileAsDataURL } from \"@gadgets/bundled-blueprints/libraries/ui/client\";\nimport { createSubscriber } from \"@gadgets/bundled-blueprints/libraries/sync/client\"; // client.ts\nimport { SubscriberRegistry } from \"@gadgets/bundled-blueprints/libraries/sync/server\"; // server.ts\n```\n\n- **`ui`** — `el(tag, props, children)` builds every element in\n  `client.js`; `readFileAsDataURL` and `loadImage` are the two halves of\n  `fileToImageDataURL`, whose encoding rule (SVG verbatim, PNG kept as PNG,\n  the original on any canvas failure) stays here because it is the deck's.\n  The library's own icons, toolbar controls and prompt are for the gadgets\n  with a formatting bar; this deck draws its own `ICONS` as complete SVGs\n  and has no toolbar of that shape, so it imports none of them.\n- **`sync`** — `createSubscriber(RpcTarget, { deckChanged })` builds the\n  callback target (the RPC layer only calls prototype methods, which is\n  what the library gets right), and `SubscriberRegistry` holds the\n  connected browsers in the Durable Object. A deck has no presence — every\n  viewer sees the same slides and no cursors are shared — so the registry\n  is built with no presence hooks and is a plain fan-out.\n\nEverything else here — the tokens, the `COMPONENTS` registry, the layout and\nsnapping code, the deck document and its undo history — is this blueprint's.\n\n## Slide formats\n\nThe deck is built in a clean, **branded corporate style** (an orange accent\npalette on white slides) and supports two ready-made slide formats. Both live on the fixed\n`1200 × 675` reference canvas and are made entirely of normal editable\nblocks, so anything a template drops in can be moved, restyled, or deleted\nafterward.\n\n### 1. Title / cover slide (`makeTitleSlide()` in `client.js`)\n\nThe orange opening slide.\n\n- Full-bleed orange cover artwork (`background.coverOrange`).\n- White brand **logo** (a clean editable wordmark with an optional accent\n  dot) at `x 36, y 56`, width `267`.\n- **Title** — Inter, white, ~58px, weight 700, at `x 33, y 197`, width\n  `687`. Kept in the left portion of the slide, max three lines.\n- **Subtitle** — Inter, white, ~17px, weight 600, at `x 36, y 533`, width\n  `553`, max two lines. Delete the block if no subtitle is needed.\n\nThis is also what the seed deck's first slide and the server's\n`newBlankSlide()` produce.\n\n### 2. Basic content slide (`makeContentSlide()` in `client.js`)\n\nThe standard white content shell for every non-cover idea.\n\n- Pure white background, no inset surface, no shadow, no border.\n- **Eyebrow** (`sectionLabel`) — Ruby `#FF6633`, uppercase, SemiBold, at\n  `x 36, y 35`. Optional; delete when not useful. Eyebrows are always Ruby.\n- **Headline** (`title`) — black `#000000`, ~28px, weight 600, at\n  `x 35, y 76`, width `984`, max two lines, written as an assertion.\n- **Logo** — the brand wordmark (the `logo` component in its `dark`\n  variant: black wordmark + orange accent dot), at `x 1013, y 40`, sized\n  via `scale: 0.62`.\n- **Content region** — body starts at `x 36, y 204`. The default narrative\n  uses a constrained `760px` reading measure at 19px, weight 400,\n  line-height 1.6. The add-slide controls also provide exact-grid\n  **2-column** (`x 36/609`, `w 553`) and **4-column**\n  (`x 34/328/622/916`, `w 260`) compliant structures.\n- **Bottom brand bar** — full-width 12px Tangerine `shape` at `y 663`, touching the bottom edge.\n\n### Creating slides in either format\n\n- **In the editor:** enter edit mode (**E**). The left panel footer has\n  two buttons — **Title** and **Content** — that insert the matching\n  template after the current slide. Each calls\n  `addSlide(makeTitleSlide())` / `addSlide(makeContentSlide())`.\n- **Programmatically (over the `GADGET` binding):** call\n  `addSlide(atIndex, slideObject)` with a `{ background, blocks }` object.\n  The block/prop shapes in `makeTitleSlide` / `makeContentSlide` are the\n  canonical reference for the two styles; the server mints block ids on\n  insert.\n\nBrand style tokens worth reusing: Ruby `#FF6633`, Tangerine `#F6821F`,\nMango `#FBAD41`, black `#000000`, muted gray `#747474`, Inter throughout.\nSlide-facing component controls are intentionally limited to this palette\nand the approved 400/500/600/700 weights. Cards and diagram boxes default\nto flat white surfaces, thin `#E5E5E5` rules, minimal corner radii, and no\nshadows. Generic colored tones and the pill component are not offered in\nthe component library. The `bulletList` component implements the corporate\nhanging-indent treatment: one item per source line, 6px Tangerine dots,\n12px dot-to-copy gap, 19px primary or 17px compact Inter Regular text, and\ncontrolled 10px/8px item spacing.\n\n## Initial blueprint\n\nNew Gadget instances start from the current four-slide overview of the\nbuilder: the orange “Compose your deck or build with agent” cover, a six-part\nfeature overview, a connected-charts example showing how an agent can use an\napproved internal system of record as a data source, and a two-path get-started\nslide for editing directly or asking the agent. The complete blueprint\nis defined by `INITIAL_DECK` plus `KEY_TAKEAWAYS_SLIDE` in `server.js`; both\nfirst-time initialization and `resetAll()` clone that blueprint. The immutable default\nobjects in `server.js` are cloned whenever storage is first seeded and by\n`resetAll()`, so later edits cannot mutate the blueprint.\n\n## Data model\n\nThe deck is one JSON document:\n\n```js\n{\n  slides: [\n    {\n      id: \"abcd1234\",\n      background: { color: \"#f5f1eb\", inset: true, dotGrid: 0.45 },\n      blocks: [\n        { id: \"...\", type: \"title\", x: 48, y: 78, w: 1040,\n          props: { text: \"Hello\", fontSize: 46, highlight: \"Hello\" } },\n        ...\n      ],\n    },\n    ...\n  ],\n}\n```\n\nEvery block is `{ id, type, x, y, w?, h?, props }`. Position is in the\nfixed `1200 × 675` slide coordinate system; the stage scales uniformly to\nfit the viewport.\n\n## Component registry\n\n`COMPONENTS` in `client.js` is the catalog. Each entry declares:\n\n| field            | purpose                                                 |\n| ---------------- | ------------------------------------------------------- |\n| `name`           | Display name (palette + inspector header).              |\n| `defaultBlock()` | Returns `{ x, y, w?, h?, props }` for a freshly added block. |\n| `fields`         | Inspector schema (`text`, `multiline`, `number`, `select`, `checkbox`, `color`, `image`, `svg`). Set `advanced: true` on a field to tuck it under the inspector's \"Advanced\" disclosure (along with position/size). |\n| `resizableW/H`   | Whether the corner handle adjusts that dimension.       |\n| `fullBleed`      | If true (arrow), the block ignores wrapper position and renders an absolutely-positioned overlay. |\n| `render(props, ctx)` | Returns the DOM. `ctx.inlineText(elem, propKey, transform?)` wires up contentEditable in edit mode. |\n\nComponents exposed in the palette (generic, reusable):\n\n`title`, `subtitle`, `text`, `sectionLabel`, `card`, `box`, `tonePill`,\n`image`, `svg`, `divider`, `shape`, `arrow`.\n\nComponents defined in `COMPONENTS` but intentionally **not** in the\npalette — they're brand marks used by the seed deck only:\n\n`gadgetsMark`, `logo`. The `logo` component renders a clean editable\nwordmark (`text` prop, default `Workspace`) with an optional orange accent\ndot (`accentDot` prop); change the wordmark to rebrand the deck. They still\nrender anywhere a block of that type appears; they just aren't offered as\nbuilding blocks.\n\n### Adding a new component\n\n1. Add a new entry to `COMPONENTS` with a `render`, `defaultBlock`, and\n   `fields`. Use `ctx.inlineText(elem, \"propKey\")` for any text the user\n   should be able to edit directly in the slide.\n2. Add the type to `PALETTE_ORDER` so it shows up in the library.\n3. Add the type to one of the `COMPONENT_CATEGORIES` groups, and add a\n   short description to `COMP_DESC` plus a 14×14 currentColor SVG icon\n   to `COMP_ICON`. (The library renders the icon + name + description as\n   one row per component.)\n\nThat's it — no other code needs to change. The inspector is auto-generated\nfrom `fields`, drag/resize/selection all work automatically.\n\n## Editing\n\nPress **E** (or click the pencil) to enter edit mode. Edit mode reveals:\n\n- **Left panel** — a light, neutral sidebar with a **slide search** (matches\n  any string value in any block's props), a scrollable thumbnail list,\n  and a split **Add slide** control. The main action inserts the standard\n  narrative slide; its caret menu offers title, narrative, 2-column, and\n  4-column layouts (see \"Slide formats\" above). Each row\n  shows its slide\n  number, the thumbnail, and a one-line label derived from the slide's\n  first title (or subtitle / text). Click to jump, drag to reorder\n  (disabled while filtering), hover to reveal duplicate/delete. Press\n  Enter in the search box to jump to the first match; Escape clears.\n- **Right panel** — three stacked sections, in order:\n  1. **Component library** — a contained, filterable card. The search\n     input doubles as the section title; type to filter (matches on\n     name, description, and type id), Enter inserts the first match,\n     Escape clears. Without a query, components are grouped by category\n     (Text / Containers / Decoration / Diagram); with a query, results\n     are flat. The list has its own internal scroll so the library\n     never grows past ~280px tall.\n  2. **Slide** — compact background settings for the current slide\n     (page color, dot grid opacity, inset toggle).\n  3. **Selected block inspector** — icon + name header, schema fields\n     (label-on-the-left rows, multiline + color stack vertically),\n     a 4-button icon row (duplicate, bring forward, send backward,\n     delete), and an \"Advanced\" disclosure that holds position/size\n     and any fields tagged `advanced: true`. When nothing is selected\n     this is replaced by a quiet \"Nothing selected\" hint.\n- **Stage** — every block is selectable (hover dashes, click to select);\n  drag the body to move, the bottom-right handle to resize. Text inside a\n  block is contentEditable; press Enter or click away to save. While\n  dragging, the block softly snaps when its left / right / center edges\n  line up with another block's edges or center, or with the slide bounds\n  / center; a thin orange guide line shows which alignment is locked.\n  Hold **Alt / Option** while dragging to disable snapping entirely.\n\nPress **F** to enter presentation mode (chrome hidden, stage fills the\nviewport, no rounded corners). **Esc** or **F** exits.\n\n## Realtime\n\nEvery mutation is sent through RPC to the Durable Object and re-broadcast\nas a full deck snapshot via the subscriber callback. The callback signature\nis `deckChanged(deck, meta)` where `meta = { canUndo, canRedo }` reflects\nthe current server-side history state. The client preserves the currently-\nselected slide & block across updates when possible.\n\nThe registry behind `#broadcast` isolates subscribers from each other: one\nwhose call fails is dropped and its stub released rather than failing the\nmutation being broadcast, and so is one whose connection breaks.\n\n## Undo / redo\n\nThe undo stack lives on the server (in `Gadget`, in memory only — lost on\nDO restart, capped at `MAX_UNDO` entries). It's **global**: there's one\nshared history for the deck, and pressing undo rolls back the most recent\nmutation made by any connected client. Every call to `#save` snapshots\nthe previous deck onto the undo stack and clears the redo stack;\n`undo()` / `redo()` bypass `#save` (so they don't recurse) and broadcast\nthe restored deck through `#broadcast`. Clients learn whether undo/redo\nare currently available from the `meta` arg piggy-backed on every\nbroadcast, plus an initial `getUndoState()` call at boot.\n\n## Keyboard\n\n- `←` / `→` / `Space` / `PageUp` / `PageDown` — navigate slides\n- `Home` / `End` — first / last slide\n- `E` — toggle edit mode\n- `F` — toggle present mode\n- `Delete` / `Backspace` (edit mode) — delete the selected block\n- `Ctrl/Cmd + C` / `X` / `V` (edit mode) — copy / cut / paste the selected\n  block. The clipboard is in-memory (per tab); paste inserts onto the\n  *current* slide with a small position offset so the new block doesn't\n  sit perfectly on top of its source, and the new block becomes selected.\n- `Ctrl/Cmd + D` (edit mode) — duplicate the selected block in place\n  (same as the inspector's duplicate icon, doesn't touch the clipboard).\n- `Ctrl/Cmd + Z` — undo; `Ctrl/Cmd + Shift + Z` (or `Ctrl + Y`) — redo.\n  Also available as two icon buttons in the floating control bar (only\n  shown while in edit mode; the keyboard shortcuts work anywhere).\n- `Esc` — close jump menu, then deselect, then exit present mode\n\n## Motion\n\nAll UI motion uses a single `--ease` token\n(`cubic-bezier(0.23, 1, 0.32, 1)` — a strong ease-out) and a small\n`--ease-in-out`. Press feedback on buttons is handled with two CSS\nselectors registered in `mountShell`:\n\n- `[data-press]:active { transform: scale(0.97); }` — generic buttons.\n- `[data-icon-btn]:active { transform: scale(0.92); }` — pill icon\n  buttons in the control bar.\n\nAdd either attribute to a button to get the right press feel for free.\nAvoid `transition: all`; always specify the exact properties so unrelated\nproperty changes don't accidentally animate.\n\n## Inspector form controls\n\nAll inspector inputs (text, number, select, textarea, checkbox, color)\nshare a small set of CSS classes defined in `mountShell`:\n\n- `.field-input` — the base look (subtle dark fill, transparent border,\n  orange ring on focus). Applied to `<input>`, `<select>` and\n  `<textarea>`.\n- `.field-textarea` — adds the multiline sizing on top of `.field-input`.\n- `.field-select` — adds an inline SVG chevron and resets the native\n  `appearance` so the select matches text inputs.\n- `.field-check` — a custom-painted checkbox (orange when checked).\n\n`fieldRow(label, control, { stacked })` lays out a label + control as a\ntwo-column grid by default; pass `{ stacked: true }` for full-width\ncontrols (multiline only). All field factories (`textField`,\n`numberField`, etc.) use this and the classes above, so you almost never\nneed to touch styles when adding a new field type.\n\n## Notes\n\n- The brand logo and gadgets mark are inlined as SVG; they live in\n  the `logo` / `gadgetsMark` components and are used only by the seed\n  deck. The palette doesn't expose them as reusable blocks. To rebrand,\n  edit the `logo` block's `Wordmark` field (and toggle its accent dot).\n- **Image** blocks store their data inside `props.src` as either an\n  external URL (loaded normally by `<img>` — note this is one of the few\n  network paths the sandbox still allows) or as an inlined `data:` URI.\n  Uploads are read with the `ui` library's `readFileAsDataURL`, and raster\n  images larger than `MAX_IMAGE_DIM` (1600px on the longest side) are\n  downscaled on canvas before being inlined, so the deck JSON doesn't\n  balloon. SVG files are passed through verbatim. The custom `image` field\n  type in `renderField` renders the preview + upload/clear buttons + URL\n  input.\n- **SVG** blocks store raw markup in `props.markup`. `render()` parses\n  it with the DOM parser, strips `<script>` elements and `on*` attributes,\n  then forces `width=\"100%\" height=\"100%\"` and a `preserveAspectRatio`\n  derived from the `fit` prop so the SVG scales to the block bounds.\n  Agents can author / replace SVG blocks programmatically over the\n  `GADGET` binding: call `addBlock(slideId, { type: \"svg\", x, y, w, h,\n  props: { markup: \"<svg ...>...</svg>\" } })` or `updateBlock(...,\n  { props: { markup: \"...\" } })`.\n- Arrows are special-cased: they render a full 1200×675 SVG overlay and\n  store endpoints in `props.x1/y1/x2/y2`. Endpoint handles appear when an\n  arrow is selected. **Important**: full-bleed blocks must NOT make their\n  root element pointer-clickable, or they'll eat clicks for everything\n  rendered before them on the slide. `renderBlock` keeps the SVG itself\n  `pointer-events: none` and adds an invisible thick hit-line on top of\n  the visible arrow stroke so clicking near the line still selects it.\n- Alignment snaps while dragging are implemented in `attachBlockInteractions`\n  via three helpers: `computeSnapTargets` (gathers x/y edges + centers from\n  every other non-arrow block on the slide, plus slide bounds + center),\n  `applySnap` (independently snaps each axis to the nearest target within\n  `SNAP_THRESHOLD` slide-units), and `drawSnapGuides` (renders thin orange\n  guide lines into a `[data-snap-layer]` div inside the slide frame). The\n  snap layer is torn down on `pointerup`. Blocks without explicit `w`/`h`\n  (titles, pills, etc.) are measured via `offsetWidth/Height` so their\n  rendered bounding box still participates correctly.\n- Slide thumbnails in the left panel are produced by `renderSlideThumbnail`,\n  which calls the normal `renderSlide` with `editMode` temporarily forced\n  off and wraps the resulting 1200×675 frame in an `overflow:hidden`\n  container scaled with `transform: scale(width / 1200)`.\n- The slide search at the top of the left panel is built once at shell\n  mount (`buildSlideSearchHeader`) and persists across re-renders, so\n  typing into it never steals focus. `renderSlideList` reads\n  `slideSearch` and walks `filteredSlideIndices()` to decide what to\n  show. Drag-to-reorder is disabled when a filter is active since the\n  visible positions don't map 1:1 to absolute deck indices.\n- Reordering uses native HTML5 drag-and-drop (`draggable=\"true\"` +\n  `dragstart`/`dragover`/`drop`). A floating 2px orange line shows the\n  drop position; the actual move is done optimistically on the client and\n  then sent to the server via `gadget.moveSlide`.\n- The sandbox prohibits modal dialogs and external fetches; all asset\n  data must be inlined.\n- The server still has a `resetAll` RPC method — it's no longer exposed\n  in the UI but remains for use from the `GADGET` binding if you ever\n  need to nuke the deck.\n\n## Export formats\n\nThe deck supports **HTML**, **PDF**, and **PowerPoint (`.pptx`)** exports. HTML and PDF use the\nbrowser print renderer. PowerPoint export runs on the server and creates a conventional OPC /\nPresentationML package through `@gadgets/bundled-blueprints/libraries/pptx/server`. The build inlines\nthat library into `server.js`; there is no separately shipped `pptx.js` or runtime package dependency.\n\nText, cards, boxes, pills, basic shapes, dividers, arrows, and the two brand marks become editable\nnative PowerPoint objects rather than a screenshot. `ExportHandler` passes the original deck to\n`deckToPptx(deck, adaptBlock)`, with an adapter that expands each `logo` into a text wordmark and\nellipse accent dot. Other repository-authored TypeScript blueprints can import the same renderer\nand supply their own adapters. Source block order remains the shape z-order.\n\nAuthored quotas are checked before adaptation: 500 slides, 1,000 blocks per slide, and 10,000 blocks\nper deck. Adapted output has separate bounds of 2,000 blocks per slide and 20,000 per deck, so a\nlogo's accent dot does not consume another authored block. Text and image budgets still apply.\n\nThe starter chart, Edit/agent icons, and bottom brand bars use native blocks and survive export.\nThe orange cover treatment exports as native objects; SVG blocks and dot-grid backgrounds are omitted.\n\nThe 1200 x 675 canvas maps to standard widescreen PowerPoint at 12,192,000 x 6,858,000 EMU. The\nrenderer asks consumers to grow auto-height blocks. Cards budget text boxes and gaps within their\nauthored padded height, then ask consumers to shrink the text to fit rather than match browser\nclipping. PNG and JPEG data URLs are embedded after signature, dimension, and resource limit checks.\nImage corner radii apply when the picture fills its block, including matching-aspect `contain`\nimages; letterboxed pictures remain rectangular. Remote raster images become visible placeholders.\nSVG image data URLs and `svg` blocks are omitted entirely.\n\n","packages/bundled-blueprints/libraries/README.md":"# Gadget libraries\n\nShared code the bundled blueprints import instead of carrying their own copies. A blueprint's\n`client.ts` and `server.ts` import a library by this package's name and the subpath its\n`package.json` exports, `@gadgets/bundled-blueprints/libraries/<name>/client` and\n`.../<name>/server`; the blueprint build (`../src/files.ts`) resolves each to the library's entry\nhere and inlines what the entry uses into the `client.js` / `server.js` the blueprint ships, the way\nit inlines a blueprint's own `lib/` modules. The blueprint stays self-contained, and a gadget created from\nit carries its own copy of the library as of its creation; nothing resolves the package name\nat runtime, and a gadget the agent writes cannot import one.\n\nNothing here is deployed on its own. The package name is how tsc, vitest and an editor resolve the\nimport, through `exports`, with no alias or `paths` block; the build resolves the same name to this\ndirectory itself, so a blueprint tree with no `node_modules` above it builds too.\n\n## Layout\n\n```\n<name>/\n  client.ts        entry of .../libraries/<name>/client, inlined into a blueprint's client.js\n  server.ts        entry of .../libraries/<name>/server, inlined into a blueprint's server.js\n  src/**           the modules the two entries import; never shipped on their own\n  __tests__/**     vitest, jsdom by default, `// @vitest-environment node` for a pure module;\n                   `server.test.ts` / `<topic>.server.test.ts` for a test of the server side\n  README.md        what the library does and how it is put together\n```\n\nA library may import another (`@gadgets/bundled-blueprints/libraries/ui/client` from a library built\non it, say); the blueprint build resolves that import the same way. A client entry may not import a\nlibrary's server side, and the build rejects a blueprint that does: it would drag a Durable Object\ninto the iframe. A blueprint imports its own files by relative path and a library by package\nsubpath; everything else the bundle inlines fails the build -- a relative or absolute path into\n`libraries/` (`../../../libraries/ui/src/dom.ts`), a library's `src/` module by package path, the\npackage root, or a bare specifier some `node_modules` above the blueprint happens to satisfy -- so\nthe exported subpath is the only door.\n\n## Rules for a library\n\n- **es2022.** A blueprint's bundles target `es2022`, and the gadget loader's `compatibilityDate` is\n  `\"2026-02-01\"` (`loadGadgetWorker` in overseer.ts). A library that needs a newer runtime feature\n  bumps that date for every gadget, and says so in its README.\n- **No npm.** What a library imports is inlined into a blueprint's files, which nothing audits\n  afterwards, so the blueprint build rejects an input from `node_modules`. A library is written\n  against the platform alone, like a gadget.\n- **Readable.** The inlined code is what the agent reads and edits in an instantiated gadget. The\n  bundle is not minified, so a library's names and structure survive into it and should read well\n  on their own; its comments do not survive, because esbuild drops ordinary comments whatever the\n  minify settings, so the doc comments in `src/` are for this repository's readers.\n- **Lint applies.** Unlike a blueprint's `files/`, which the repo's lint ignores as user-authored\n  gadget source, this is platform code and is checked like the rest of the repo.\n\n## Tests\n\n```\npnpm --filter @gadgets/bundled-blueprints test:run   # the libraries' suites, with the blueprints'\nvp run -F @gadgets/bundled-blueprints build          # the type-check programs\n```\n\nThe package's `tsconfig.client.json` checks each client entry under the DOM lib,\n`tsconfig.server.json` each server entry under the Workers types, `tsconfig.tests.json` the tests\nunder DOM and Node types and `tsconfig.server-tests.json` the server-side tests under Workers and\nNode types; a `src/` module is checked under whichever of those imports it, which is what keeps a\nDurable Object from seeing `document` and iframe code from importing `cloudflare:workers`. A server\ntest runs against a stub of `cloudflare:workers` that the package's vitest config aliases in.\n\n## Libraries\n\n- [`ui`](ui/README.md) -- DOM helpers for document-style gadgets: the `el` builder, SVG icons and\n  toolbar controls, the in-page prompt, the save-status dot, relative time, and image reading and\n  downscaling. Client-only.\n- [`sync`](sync/README.md) -- collaboration plumbing: on the server a mutation queue, a subscriber\n  registry with presence seeding and broadcast, and versioned upserts; on the client a save scheduler\n  with retry backoff, a presence roster and a subscribe helper. The Docs, Sheets and Slides\n  blueprints build on it.\n- `zip` -- a server-only streaming ZIP32 writer shared by the Sheets XLSX exporter and PPTX library.\n- `pptx` -- a server-only PresentationML renderer for block-based slide decks, inlined into each\n  importing blueprint's `server.js`, not shipped as a separate `pptx.js`. `deckToPptx(deck, adaptBlock)`\n  validates authored quotas before a per-block adapter replaces brand-specific blocks; adapted\n  output is bounded separately. Installed gadgets cannot resolve these package imports at runtime.\n","packages/bundled-blueprints/libraries/sync/README.md":"# `sync` — collaboration plumbing\n\nEvery collaborative gadget here has the same skeleton: one Durable Object owns the authoritative\nstate and many browsers edit it live -- a mutation queue in the object, a set of subscriber stubs\nit broadcasts to, presence seeded on join and dropped on leave, per-item optimistic concurrency,\nand in the browser a debounced save loop with retry, a throttled presence heartbeat and an\n`RpcTarget` for the callbacks. This library is that skeleton once, with none of the flesh: it knows\nnothing about blocks, cells or slides, and a gadget keeps its own model, protocol, DOM and export\nformats on top of it.\n\n## Server (`@gadgets/bundled-blueprints/libraries/sync/server`)\n\n- **`MutationQueue`** -- `run(work)` chains mutations so that each loads the state the previous\n  one stored, whatever order their RPCs interleave in; a rejection reaches its caller alone.\n- **`SubscriberRegistry<Callbacks, Info>`** -- `add(subscriber, info)` `dup()`s the stub the RPC\n  layer delivered, registers `onRpcBroken` cleanup and returns the kept handle; `broadcast(send)`\n  delivers to everyone at once and never waits: a failing subscriber is dropped and disposed\n  (never failing the mutation), a hung one holds up nothing but itself, and a callback may call back\n  into the object -- read the document, queue a mutation -- without deadlocking on the mutation that\n  is telling it about the last one; deliveries are issued synchronously in call order, so each\n  subscriber hears events in order. `remove(handle)`, `has(handle)`, `members()`, `size`. Given\n  `PresenceHooks` -- `join(subscriber, who)` and `leave(subscriber, who)` in the gadget's own\n  callback vocabulary -- it seeds a newcomer with everyone already here (all at once; one that\n  fails a seed is dropped, and its leave announced, since it was a member from the moment it was\n  added), announces the newcomer to all, and announces whoever drops out. A gadget with no presence passes none and gets a fan-out.\n- **`applyVersioned(items, batch, options?)`** -- the per-item concurrency rule over any\n  `{ id, version }`: an upsert whose `baseVersion` is stale is rejected with the authoritative\n  item, one for a deleted item is rejected as `missing` (the client may re-create it with\n  `baseVersion` 0), an identical upsert is no change, deletions are version-checked, and accepted\n  items take the next version. Returns the new item map, `accepted`, `deletedIds`, `conflicts`,\n  `changed` and a `status`; `operationStatus(changed, conflicts)` recomputes the status for a\n  gadget whose batch also carries ordering or a title. `normalizeBaseVersion` reads a base version\n  off the wire.\n- **`normalizeCollaborator`** bounds what a client says about itself before it is repeated.\n\nThe entry imports nothing from `cloudflare:workers`: the stubs are whatever the gadget's own\n`subscribe` receives, seen through the structural `SubscriberStub` (`dup`, `onRpcBroken`, the\ndisposer).\n\n## Client (`@gadgets/bundled-blueprints/libraries/sync/client`)\n\n- **`SaveScheduler`** -- `schedule(delay?)` debounces (`DEBOUNCE_MS`), `flush()` serializes and\n  a flush during a save folds into one more. The gadget supplies `save()`, which sends what is\n  dirty and answers `\"saved\"`, `\"conflict\"` (rebased, re-sent at once) or `\"pending\"` (a draft\n  waits on something else; the gadget schedules again), and `isDirty()`. A failed save is retried\n  after `retryDelay(failures)`: `RETRY_BASE_MS` doubling to `RETRY_MAX_MS`, reset by a success,\n  with the failure left on the status line until the retry starts. Statuses (`SaveStatus`) are\n  `saving`, `saved`, `conflict`, `offline`, with `synced` left for the gadget to report on a live\n  update. `readOnly` makes it a no-op.\n- **`PresenceRoster<Cursor>`** -- `apply(event)` takes join, cursor and leave events (ignoring the\n  client's own), keeping a position through a re-join; `people()`, `entries()`, `get(clientId)`;\n  `expire(now?)` drops anyone silent for `STALE_MS`. `Cursor` is the gadget's position type,\n  carried unread.\n- **`PresenceReporter<Update>`** -- `schedule()` sends at most once per `THROTTLE_MS`, `sendNow()`\n  sends at once, `startHeartbeat(onBeat?)` re-sends every `HEARTBEAT_MS` and then runs the\n  gadget's tick (expire the roster, redraw), returning the stop function.\n- **`createSubscriber(RpcTarget, callbacks)`** -- an instance of the host's `RpcTarget` whose\n  prototype carries each callback, since the RPC layer exposes prototype methods and never own\n  properties. The class comes from the gadget's bootstrap, which a library cannot import, so the\n  gadget passes it in the way it passes its `gadget` stub.\n- **`collaboratorFor(clientId)`** -- the guest identity a tab declares for itself.\n\nNothing on the client side touches the DOM; every module runs in Node under its tests.\n\n## Using it\n\nA gadget keeps its RPC surface and its stored shape; the library implements the loop. On the\nserver, every mutation runs through `queue.run`, one registry with hooks that build the gadget's own\n`presence` events holds the subscribers and broadcasts to them, and `applyVersioned` applies the\nupsert/delete rule with the gadget's sanitizer in front and its ordering after. In the browser, a\n`SaveScheduler` whose `save()` is the gadget's payload builder drives saving, a roster holds the\ncollaborators, a reporter sends presence and the heartbeat, and `createSubscriber` builds the\ncallbacks class. Each part stands alone; a gadget may use any subset.\n\n## Tests\n\n`__tests__/` runs in Node (`// @vitest-environment node`): one suite per module, plus\n`server.test.ts`, which assembles the server entry into the smallest gadget and drives it with\nstubs shaped like the RPC layer's.\n","packages/bundled-blueprints/libraries/ui/README.md":"# `ui` — DOM helpers for document-style gadgets\n\nThe Docs, Sheets and Slides blueprints share the same few helpers: an element builder, an SVG icon\nfactory, toolbar buttons and a dropdown, an in-page prompt (the sandboxed iframe blocks\n`window.prompt`), a save-status dot, relative timestamps, and the reading and downscaling of a\npasted image. This library is those helpers once, imported as\n`@gadgets/bundled-blueprints/libraries/ui/client`. It is DOM-only: nothing here talks RPC\nor touches storage, and nothing here knows what a click means -- every control takes the gadget's\naction as an argument.\n\n`@gadgets/bundled-blueprints/libraries/ui/server` exists so that every library has both entries and a\nblueprint's server may import any of them uniformly. It exports one flag, `clientOnly`, and nothing\nelse.\n\n## What it exports\n\n| Export | What it is |\n| --- | --- |\n| `el(tag, props?, children?)` | Element builder. `class`, `text`, `html`, a `style` object and `on*` listeners are the conveniences; `null`/`undefined`/`false` props are skipped, `true` is an empty attribute; children are nodes, strings, numbers or nothing, singly or in an array. |\n| `icon(paths)` | The 24-unit stroked SVG the toolbars draw, `aria-hidden`. |\n| `ICONS` | Path markup for the icons at least two gadgets share (undo, redo, bold, italic, underline, strike, textcolor, the three alignments, ul, ol, link, image, clear). A gadget spreads its own over it. |\n| `iconBtn(paths, title, onClick, label?)`, `segBtn(paths, title, onClick)` | `button.icon-btn` / `button.seg-btn`, `type=button`, titled and `aria-label`led, mousedown prevented so the editor's selection survives; `label` shows text instead of an icon. |\n| `group(prio, items, first?)` | `div.tgroup[.p1/.p2/.p3]` led by a `div.tdiv` divider unless `first`. |\n| `colorBtn(paths, title, defaultColor, onChange)` | `div.color-btn`: icon, `span.bar` swatch, native colour input; a pick recolours the bar and calls `onChange(hex)`. |\n| `customSelect({className?, title?, options, value, showUnlistedValue?, onChange})` | The toolbar dropdown: `button.cselect` plus a `div.cmenu` appended to the body while open; options may carry `style` (docs' font preview), `ex` (sheets' format example) or be `{sep: true}`. A value may be a string or a number, matched and reported as a string. Returns `{el, setValue, getValue}`. |\n| `promptInline(message, initial?, placeholder \\| options?)` | The dialog: resolves the text on OK/Enter, `null` on Cancel/Escape/backdrop. Options: `placeholder`, `okLabel`. |\n| `PROMPT_STYLES` | CSS for the dialog's classes, scoped under `.prompt-overlay`/`.prompt-card`, for a gadget whose stylesheet has none. |\n| `statusIndicator({kind?, text?, title?})` | `div.status` > `span.dot.<kind>` + text; `set(kind, text)` is a no-op when nothing changed. |\n| `relativeTime(epochMs, now?)` | `just now`, `3 min ago`, `2 h ago`, `5 d ago`, then the locale date. |\n| `prepareImage(file, options?)` | Data URL within `maxDimension` (1600px) and `maxDataUrlLength` (1.4M chars): a GIF within `maxGifDataUrlLength` (2.7M chars, about 2 MB) is kept animated, everything else becomes WebP (JPEG where WebP cannot be encoded) at falling quality, then falling size. `alt` defaults to `altFromFileName`. |\n| `readFileAsDataURL(file)`, `loadImage(src)` | Its two steps, for a gadget that wants one of them. |\n| `isImageFile`, `imageFilesFrom(transfer)`, `IMAGE_TYPES`, `DEFAULT_IMAGE_LIMITS`, `altFromFileName` | The rest of the image module. |\n\nEvery element is styled by the gadget's own stylesheet through the class names above; the library\nships no CSS but `PROMPT_STYLES`.\n\n## Behaviour worth knowing\n\n- **`el`** has no `data` prop; write a `data-x` attribute. `text` sets `textContent` before the\n  children are appended, so both may be given.\n- **`iconBtn`/`segBtn`** prevent mousedown so the editor's selection survives the click;\n  **`customSelect`**'s button does not, and a gadget whose selection has to survive it adds that\n  listener to the returned element, as Docs does on its colour button. The dropdown's label for a\n  value no option carries is the first choice's, or the value itself with `showUnlistedValue`\n  (Docs' font size, where a heading's size is off the list).\n- **`promptInline`**'s message is text, not markup.\n- **`prepareImage`** tries four encoding qualities before it shrinks the image, and keeps a GIF\n  animated only while its data URL fits `maxGifDataUrlLength`. A gadget with its own conversion\n  (Slides passes SVG through, keeps PNG as PNG and falls back to the original on any failure) uses\n  `readFileAsDataURL` and `loadImage` and does the rest itself.\n- **`relativeTime`** has no caller among the bundled blueprints yet.\n\n## Tests\n\n`__tests__/` covers every export in jsdom (`time.test.ts` in node). jsdom decodes no images and\ndraws on no canvas, so `images.test.ts` stands in an `Image` that reports the size written in its\nsource and a canvas whose data URL is as long as the requested quality and pixel count say.\n","packages/gatekeeper-cloudflare/README.md":"# Gatekeeper Cloudflare\n\nThis package provides Cloudflare OAuth integration for Gadgets. It serves three purposes:\n\n- **Sign-in:** when `cloudflare` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Cloudflare\" appears on the login page. The grant reads the account email (verified by Cloudflare,\n  via the `/user` API), which becomes the user's identity. Cloudflare sign-in also establishes the\n  persistent billing-only connection described below.\n- **AI Gateway billing:** when a user connects Cloudflare or signs in with it, the billing scopes are\n  requested and the connection persists. The Workshop then reads a usable access\n  token from it (`getUsableAccessToken`) to power the [AI Gateway billing](../../docs/ai-gateway-billing.md)\n  flow — reading the credit balance and routing BYOK inference through the account's default AI\n  Gateway.\n- **Workers Observability:** gadgets can receive read-only access to logs, events, invocations,\n  aggregate metrics, and traces either across an account or restricted to one Worker. Every result\n  is authorized as an observation, and Worker bindings inject an immutable service filter and\n  defensively discard foreign-service events. Distributed trace summaries are account-only because\n  their names, timing, services, and counts describe the whole cross-service trace; a Worker binding\n  can still retrieve its own events for a known trace ID.\n\nObservability connections request `workers-observability.read`. The OAuth client must allow that\nscope or Cloudflare will omit/reject it. Existing billing-only connections can add the grant when the\nuser first selects an observability resource. Cloudflare exposes account and Worker resource choices,\nbut both map to this one indivisible OAuth scope; resource bindings provide the finer capability\nboundary after connection.\n\nWorkers telemetry is retained by Cloudflare for at most seven days. Queries default to the last hour,\nand the Worker picker searches the full retention window. Suggested bindings are\n`CLOUDFLARE_OBSERVABILITY` for account access and `WORKER_OBSERVABILITY` for one Worker.\n\n### Sharing a gadget that reads telemetry\n\nA binding is not transferable. When a gadget with an observability binding is shared, each\ncollaborator is admitted only if **their own** connected Cloudflare account can read that resource\n(`addObserver` checks it against their credentials, not the owner's). A collaborator without access is\nrefused, and a verification that fails for any other reason — a 5xx, a transport error — is also a\nrefusal rather than an admission.\n\n### What a Worker binding trusts, and what it re-checks\n\nThe scope filter is prepended to every query, but a filter the provider *accepted* is not evidence it\n*applied* it — three separate behaviours here return wrong-but-plausible data with no error. So events\nare re-filtered on the way out, and a single foreign event is treated as proof the filter was dropped:\nthe provider's `count` is withheld (it would be a count of the whole account's matching telemetry) and\nthe event is logged at `error`. `statistics` is kept, because it describes what our query cost rather\nthan how much matched, and callers are told to read it for cost.\n\nThe re-check is deliberately not a hard failure. The events returned are filtered and therefore safe,\nand this provider has surprised us often enough that turning a hypothetical disclosure into a\nguaranteed outage would be the worse trade.\n\n`calculate()` is the exception, and knowingly so: an aggregate cannot be un-mixed, so there is no\nsecond line of defence to add. It rests entirely on the injected filter. The fix that would work —\ngrouping by `$metadata.service` and keeping this binding's own group, whose value *is* the correctly\nscoped answer even for a median — changes `limit` and `orderBy` semantics for every caller, so it is a\nfollow-up rather than a footnote.\n\n### Why discovery sometimes costs a query\n\nCloudflare's `telemetry/keys` and `telemetry/values` endpoints ignore the `filters` array they accept:\nverified against a live account, they answer for the whole account no matter what is passed. A\nWorker-scoped binding therefore cannot use them — they would disclose every field name and value in\nthe account. Whenever a discovery call has to be constrained (a Worker binding, or any caller-supplied\nfilter), the answer is derived instead from a filtered `telemetry/query` events sample, which the\nprovider does filter correctly. Unconstrained account-wide discovery still uses the cheap endpoints.\n\n> Unresolved: whether the `workers-observability.read` scope alone is accepted, or whether Cloudflare\n> requires the Workers Observability **Write** permission to read telemetry. This has not been\n> verified against a live token; if reads 403 on a correctly-scoped grant, that is the first thing to\n> check.\n\n### Two names for one field\n\nA log's own structured fields are returned nested under `source` but are **indexed under their bare\nname**: `logger.warn(msg, {event: \"x\"})` comes back as `source.event` and is queried as `event`.\nConfirmed both ways — `telemetry/keys` on a live account reports `event`/`component`/`level` and no\n`source.*` key at all, and the [Workers Logs\ndocs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) show\n`console.log({user_id: 123})` being filtered as `user_id`.\n\nThis matters because the provider accepts an unknown filter key, matches nothing, and returns an empty\npage with no error. So discovery reports indexed names (`observability-discovery.ts`), and\n`observabilityFieldKey` additionally accepts a `source.`-prefixed key as an alias wherever a caller\nnames a field — filters, `calculations`, `groupBys`, `listValues` — because copying a path out of a\n`listEvents` result is the obvious thing to do and used to fail silently.\n\n`openid` is intentionally **not** requested — the Cloudflare dashboard OAuth client isn't permitted\nthat scope; identity comes from the `/user` API (`user-details.read`).\n\n### Why an error's text never reaches the log\n\n`summarizeFilter` deliberately keeps filter *values* out of the audit trail — they are caller text.\nBut a provider error message can quote that same value straight back, so logging the message would\nreadmit through the error path exactly what the audit path excludes. `CloudflareObservabilityApiError`\ntherefore carries Cloudflare's numeric `codes` alongside the message: the request log names the\ncodes, and the message travels only to the caller who caused it.\n\nThe codes are not the discriminator, though — Cloudflare can return a message with no numeric code\nat all, so the error records separately whether the message is *its* or *ours*, and only ours is\nlogged. A provider failure carrying no codes is therefore logged as a bare status, which is the\nfail-closed answer: the status still says what happened without quoting anyone's filter back.\n\n### Listing accounts is a walk, not a request\n\n`/accounts` is paginated and defaults to **20** per page, so a single GET silently returns a truncated\nlist — an account past the first page simply cannot be picked, with nothing to indicate why. The\naccount picker's substring match also stays client-side: `name` is the only documented server-side\nfilter and whether it matches exactly or by substring is not specified, so pushing it down would trade\na visible truncation for an invisible one.\n\n## Setting Up Cloudflare OAuth Credentials\n\nYou need a Cloudflare dashboard OAuth client (client id + secret). The dashboard OAuth endpoints and\nscopes are hardcoded in `src/oauth.ts`, so you only configure the client id/secret and register the\nredirect URI. Ensure the client's scope allowlist includes `workers-observability.read` when this\ndeployment offers Workers Observability resources.\n\n### Step 1: Register the redirect URI\n\nThe gatekeeper's OAuth redirect URI is:\n\n```\n${BASE_URL}/oauth\n```\n\nwhere `BASE_URL` defaults to `http://localhost:8787/gatekeeper/cloudflare` in dev — i.e. the full\nredirect URI is:\n\n```\nhttp://localhost:8787/gatekeeper/cloudflare/oauth\n```\n\nRegister **exactly** this (replace the host with your `PUBLIC_BASE_URL` when not running locally) as\nan allowed/pre-registered redirect URL on the Cloudflare OAuth client. If it isn't registered you'll\nget an `invalid_request` error: _\"the 'redirect_uri' parameter does not match any of the OAuth 2.0\nClient's pre-registered redirect urls.\"_\n\n### Step 2: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-cloudflare/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\nIn local dev, `run-dev-server.ts` will also seed these from `CLOUDFLARE_OAUTH_CLIENT_ID` /\n`CLOUDFLARE_OAUTH_CLIENT_SECRET` if you'd rather set them in the root `.dev.vars`. A per-package\n`.env` takes precedence and keeps the credential with the gatekeeper that uses it.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable Cloudflare sign-in / billing\n\nTo offer \"Continue with Cloudflare\" on the login page, add `cloudflare` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nThe order controls the order of the login buttons. For the AI Gateway billing / top-up flow, also\nset `ENABLE_CLOUDFLARE_LIMITS=true` (see [AI Gateway billing](../../docs/ai-gateway-billing.md)); a\nuser enables billing by connecting Cloudflare, which requests the billing scopes\n(`offline_access aig.read aig.run user-details.read account-settings.read`). Connecting all\nCloudflare gadget resources also requests `workers-observability.read`.\n\n### Step 4: Verify Setup\n\n1. Start the application in dev mode (see the root README.md).\n2. On the login page, click **Continue with Cloudflare**.\n3. A pop-up opens to the Cloudflare authorization page; approve it.\n4. The pop-up closes and you're signed in, identified by your Cloudflare account email.\n5. To use AI Gateway credits, open **Usage & billing** in settings and **Connect Cloudflare** (this\n   requests billing scopes only).\n\n## Troubleshooting\n\n### \"redirect_uri ... does not match any of the ... pre-registered redirect urls\"\n\nThe redirect URI isn't registered on the OAuth client. Register exactly\n`http://localhost:8787/gatekeeper/cloudflare/oauth` (or your `PUBLIC_BASE_URL` equivalent) — no\ntrailing slash, `http` not `https` for localhost.\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` / `CLIENT_SECRET` are missing. Ensure they're set (per-package `.env` or seeded from the\nroot `.dev.vars`), then restart the dev server.\n","packages/gatekeeper-confluence/README.md":"# Confluence (Cloud) gatekeeper\n\nConnects Gadgets to Atlassian Cloud [Confluence](https://www.atlassian.com/software/confluence)\n(`*.atlassian.net`) using OAuth 2.0 (3LO). It exposes spaces, pages, blog posts, comments, labels,\nand attachments through a capability-based Session API, with page/blog bodies converted to and from\nMarkdown. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_CONFLUENCE` binding.\n\nOnly Atlassian **Cloud** is supported; there is no Server / Data Center adapter.\n\n## What it provides\n\nThree connectable resource granularities:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| **Site** — search and open any space/page/blog post the account can access | `https://*.atlassian.net/wiki` | `ConfluenceSite` |\n| **Space** — list/search/create pages and blog posts within one space | `https://*.atlassian.net/wiki/spaces/:spaceKey` | `ConfluenceSpace` |\n| **Page or blog post** — read/edit the body, manage labels/comments/attachments, and (pages only) read/create child pages | `https://*.atlassian.net/wiki/spaces/:spaceKey/pages/:pageId/*` | `ConfluenceContent` |\n\nPages and blog posts share the `ConfluenceContent` session type (distinguished by\n`ContentMetadata.type`); child-page methods are page-only.\n\nA single account-level grant can span multiple sites. `getGatekeeperClassFor()` resolves a URL's\nhost to one of the connection's accessible sites (its cloud ID) — the one chokepoint enforcing site\naccess.\n\nSee `src/types.d.ts` for the full, agent-facing Session API.\n\n## Setting up an Atlassian OAuth 2.0 app\n\n1. Go to the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/) and\n   create an **OAuth 2.0 integration**.\n2. Add the **Confluence API** to the app and enable **both** scope families below (see\n   `CONFLUENCE_SCOPES` in `confluence-api.ts`). The console has separate **Granular scopes** and\n   **Classic scopes** tabs; this gatekeeper needs scopes from each because it uses the v2 API for\n   most operations and falls back to v1 for a few (search, label writes, attachment upload,\n   restore).\n\n   **Granular scopes** (authorize the v2 API — v2 doesn't accept classic scopes):\n   - `read:space:confluence`\n   - `read:page:confluence`, `write:page:confluence`, `delete:page:confluence`\n   - `read:blogpost:confluence`, `write:blogpost:confluence`, `delete:blogpost:confluence`\n   - `read:comment:confluence`, `write:comment:confluence`, `delete:comment:confluence`\n   - `read:label:confluence`, `write:label:confluence`\n   - `read:attachment:confluence`, `write:attachment:confluence`, `delete:attachment:confluence`\n   - `read:user:confluence`\n\n   **Classic scopes** (authorize the v1 fallbacks — v1 doesn't accept granular scopes):\n   - `search:confluence` (CQL search)\n   - `read:confluence-content.all`, `read:confluence-content.summary` (search results / content reads)\n   - `read:confluence-space.summary`\n   - `write:confluence-content` (label add/remove, restore-from-trash)\n   - `write:confluence-file` (attachment upload)\n   - `readonly:content.attachment:confluence` (attachment download)\n   - `read:confluence-user`\n\n   **Account-level:**\n   - `read:me` (authorizing user's identity)\n   - `offline_access` (required for refresh tokens)\n3. Under **Authorization**, set the callback URL to `<BASE_URL>/oauth`:\n   - `http://localhost:8787/gatekeeper/confluence/oauth` for local dev\n   - `<PUBLIC_BASE_URL>/gatekeeper/confluence/oauth` otherwise\n4. Copy the app's **Client ID** and **Secret**.\n\n> **API version:** this gatekeeper targets the Confluence **v2** REST API (`/wiki/api/v2/...`),\n> which Atlassian now requires (the v1 content/space endpoints have been removed) — and which is\n> why the **granular** scopes are needed. A few capabilities with no v2 equivalent (CQL search,\n> label add/remove, attachment upload, restore-from-trash) fall back to v1 endpoints, which need\n> the **classic** scopes, and degrade with a clear error if Atlassian has removed them too. The app\n> therefore must enable scopes from both the Granular and Classic tabs.\n\n## Configuring credentials\n\nFor local development, set these in the repo-root environment (e.g. `.dev.vars`) so the dev server\ninjects them into this Worker:\n\n```\nCONFLUENCE_CLIENT_ID=your-client-id\nCONFLUENCE_CLIENT_SECRET=your-client-secret\n```\n\n`run-dev-server.ts` maps `CONFLUENCE_CLIENT_ID` / `CONFLUENCE_CLIENT_SECRET` into the Worker's\n`CLIENT_ID` / `CLIENT_SECRET` vars. For production, set `CLIENT_ID` and `CLIENT_SECRET` as secrets\non the deployed Worker and set `BASE_URL` to the public gatekeeper URL.\n\n`providesAuth` is false: Confluence is a data connector, not a sign-in method. Wiring it up as a\n\"Sign in with Atlassian\" provider (using the verified `/me` email) is a possible fast follow.\n\n## How the connect flow works\n\n1. The user starts a connection; `connectAccount()` creates a `UserAccount` Durable Object and\n   returns a URL of the form `<BASE_URL>/<doId>/<nonce>`. The flow uses a two-phase nonce\n   (initiation → OAuth).\n2. Visiting that URL redirects to `auth.atlassian.com` to authorize the requested scopes.\n3. Atlassian redirects back to `<BASE_URL>/oauth`; the gatekeeper exchanges the code for access +\n   rotating refresh tokens, lists the account's accessible Confluence sites\n   (`/oauth/token/accessible-resources`), reads the authorizing user's identity (`/me`), and stores\n   them.\n4. Each connected resource routes API calls through\n   `https://api.atlassian.com/ex/confluence/<cloudId>/wiki/...`. Access tokens are short-lived and\n   refreshed automatically (proactively before expiry and again on a 401); each refresh persists\n   the **new** rotating refresh token.\n\nA connection is created either by pasting a Confluence URL into the chat, or from the Connections\nUI.\n\n## API\n\nPage/blog bodies are exchanged as **Markdown** (best-effort conversion to/from Confluence \"storage\nformat\" XHTML; see `confluence-markdown.ts`). Highlights:\n\n- `ConfluenceSite`: `listSpaces`, `getSpace`, `getContent`, `search` (text or raw CQL),\n  `getCurrentUser`\n- `ConfluenceSpace`: `listPages`, `listBlogPosts`, `getContent`, `search`, `createPage`,\n  `createBlogPost`\n- `ConfluenceContent`: `getMetadata`/`getContent`, `setContent`/`appendContent`/`setTitle`,\n  `listChildPages`/`createChildPage`, labels, comments, attachments\n  (`downloadAttachment` is capped at 16 KB), `trash`/`restore`\n\nEndpoint split: v2 for spaces, pages, blog posts, child pages, and reading comments/labels/\nattachments; v1 (with graceful degradation) for CQL search, label add/remove, attachment upload,\nand restore-from-trash.\n\n## Approvals, caching & simulation\n\nEvery read calls `ApprovalQueue.authorizeObservation()`; every side-effecting operation\n(create/edit page or blog post, comments, labels, attachment upload, trash/restore) is recorded as\na pending action and submitted via `submitAction()` — nothing reaches Confluence until the Overseer\ncalls `applyAction()`. Reads simulate pending (unapproved) writes so the agent can keep working\nbefore approval (provisional IDs for created content, body/title/label/comment overlays, trashed\nstate). `rejectAction()` / `revertAction()` discard or undo. Content responses are cached in DO\nstorage with a short TTL and invalidated on write. This logic lives in `confluence-actions.ts`.\n\n## Resource pickers\n\nThe Connections UI shows space / page-or-blog / site pickers supplied by\n`startResourceConfigurator()`. Pasting a resource URL — or an agent's\n`requestConnection(resourceUrl)` — also works and pre-fills the picker. For multi-site Atlassian\naccounts the space/page pickers search across **all** accessible sites (each option's value carries\nthe correct site), so pasting a URL works for any connected site too.\n\n## Notes and current limitations\n\n- Body conversion between Confluence storage format and Markdown is best-effort; unsupported macros\n  degrade to a labeled placeholder.\n- `author` / `lastUpdatedBy` expose only an `accountId` (the v2 content API returns IDs, not user\n  objects).\n- CQL search, label add/remove, attachment upload, and restore-from-trash rely on v1 endpoints; if\n  Atlassian removes them, those operations surface a clear \"not available\" error.\n\n## Development\n\n- `pnpm exec vp run -F @gadgets/confluence-gatekeeper build` — build the configurator UIs (`build:configurator`, which this task depends on) and type-check (`tsc`). A Vite+ task rather than a script, so `pnpm --filter` cannot see it.\n- `pnpm --filter @gadgets/confluence-gatekeeper test:run` — run unit tests (URL/CQL parsing, Markdown conversion, v2 converters, action simulation).\n- The Worker is run via the root `pnpm dev-server`, not directly.\n","packages/gatekeeper-context/app/skills/README.md":"# Skills Navigator Direction and Compatibility\n\nThe target information model is deliberately flat: a collection contains atomic skills. Each skill\nencompasses its `SKILL.md` manifest and all related agentic standards files. Those files may be\nstored together in a skill directory, but the navigator presents the complete bundle as one skill,\nnot as a file hierarchy.\n\nSkill titles are presented as human-readable title case. Creation and rename accept that display\nform, then derive the lowercase, hyphen-separated `name` required by the Agent Skills specification\nfor the manifest metadata and directory name.\n\nOrganizational directories are being abandoned. The navigator does not let users create, rename, or\nmove them, and new product behavior must not depend on adding more directory hierarchy. Directory\nnodes remain only to preserve collections that already use older nested layouts.\n\nSelecting a skill still opens the existing collection editor, which remains the general-purpose\ndocument editing surface. Every reader can also open a collection from its row menu, so collections\nthat contain only ordinary documents remain reachable.\n\n## Legacy directories\n\nExisting organizational directories remain usable for backwards compatibility: skills can be\ncreated in them and moved into, out of, or between them, and the directories themselves can be\ndeleted. They cannot be created, renamed, or moved through the navigator, because the forward model\ntreats only skill bundles as movable units.\n\nLegacy directories can also be deleted. Deleting one removes its complete document subtree,\nincluding files the skills-only projection does not display. None of these compatibility behaviors\nmake directories part of the forward-looking data model: no operation can create another\norganizational directory.\n\n## Mutation boundaries\n\n- Users can create, rename, move, and delete complete skill bundles, but cannot create, rename, or\n  move organizational directories.\n- Skill mutations use the skill-specific RPC methods rather than composing generic document calls in\n  the client. Each operation validates the manifest and applies all storage changes atomically.\n- Renaming or moving a non-root skill operates on its complete directory, preserving supporting\n  files. Destination conflicts fail rather than overwrite existing documents.\n- A root-level `SKILL.md` is supported, but it has no exclusive directory subtree: moving, renaming,\n  or deleting it affects only that manifest. Treating every other root document as its support file\n  would risk moving or deleting unrelated skills and documents.\n- A destination must be the collection root or an existing legacy directory outside every skill\n  bundle. Skills cannot be nested inside other skills, even if an RPC caller supplies a path the UI\n  would not offer.\n\n## Permissions and sources\n\nCollection actions follow `getContextCollectionPermissions`. Skill and directory actions are offered\nonly when the account can edit the collection and the collection is web-backed. Git-backed\ncollections remain read-only at the skill level, but editors, owners, and admins can still edit\ntheir collection settings, manage Git tokens, and refresh them. Sharing is offered on editable\nprivate collections, and deleting the whole collection only to owners and admins. Viewers keep\nnavigation, previews, downloads, and opening the collection. Otherwise read-only collections expose\nno mutation actions and are not offered as Add Skill targets. If loading a collection's documents\nor permissions fails, that collection fails closed as non-writable in the current view.\n\nCollection labels come from the viewer's live membership: deployment-public collections show\nOrganization, an owned collection shows Private or, once anyone else can read it, Shared, and a\ncollection someone else shared shows Shared with you and the viewer's role. Collection titles need\nnot be unique. Labels describe access, not identity: same-titled collections can also have identical\nlabels. UI selection and API operations use collection IDs rather than titles.\n\n## Sharing\n\nThe Share dialog adds people through the Workshop's host-owned person picker\n(`useSelectUsers()` in `bridge.ts`). Each picker session gets its own receiver that fixes the\ncollection and the role chosen when the picker opened, and shares with each picked person as soon as\nthey are delivered; a backend refusal rejects that delivery, so the picker shows the message in a\nstatus notice that names the person. Cancelling the picker does not undo shares already delivered.\nStopping the wait is not a sharing error and does not undo a delivery already running. The dialog\nreloads sharing and permissions when the picker closes and again when a late delivery settles,\nincluding rejected deliveries that may have committed a grant before cleanup failed. A delivery that\nsettles after the Share dialog closes also refreshes the parent collection metadata or listing.\n\nMutation controls remain disabled until refreshed sharing and permissions arrive together. If that\nreload fails, the previous rows remain visible for context but cannot be used to mutate access;\n**Retry** must successfully refresh them first. Backend cleanup errors remain visible independently\nof reload failures.\n\n`Share “<collection title>”` starts with an **Add users with role** dropdown and **Select users**\nbutton. Compact access rows show the current account as **You** with a generic profile icon, then\n**Anyone in organization** (No access or Can view), then a dashed divider and the other members.\nThe add-users role and access controls use ghost-button dropdown menus with right-aligned selection\ncheckmarks; member menus contain role changes and a separate destructive **Remove access** action,\nor **Leave collection** for the current account. The last owner cannot leave or be demoted. A neutral\n**Done** button is the only footer action and stays visible while the member list scrolls. The\nWorkshop's person picker uses the same neutral **Done** action.\n\nAnyone a collection was shared with as a viewer or editor can leave it from the collection's row menu,\nincluding viewers, who cannot open the Share dialog. Owners leave from the Share dialog, which knows\nwhether another owner remains. Both surfaces open the same confirmation before leaving. It warns\nthat the last member leaving an ownerless collection permanently deletes the collection and its\ndocuments, even when organization-wide viewing is enabled. Leaving reconciles the parent even if\nGit token cleanup rejects after the membership was removed, while retaining the cleanup error rather\nthan presenting a rejected operation as success.\n\nThe server remains the authority for every mutation. The client-side checks control affordances and\nprovide earlier feedback; they are not the security boundary.\n\n## Move limitations\n\nDrag-and-drop is intentionally limited to one collection. Within that collection, existing legacy\ndirectories remain valid destinations for the compatibility reasons above. A same-collection move\ncan update the skill directory and all supporting documents in one Durable Object storage\ntransaction.\n\nCross-collection moves are disabled. Implementing one as separate client-side copy and delete calls\ncould overwrite concurrent edits or leave duplicated or partially moved data after a failure. A\nfuture backend operation must authorize both collections, reject read-only sources and destinations,\npreserve the complete skill subtree, fail on destination conflicts, and define recoverable or atomic\nsemantics across the two collection Durable Objects before the UI enables these drops.\n\n## Upload limitations\n\nThe Skills Navigator accepts standalone Markdown files and folders. Each loose `.md` or `.markdown`\nfile becomes one skill. A `SKILL.md` file defines a bundle and imports the other files below its source\ndirectory as related skill files; nested bundles are imported as separate top-level skills. Browser\nfolder selection is based on the non-standard but widely supported `webkitdirectory` input. Chrome's\ndirectory APIs fail or terminate the management app's opaque-origin iframe, so complete folders should\nbe selected with the folder picker. Supporting folder drops requires reading them in the Workshop host\nand passing the files into the sandboxed app.\n\nSkill manifests are created through the skill-specific RPC, but there is currently no backend RPC for\natomically creating a complete bundle. Supporting files are written only after the manifest succeeds.\nIf one of those writes fails, the user is told which skill was only partially uploaded, but the created\nmanifest and any successful related files remain. A future bundle-oriented RPC is required for true\nall-or-nothing uploads.\n\n## Skill upload format contract\n\nThe upload dialog accepts [Agent Skills](https://agentskills.io/specification) and\nlegacy Markdown. Import acceptance is deliberately broader than validation of the\nstored `SKILL.md`.\n\n### Recognizing metadata\n\nAn import opts into skill metadata only when it begins with a complete `---`\nfenced block that parses as a YAML mapping containing `name` or `description`.\nThe delimiter must be unindented; a BOM, CRLF, and trailing fence whitespace are\naccepted. Indented delimiters within YAML block scalars do not close the block.\n\nEverything else is legacy Markdown, including ordinary text, horizontal rules,\nunclosed fences, invalid YAML, sequences, and mappings without skill metadata keys.\nThese inputs are ambiguous, so preserve the entire source as instruction text\nrather than guessing which part to discard. This also applies to imports named\n`SKILL.md`; that filename groups supporting files but does not require a legacy\nimport to already be a valid skill.\n\n### Review and conversion\n\n- Recognized metadata supplies the editable name and description. Missing or\n  non-string fields are left blank for correction; invalid strings are not replaced\n  with inferred defaults. Extra metadata is retained when writing the manifest.\n- Legacy Markdown gets a filename-derived name and a suggested description for\n  user review. Conversion adds new frontmatter and preserves the entire original\n  instruction text, including leading whitespace and line endings.\n- Recognized frontmatter is serialized after edits, but its Markdown body is\n  preserved verbatim. The metadata syntax itself need not round-trip byte-for-byte.\n- The existing review form checks the final name and description before upload;\n  the server's `parseSkillManifest` validates the resulting stored manifest.\n\n`readSkillUploadSource` in `skillUpload.ts` is the single recognition decision used\nby both inference and conversion. New import behavior should be expressed here\nand covered by the table-driven round-trip tests, rather than added as an independent\ndelimiter heuristic in a caller.\n\nThis is not a claim of full specification validation: the existing server validates\nrequired fields, permits names differing from the parent directory, and does not\nvalidate every optional specification field.\n","packages/gatekeeper-email/README.md":"# Gatekeeper Email\n\nThis package provides an email receiving gatekeeper for Gadgets. It allows a Gadget to receive inbound emails at an address like `<name>@<host>` via a hook.\n\nUnlike most gatekeepers which connect to external services, this gatekeeper *is* the service -- it implements a Cloudflare Email Worker that receives mail directly.\n\n## How It Works\n\nThe email gatekeeper uses the URL scheme `http://localhost:8787/gatekeeper/email/mailbox/<name>` to represent the email address `<name>@<host>`, where `<host>` is the domain configured to route email to this worker.\n\nWhen a Gadget is connected to an email address:\n\n1. The Gadget's code exports a `WorkerEntrypoint` that implements the `EmailHook` interface.\n2. The coding agent calls `setBindingHook` to connect the hook to the binding.\n3. The gatekeeper stores the hook reference in a Durable Object keyed by the email username.\n4. When an email arrives, the Email Worker routes it to the appropriate DO, which invokes the Gadget's hook with the parsed email content.\n\nEmails are parsed using [postal-mime](https://www.npmjs.com/package/postal-mime), so the hook receives structured data (from, to, subject, text body, HTML body, attachments) rather than raw MIME.\n\n## Creating a Binding\n\n1. Start the dev server (see root README).\n2. Create or open a Gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose **Email Mailbox**.\n6. Enter the mailbox local part, e.g. `myinbox`.\n7. This represents the email address `myinbox@<host>`.\n8. The binding will appear as `EMAIL` (the suggested name).\n\nMailbox names are canonicalized to lowercase. They may contain letters, numbers, dots, underscores, plus signs, or hyphens, and cannot start or end with a dot or contain consecutive dots.\n\nThe binding provides an `EmailSession` interface with a single method:\n\n```typescript\ninterface EmailSession {\n  getAddress(): Promise<string>;  // e.g. \"myinbox@example.com\"\n}\n```\n\nTo actually receive emails, the Gadget must implement a hook. Ask the coding agent to set up a hook for the email binding, or do it manually:\n\n```typescript\n// In the Gadget's code:\nimport { WorkerEntrypoint } from \"cloudflare:workers\";\n\nexport class MyEmailHandler extends WorkerEntrypoint {\n  async receiveEmail(email) {\n    // email has: from, to, cc, subject, date, text, html, attachments\n    console.log(`Got email from ${email.from.address}: ${email.subject}`);\n    // Store it, process it, etc.\n  }\n}\n```\n\nThen prompt your coding agent to use the `setBindingHook` tool to connect it:\n- Binding: `EMAIL`\n- Export name: `MyEmailHandler`\n\n## Local Development\n\nIn local development, the gatekeeper is served under the path `/gatekeeper/email` on `localhost:8787`. We don't actually support receiving real SMTP email locally.\n\n### Sending a Test Email\n\nIn local dev, wrangler exposes a `/cdn-cgi/handler/email` endpoint that simulates inbound email. You can POST raw email content to it using curl.\n\nWith the dev server running (`pnpm run dev-server` from the repo root), send a test email:\n\n```bash\ncurl -X POST 'http://localhost:8787/cdn-cgi/handler/email' \\\n  --url-query 'from=sender@example.com' \\\n  --url-query 'to=myinbox@example.com' \\\n  --header 'Content-Type: application/json' \\\n  --data-raw 'From: \"Alice\" <sender@example.com>\nTo: myinbox@example.com\nSubject: Hello from local dev\nContent-Type: text/plain; charset=\"utf-8\"\nDate: Mon, 16 Feb 2026 12:00:00 +0000\nMessage-ID: <test-123@example.com>\n\nThis is a test email body.'\n```\n\nThe `to` address's local part (`myinbox`) determines which `EmailAddress` Durable Object receives the email. Make sure:\n- You have a Gadget with an Email Mailbox binding for `myinbox@<host>`\n- The binding has a hook connected via `setBindingHook`\n\nIf no hook is configured for that address, the email will be rejected.\n\n## Production Configuration\n\nIn production, you need to set up [Cloudflare Email Routing](https://developers.cloudflare.com/email-routing/) to forward emails to this worker.\n\n### Step 1: Deploy the email gatekeeper and set env.BASE_URL\n\nSet the `BASE_URL` environment variable to the full base URL (protocol + host + optional path) at which the email gatekeeper's fetch handler is served. No trailing slash. For example:\n\n```\n# Deployed as its own worker at the root:\nBASE_URL=https://gatekeeper-email.example.workers.dev\n\n# Or co-hosted on the same domain as the main app under a path:\nBASE_URL=https://app.example.com/gatekeeper/email\n```\n\nAll occurrences of `http://localhost:8787/gatekeeper/email` in the doc above will in production be replaced by this `BASE_URL` value.\n\n### Step 2: Enable Email Routing\n\n1. In the Cloudflare dashboard, go to your domain's **Email Routing** settings.\n2. Follow the setup wizard to enable Email Routing and configure the required DNS records (MX, SPF, etc.).\n\n### Step 3: Create an Email Worker Route\n\n1. Go to **Email Routing** > **Email Workers**.\n2. Create a route that matches the addresses you want to handle. For example:\n   - **Custom address**: `*@yourdomain.com` (catch-all) or specific addresses like `gadget-*@yourdomain.com`\n   - **Action**: Send to a Worker\n   - **Worker**: Select the deployed `gatekeeper-email` worker\n3. Alternatively, you can configure this in your Worker config for deployment.\n\n### How It Fits Together\n\n```\nInternet email → Cloudflare Email Routing → gatekeeper-email worker\n                                               │\n                                               ├── email() handler parses recipient\n                                               │\n                                               ▼\n                                          EmailAddress DO (per username)\n                                               │\n                                               ├── loads stored hook Fetcher\n                                               │\n                                               ▼\n                                          Gadget's hook entrypoint\n                                          (via Overseer loopback)\n```\n\nEach email address maps to a Durable Object named by its local part. The DO stores the hook `Fetcher` in KV storage, which persists across requests. When an email arrives, the DO loads the hook and calls `receiveEmail()` with the parsed email data.\n","packages/gatekeeper-github/README.md":"# Gatekeeper GitHub\n\nThis package provides GitHub OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `github` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitHub\" appears on the login page. Sign-in requests only minimal scopes (`read:user user:email`)\n  to read the account's **primary verified email**, which becomes the user's identity. The sign-in\n  grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects GitHub (or signs in and later connects it), the full scopes\n  (`repo read:user user:email`) are requested so gadgets can access repositories, issues, and pull\n  requests on the user's behalf.\n\n> **Use a GitHub _OAuth App_, not a GitHub _App_.** Only OAuth Apps honor the OAuth `scope`\n> parameter, which is what makes the minimal-on-login / full-on-connect behavior work, and the\n> `user:email` scope is what grants access to the user's verified email. A **GitHub App** (client id\n> starting with `Iv…`) ignores `scope` entirely and will return `Resource not accessible by\n> integration` for the email lookup unless you separately grant it the **Email addresses** account\n> permission — and even then login won't be minimal-scope. See [Using a GitHub App\n> instead](#using-a-github-app-instead) if you must.\n\n## Auto-approval\n\nEvery queued action carries a stable kind (`github.<action>`, e.g. `github.push`), and the user may\nopt in to approving a kind automatically on a connection. Every action is eligible, including\nclosing and reopening, `requestChanges` reviews and force pushes, except two that always wait for a\nperson: **merging a pull request** and **an approving review**. An approval shares the\n`github.postReview` kind with comment and request-changes reviews, but is never auto-approvable,\nso enabling that kind does not cover it. A force push has its own kind (`github.forcePush`), so\nenabling ordinary pushes does not let history be rewritten. Excluded actions are still approved\nby hand as before. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit (an issue binding, for example, never lists pushes).\n\n## Setting Up GitHub OAuth Credentials\n\nIf you're running this project locally and want to use GitHub integrations, you'll need to create\nyour own GitHub OAuth app. This guide walks you through the process.\n\n### Step 1: Create a GitHub OAuth App\n\n1. Go to [GitHub Settings > Developer settings > OAuth Apps](https://github.com/settings/developers)\n   (**OAuth Apps**, not \"GitHub Apps\").\n2. Click **New OAuth App**\n3. Fill in the application details:\n   - **Application name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - **Homepage URL**: `http://localhost:3000`\n   - **Authorization callback URL**: `http://localhost:8787/gatekeeper/github/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n4. Click **Register application**\n\nGitHub enables expiring user tokens by default for OAuth apps registered since August 2026\n(**Optional features** in the app's settings). The gatekeeper supports both kinds: it refreshes an\nexpiring token shortly before its eight hours run out, and asks the user to reconnect only once\nGitHub rejects the refresh token itself (unused for six months, or revoked).\n\n### Step 2: Generate a Client Secret\n\nOn the app's settings page after registration:\n\n1. Click **Generate a new client secret**\n2. Copy the **Client ID** and the generated **Client secret** — you'll need both in the next step.\n\n### Step 3: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-github/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 4: (Optional) Enable GitHub sign-in\n\nTo offer \"Continue with GitHub\" on the login page, add `github` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nUsers are keyed by their GitHub primary verified email, so the OAuth App must be able to read it\n(the `user:email` scope, requested automatically). No extra setup is needed for an OAuth App.\n\n### Step 5: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitHub resource type: repository, issue, or pull request.\n6. If prompted, connect a GitHub account.\n7. You should be redirected to GitHub's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the repository, issue, or pull request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitHub resource scope.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Event hooks (optional)\n\nGadgets can subscribe to a repository's issue, pull request, comment, review, push and tag events,\nor to one issue's or pull request's (`subscribe()` in `src/types.d.ts`). GitHub delivers them through\na repository webhook, so this needs a public URL:\n\n1. Set `WEBHOOK_ORIGIN` on this worker to the public origin GitHub should deliver to, e.g.\n   `https://gadgets.example.com`. Without it, hooks are refused and everything else works as\n   before.\n2. If the deployment sits behind Cloudflare Access, add a bypass for\n   `/gatekeeper/github/webhook/*`; the worker accepts only deliveries signed with a webhook's\n   secret instead.\n\nEnabling a hook adds a webhook to the repository, delivering to\n`${WEBHOOK_ORIGIN}/gatekeeper/github/webhook/<id>`, so GitHub allows it only if the connected\naccount is an admin of the repository. An account's hooks on one repository share its webhook,\nwhich is deleted when the last of them is disabled, or when the account is disconnected. Should\nGitHub fail to delete it, the hourly check tries again, and a disconnect that leaves it fails,\nkeeping the account connected, with the token that can delete it, until disconnecting again finds\nit gone. Each webhook signs its deliveries with a secret only GitHub and this deployment hold; a\ndelivery of a payload already received, or of an event more than a day old, is ignored; and an\nevent is delivered only while the account can still read the repository, since a webhook outlives\nits creator's access.\n\nThe repository's other admins can see the webhook, though not its secret. Every hour the worker\nchecks each webhook on GitHub, and restores one that someone has deleted, deactivated, pointed\nelsewhere, switched to form-encoded payloads or unverified TLS, or given other events, another\nsecret or none: to stop the deliveries, disable the hooks. The same check moves them to a new\n`WEBHOOK_ORIGIN` within the hour. GitHub never retries a failed delivery itself, so the same check\nasks it to redeliver what it failed to deliver in the past two hours: up to 20 an hour for each\naccount, shared among its repositories so that a busy one can't crowd out the rest. That includes\nwhat was refused while the webhook was misconfigured, by this worker or by another receiver, as\nGitHub redelivers to the webhook as it is configured now. Failures beyond that, or older than two\nhours, are lost, as is anything another receiver accepted while the webhook pointed there. A\ndelivery that reaches the worker is retried until the gadget's hook accepts it, eight attempts in\nall. When every recent delivery failed, as when Cloudflare Access turns GitHub away, the check logs\n`hooks.webhook.deliveries.failing` with the statuses GitHub got.\n\nFor local development, GitHub cannot reach `localhost`: run a tunnel to the dev server (e.g.\n`cloudflared tunnel --url http://localhost:8787`) and set `WEBHOOK_ORIGIN` to its origin in the\nroot `.dev.vars`.\n\nWhat this relies on GitHub for is checked against GitHub itself by `pnpm test:contract`\n(`__tests__/contract`), which the `github-contract` workflow runs weekly and on demand once the\n`CONTRACT_GITHUB_TOKEN` secret and `CONTRACT_GITHUB_REPO` variable name a disposable private\nrepository (the token fine-grained, with access to that repository alone; see the workflow):\n- a reader without access gets a 404, whatever ETag it sends;\n- a redelivery uses the webhook's current URL, encoding and secret;\n- the delivery log is newest first;\n- the account is the sender of what it does;\n- each event's payload parses.\n\n## Using a GitHub App instead\n\nIf you must use a **GitHub App** (client id `Iv…`) rather than an OAuth App, be aware:\n\n- GitHub Apps **ignore the OAuth `scope` parameter**. Permissions are fixed in the App's\n  configuration and apply to every user authorization, so sign-in cannot be limited to minimal\n  scopes — it grants whatever the App is configured for.\n- To read the user's email for sign-in, you must grant the App the **Email addresses** account\n  permission: App settings → **Permissions & events** → **Account permissions** → **Email\n  addresses → Read-only** → save. Existing users must then re-authorize (re-run the sign-in flow) to\n  approve the added permission. Without it, the email lookup fails with `Resource not accessible by\n  integration` and sign-in is rejected.\n\nFor these reasons an **OAuth App is recommended** for sign-in.\n\n## Troubleshooting\n\n### \"Resource not accessible by integration\" error\n\nYou're using a **GitHub App** that lacks the **Email addresses** account permission, so the email\nlookup required for sign-in is forbidden. Either switch to an OAuth App (recommended) or grant the\nApp the Email-addresses permission and re-authorize — see [Using a GitHub App\ninstead](#using-a-github-app-instead).\n\n### \"redirect_uri_mismatch\" error\n\nThe callback URL in your OAuth app settings doesn't match what the app is sending. Double-check that you set it to exactly `http://localhost:8787/gatekeeper/github/oauth` (no trailing slash, `http` not `https`).\n\n### \"bad_verification_code\" error\n\nThe authorization code has expired or already been used. Return to Gadgets and try connecting again.\n\n### \"Not configured\" page during authorization\n\nYour `CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at `packages/gatekeeper-github/.env` and contains both values, then restart the dev server.\n","packages/gatekeeper-gitlab/README.md":"# Gatekeeper GitLab\n\nThis package provides GitLab OAuth integration for Gadgets, for gitlab.com and for self-hosted\nGitLab instances. It serves two purposes:\n\n- **Sign-in:** when `gitlab` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitLab\" appears on the login page. Sign-in requests only the `read_user` scope to read the\n  account's **confirmed primary email**, which becomes the user's identity. The sign-in grant is\n  transient (discarded right after the email is read).\n- **Connections:** when a user connects GitLab (or signs in and later connects it), the `api` and\n  `write_repository` scopes are requested so gadgets can read and manage projects, issues, and\n  merge requests, and pull from and push to repositories, on the user's behalf.\n\nIt is a mirror of the GitHub gatekeeper: the same three resource granularities (project, issue,\nmerge request), the same worktree integration (commit ids advertised, `gitPull()` over git\nsmart-HTTP, `push()` as a queued action), and the same review and merge flows expressed in GitLab's\nown terms. See `plans/gitlab-gatekeeper.md` for the design record.\n\n`gitPull(oids, options)` returns a `GitPullTransfer`: a one-shot raw PACK stream and a separate\nterminal status. Lazy DO reads consume it through the bounded object cache. The same capability\nsupports native Git hydration, but does not enable it here: the container consumer ships in a\nseparate kernel worktree-replica bootstrap change. That consumer must bind the bootstrap to an\nauthorized immutable commit, request its complete tree at depth one, and import the pack without\npopulating the DO cache. `gitPull` is kernel-facing, not a gadget-facing arbitrary Git proxy;\nthe kernel selects objects from recorded provenance, not a provider-side advertisement validator.\nNo encoded Git commands, ref discovery, or arbitrary clone URLs cross this interface. OAuth and\noptional Cloudflare Access credentials remain in the gatekeeper's transport, including on\nself-hosted instances.\n\n## Auto-approval\n\nAs in the GitHub gatekeeper, every queued action carries a stable kind (`gitlab.<action>`), and\nevery one may be auto-approved once the user opts in to its kind, including closing and reopening,\nresolving and reopening diff threads, `requestChanges` reviews and force pushes (under their own\n`gitlab.forcePush` kind). **Merging a merge request** and **an approving review** always wait for\na person: an approval shares the `gitlab.postReview` kind with the other reviews but is never\nauto-approvable. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit.\n\n## Setting up GitLab OAuth credentials\n\nIf you're running this project locally and want to use GitLab integrations, you'll need to create\nyour own GitLab OAuth application.\n\n### Step 1: Create a GitLab application\n\n1. On GitLab, go to **User settings → Applications** (or, to share the application with a team,\n   a group's **Settings → Applications**).\n2. Fill in the application details:\n   - **Name**: anything (e.g. \"Gadgets Local Dev\")\n   - **Redirect URI**: `http://localhost:8787/gatekeeper/gitlab/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n   - **Confidential**: checked\n   - **Scopes**: `api`, `write_repository`, `read_user`\n3. Save the application, and copy the **Application ID** and **Secret**.\n\n> **Why `write_repository` when `api` is \"everything\"?** GitLab's documentation grants Git over\n> HTTPS to OAuth tokens only through the `read_repository`/`write_repository` scopes; `api` covers\n> the REST API. Pushing commits from a worktree needs `write_repository`, so both are requested.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-gitlab/.env`):\n\n```bash\nCLIENT_ID=your-application-id-here\nCLIENT_SECRET=your-application-secret-here\n```\n\nAlternatively, export `GITLAB_CLIENT_ID` and `GITLAB_CLIENT_SECRET` in your shell; `pnpm dev-server`\nmaps them into the worker's `CLIENT_ID`/`CLIENT_SECRET`.\n\nTo develop against a self-hosted instance, set `GITLAB_URL` (and `GITLAB_API_URL`,\n`CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` as the instance needs -- see\n[Self-hosted instances](#self-hosted-instances)) in the root `.dev.vars` or your shell;\n`pnpm dev-server` passes them through to the worker, so nothing tracked has to change.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable GitLab sign-in\n\nTo offer \"Continue with GitLab\" on the login page, add `gitlab` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,gitlab\n```\n\nUsers are keyed by their GitLab primary email, which GitLab only makes primary once confirmed. An\naccount whose primary email is unconfirmed cannot sign in.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitLab resource type: project, issue, or merge request.\n6. If prompted, connect a GitLab account.\n7. You should be redirected to GitLab's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the project, issue, or merge request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitLab resource.\n\n## Self-hosted instances\n\nBy default the gatekeeper talks to `https://gitlab.com`. Point it at another instance with these\nvariables on the worker:\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `GITLAB_URL` | The instance users visit: the OAuth authorization page, links in results, and the URLs of connectable resources. | `https://gitlab.com` |\n| `GITLAB_API_URL` | The origin the Worker sends requests to (REST, token endpoints, git over HTTPS), when it differs from `GITLAB_URL`. | `GITLAB_URL` |\n| `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` (secrets) | A Cloudflare Access **service token**, attached to every Worker→GitLab request as `CF-Access-Client-Id`/`CF-Access-Client-Secret`. Set both or neither. | unset |\n\nBoth URLs must be `https` origins (scheme and host, no path): every request to the API origin\ncarries a user's token, and the OAuth exchanges carry the client secret. Plain `http` is accepted\nonly on `localhost`, `127.0.0.1` or `[::1]`, for a GitLab run on your own machine. A GitLab\nserved under a relative URL root (`https://example.com/gitlab`) is not supported; the worker\nrefuses the URL at its first request rather than sending tokens to the wrong path.\n\nThe typical Access-protected layout is `GITLAB_URL=https://gitlab.example.com` (behind Access for\nbrowsers) and `GITLAB_API_URL=https://gitlab-access.example.com` (a hostname whose Access\napplication admits the service token). The Access application must admit the token on the\n`/api/v4/*`, `/oauth/*`, **and** `/<group>/<project>.git/*` paths — the last is where git fetch and\npush go, and a policy scoped to the API alone makes worktree pulls fail with a login page.\n\nRegister the OAuth application on the instance itself, with the same redirect URI and scopes as\nabove.\n\nA review that requests changes needs GitLab 19.2 or later: earlier versions' review-publishing\nendpoint ignores the reviewer state without an error, so on them the gatekeeper refuses a\n`requestChanges` review rather than post it as a plain comment. Comment and approve reviews work\non every version.\n\n## Worker Preview OAuth callbacks\n\nA Worker Preview's hostname cannot be registered with the GitLab application, so a deployment\nthat tests on previews registers one **stable** callback and relays the result to the preview\nthat started the flow (`@gadgets/gatekeeper-kit/preview-oauth`, as `gatekeeper-google` does).\nGive the stable Worker and its previews the same `OAUTH_STATE_SIGNING_SECRET` and\n`OAUTH_ALLOW_PREVIEW_REDIRECTS=true`, and set the fixed redirect on previews only:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-gitlab.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the application's redirect URI. A preview sends that URI to\nGitLab and carries its own callback in signed, short-lived state; the stable Worker accepts\nreturn URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only GitLab's\nresult and the state. The preview then exchanges the code with the same redirect URI it\nauthorized under. Normal deployments omit all three settings and use `${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Previews\ndo not inherit the stable Worker's secrets: set `CLIENT_ID`, `CLIENT_SECRET`,\n`OAUTH_STATE_SIGNING_SECRET` and, for an Access-protected instance, `CF_ACCESS_CLIENT_ID` /\n`CF_ACCESS_CLIENT_SECRET` on the stable Worker (`wrangler secret put`) **and** in its Previews\nBase configuration (`wrangler preview base-config secret put`); a preview created afterwards\nreceives them.\n\n## Troubleshooting\n\n### \"The GitLab project has been renamed or transferred\"\n\nThe project a connection was bound to has moved. GitLab answers the old path with a redirect that\nthe gatekeeper deliberately does not follow (following it would turn writes into reads). Re-bind\nthe connection to the project's new path.\n\n### \"GitLab credentials have expired or been revoked\"\n\nGitLab access tokens last two hours and are refreshed automatically; the refresh token behind them\nis single-use and rotates on every refresh. When GitLab refuses an access token the gatekeeper\nfirst refreshes past it. This error means the refresh token itself was rejected (`invalid_grant`)\n— typically because the application was revoked in GitLab, or the account sat idle past the\ninstance's refresh-token lifetime. Reconnect the account. A token endpoint that is unreachable,\nanswers 5xx, or is turned away by Cloudflare Access fails the request with \"Could not refresh\nGitLab credentials\" instead and leaves the connection intact.\n\n### \"GitLab credentials were renewed during this request\"\n\nGitLab invalidates an access token when it issues its replacement, so a request in flight while\nthe gatekeeper refreshed can be refused. Reads are rerun once with the new token; anything else\nfails with this message and is safe to retry.\n\n### The redirect URI does not match\n\nThe **Redirect URI** on the GitLab application must be exactly\n`<PUBLIC_BASE_URL>/gatekeeper/gitlab/oauth` (no trailing slash, `http` for local dev) -- or, on a\nWorker Preview, the stable Worker's `OAUTH_REDIRECT_URI` (see above).\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at\n`packages/gatekeeper-gitlab/.env` and contains both values, then restart the dev server.\n\n### \"GitLab did not answer the request: the API redirected to …\"\n\nEvery REST call is answered with a redirect to a login page: the instance is behind Cloudflare\nAccess and the Access application in front of `GITLAB_API_URL` does not admit the gatekeeper's\nservice token, or `CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET` are not set on the worker (see\n[Self-hosted instances](#self-hosted-instances)). Distinct from the rename message above: the\ngatekeeper tells them apart by where the redirect points.\n\n### git fetch or push fails with a redirect or an HTML page\n\nThe same cause on the `.git/` paths: the Access application admits the service token for\n`/api/v4` but not for the repository, or the token is not configured.\n\n### \"This collaborator does not have read access to the GitLab project …\"\n\nSomeone sharing a workspace is not a member of the project it is bound to at **Reporter** or\nabove — or is a member only at Guest. Being able to open the project in GitLab is not enough,\neven on an `internal` or `public` one: a project can hold confidential issues and internal notes\nthat only members at those roles see, and the workspace may have read them. The remedy is for a\nproject maintainer to add the collaborator as a Reporter (inherited group membership counts).\n","packages/gatekeeper-google/README.md":"# Gatekeeper Google\n\nThis package provides Google OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `google` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Google\" appears on the login page. Sign-in requests only minimal scopes (`openid`,\n  `userinfo.email`, `userinfo.profile`) to read the account's **verified email** (`email_verified`),\n  which becomes the user's identity.\n  The sign-in grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects Google (or signs in and later connects it), the scopes for\n  the selected resources (Gmail, Docs, Sheets, Slides, Drive, Calendar, or BigQuery — see below) are requested\n  so gadgets can access those APIs on the user's behalf.\n\nA single Google OAuth client is used for both. Set it up as follows.\n\n## Setting Up Google OAuth Credentials\n\nIf you're running this project locally and want to use Google API integrations, you'll need to create your own Google OAuth credentials. This guide walks you through the process step-by-step.\n\n### Step 1: Create a Google Cloud Project\n\n1. Go to the [Google Cloud Console](https://console.cloud.google.com/)\n2. Sign in with your Google account\n3. Click the project dropdown at the top of the page (it may say \"Select a project\" or show an existing project name)\n4. Click **New Project** in the top-right of the popup\n5. Enter a project name (e.g., \"Gadgets Local Dev\")\n6. Click **Create**\n7. Wait for the project to be created, then select it from the project dropdown\n\n### Step 2: Enable Required APIs\n\nYou'll need to enable the Google APIs that you want to use. Currently supported: Gmail, Google Docs, Google Sheets, Google Slides, Google Drive, Google Calendar, Google Chat, and BigQuery.\n\n1. In the left sidebar, go to **APIs & Services** > **Library** (or [click here](https://console.cloud.google.com/apis/library))\n2. Search for \"Gmail API\"\n3. Click on **Gmail API** in the results\n4. Click **Enable**\n5. Go back to the Library, search for \"Google Docs API\"\n6. Click on **Google Docs API** in the results\n7. Click **Enable**\n8. Go back to the Library, search for \"Google Drive API\"\n9. Click on **Google Drive API** in the results\n10. Click **Enable**\n11. Go back to the Library, search for \"Google Sheets API\"\n12. Click on **Google Sheets API** in the results\n13. Click **Enable**\n14. Go back to the Library, search for \"Google Slides API\"\n15. Click on **Google Slides API** in the results\n16. Click **Enable**\n17. Go back to the Library, search for \"Google Calendar API\"\n18. Click on **Google Calendar API** in the results\n19. Click **Enable**\n20. Go back to the Library, search for \"BigQuery API\"\n21. Click on **BigQuery API** in the results\n22. Click **Enable**\n23. For Chat, enable **Google Chat API**, plus **People API** so direct messages and group chats can be named when Chat omits a participant's name, and so a whole-account Chat connection can search the organization's directory and confirm that everyone in a conversation it starts belongs to the organization.\n24. On the Google Chat API's **Configuration** tab, set an app name, avatar URL, and description, turn off **Interactive features**, and click **Save**. Reads work without this, but Google refuses every Chat send, edit, and reaction until a Chat app is configured.\n\nThe Google Drive API powers the Docs, Sheets, and Slides resource pickers, Drive discovery, and Drive scope checks. Native document, spreadsheet, or presentation content opened from a Drive binding is read through the Google Docs, Google Sheets, or Google Slides API. Direct Google Doc reads and edits still go through the Docs API, direct spreadsheet reads and edits go through the Sheets API, and direct presentation reads and edits go through the Slides API.\n\n### Step 3: Configure the OAuth Consent Screen\n\nBefore creating credentials, you must configure how the consent screen appears to users.\n\n1. In the left sidebar, go to **APIs & Services** > **OAuth consent screen** (or [click here](https://console.cloud.google.com/apis/credentials/consent))\n2. Select **External** as the user type (unless you have a Google Workspace organization and want to restrict to internal users only)\n3. Click **Create**\n4. Fill in the App Information:\n   - **App name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - Details are largely optional / irrelevant here, since this app will run it testing mode.\n   - Click **Save and Continue**\n5. On the Scopes page, you can just click **Save and Continue** without adding anything. The scopes are specified by the OAuth request itself, not the console configuration. (The console's scope UI is only relevant if you later want to publish your app for Google's verification review.)\n\nThe scopes requested depend on what the user is doing. **Sign-in** requests only the identity\nscopes (`openid`, `userinfo.email`, `userinfo.profile`). **Connecting** Google for capabilities\nrequests scopes granularly per resource type, not all at once: connecting a Gmail mailbox asks\nonly for the Gmail scopes, a Google Doc only for the Docs scopes, and so on (identity is always\nincluded). Across all resource types, the gatekeeper can request:\n\n- `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account.\n- `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending.\n- `documents` for direct Google Docs reads, edits, and creation; `documents.readonly` for native Docs opened from account-wide, folder, or exact-file Drive bindings.\n- `drive.metadata.readonly` for the Docs, Sheets and Slides pickers. Google classifies this as a restricted scope.\n- `drive.readonly` for account-wide, folder, and exact-file Drive bindings: the Drive picker, discovery, file metadata, folder descendant proofs, native-file scope checks, and downloading a PDF to read its text with `readPdf()`. Google classifies this as a restricted scope too, so every Drive resource here needs restricted-scope verification.\n- `drive.readonly` and `drive.file` for the Office import grant (`https://docs.google.com/import`), which converts an Office file into a Google Docs, Sheets or Slides copy (see [Office files](#office-files)). Only the connect dialog's **Convert** button asks for it, by incremental consent, the first time an account converts a file; using a copy made before reads only metadata and asks for nothing. Once held, it is part of the account's recorded grants, so a reconnect asks for it again. `drive.readonly` lets the gatekeeper see a source file this OAuth client did not create, which `drive.file` alone cannot, and `drive.file` lets it create the copy. The grant is private to the gatekeeper: it is not a resource, so it is never offered on connect, listed, bound, or shown among an account's granted resources, and admins cannot turn it off separately from Docs, Sheets and Slides. Once held, `drive.readonly` lets the account's token read any Drive file, so bindings stay limited by the gatekeeper's code rather than by the token. A verified OAuth app must add both scopes to its consent screen, and `drive.readonly` is a restricted scope.\n- `drive.readonly` for the shared-drives grant (`https://drive.google.com/drive/shared-drives`), which lets the Drive picker list the shared drives the account belongs to, since Drive's `drives.list` accepts no narrower scope. Only the picker's **Allow access** button asks for it, by incremental consent, the first time an account opens Shared drives; once held, a reconnect asks for it again. It is private to the gatekeeper as the Office import grant is, and an account holding that grant is still asked once, since a private grant counts only once it has been asked for, not because its scopes happen to be held.\n- `spreadsheets` to read metadata, bounded cell ranges and their formatting from directly selected spreadsheets, edit their values, sheets, rows, columns and formatting with approval, and create spreadsheets. The switch from `spreadsheets.readonly` retracted every existing Sheets grant, including one derived from `drive.readonly`: owners re-consent the next time they connect a spreadsheet, and collaborators observing an existing Sheets binding are asked to grant read-write `spreadsheets` the next time they open its workspace. A `spreadsheets` grant covers the Drive resources' `spreadsheets.readonly`.\n- `spreadsheets.readonly` to read native Sheets opened from account-wide, folder, or exact-file Drive bindings.\n- `presentations` to read, edit, and create directly selected presentations, and to render slide thumbnails; `presentations.readonly` to read and render native Slides opened from account-wide, folder, or exact-file Drive bindings. Google counts each thumbnail as an expensive read, limited to 60 a minute per user and 300 per project.\n- `calendar.calendarlist.readonly` so the resource picker can list calendars.\n- `calendar.events` to manage selected calendar and check calendar availability.\n- `chat.spaces.readonly`, `chat.messages`, and `chat.memberships.readonly` for every Chat resource. A whole-account Chat connection adds `chat.users.readstate.readonly` for its unread-only search, and `chat.spaces.create` and `directory.readonly` to start direct messages and group chats with people in the connected account's Workspace directory. Starting a conversation is its own approval kind, separate from sending in an existing one, and people outside the organization can't be added to a new conversation.\n- `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries.\n\n### Step 4: Test Users\n\nThis is important! While your app is in \"Testing\" mode (which it will be by default), only users you explicitly add here can use OAuth.\n\n1. Click **Add Users**\n2. Enter your own Google email address (the one you'll use to test Google API integrations)\n3. Click **Add**\n4. Click **Save and Continue**\n5. Review the summary and click **Back to Dashboard**\n\n### Step 5: Create OAuth Credentials\n\n1. In the left sidebar, go to **APIs & Services** > **Credentials** (or [click here](https://console.cloud.google.com/apis/credentials))\n2. Click **Create Credentials** at the top\n3. Select **OAuth client ID**\n4. For **Application type**, select **Web application**\n5. **Name**: Enter anything (e.g., \"Gadgets Local\")\n6. Under **Authorized redirect URIs**, click **Add URI** and enter: `http://localhost:8787/gatekeeper/google/oauth`\n7. Click **Create**\n\nA popup will appear with your **Client ID** and **Client Secret**. Keep this window open or copy these values somewhere safe.\n\n### Step 6: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-google/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here.apps.googleusercontent.com\nCLIENT_SECRET=your-client-secret-here\n```\n\nReplace the values with the credentials from Step 5.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 7: (Optional) Enable Google sign-in\n\nTo offer \"Continue with Google\" on the login page, add `google` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nSign-in only needs the identity scopes, which are always available, so no extra Google setup is\nrequired. (While the app is in Testing mode, the signing-in user must still be listed as a Test\nUser — see Step 4.)\n\n### Step 8: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Slides Presentation, Google Drive, Google Calendar, or BigQuery.\n6. If prompted, connect a Google account.\n7. You should be redirected to Google's consent screen in a new tab.\n8. The consent screen acts extra-scary since this is an \"unverified\" test app.\n9. After granting access, the tab closes, and you're back to Gadgets.\n10. Use the picker to choose the mailbox scope, document, presentation, project, dataset, or table to connect, or for Google Drive, the whole of Drive, a folder, or a single file.\n11. Create the connection. Ask the agent what it can do, or ask it to write a gadget using the new binding.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Gmail attachments\n\nNew messages, replies, forwards, and their draft-creation methods accept\n`attachments?: GmailAttachmentInput[]`. Each input is a regular file with a filename,\nMIME media type, and exact `ArrayBuffer` bytes; strings, base64 inputs, inline uploads,\nand multipart container uploads are not supported.\n\nFor example, given an exported PDF as `pdfBytes: ArrayBuffer`:\n\n```ts\nusing draft = await gmail.createDraft({\n  to: [\"recipient@example.com\"],\n  subject: \"Cloudflare at a Glance\",\n  text: \"Attached is the one-page overview.\",\n  attachments: [{\n    filename: \"cloudflare-at-a-glance.pdf\",\n    mimeType: \"application/pdf\",\n    content: pdfBytes,\n  }],\n});\n```\n\nFor an existing draft, `draft.addAttachment(input)` returns a readable, removable\n`GmailDraftAttachment`. `draft.attachments()` lists `{info, attachment}` entries, and\n`entry.attachment.remove()` removes that exact part, even when filenames are duplicated.\nDispose attachment capabilities when finished. `draft.update()` preserves attachments;\nit does not replace their list. Message attachment capabilities remain read-only.\n\nReplies include only supplied files. Forwards preserve the original attachments and\ninline parts, then append supplied files. A forward draft's preserved source parts are\nimmutable; its added files can be removed.\n\nFiles may be empty, but each upload is limited to **10 MiB**. Filenames must be nonempty\nand contain no path separators or control characters; `.` and `..` are refused.\nThe upload's `filename` is authoritative: redundant MIME `name`/`name*` parameters are\ndiscarded, while other media-type parameters such as `charset` are preserved.\nOutgoing MIME is limited to **25 MiB**, including transfer encoding and message headers.\nThe binding's existing **50 MiB** retained-file quota is shared by uploads and forward\nsource snapshots, including uploads in active drafts.\n\nUploaded bytes are captured in the existing integrity-checked chunk store, not embedded\nin queued-action metadata. Pending reads expose the files before approval, and body edits\nand Durable Object restarts preserve their exact bytes. Approval descriptions include\nfilename, media type, size, and SHA-256, with uploaded bytes marked as incomplete for\nhuman review. Files remain retained while a pending action or active draft references\nthem and are released when the last reference is removed. Rejecting a prerequisite\ninvalidates dependent edits; reject those edits too to release their references.\n\nCalendar files retain their original line endings and charset rather than being normalized.\nNested `message/rfc822` files also retain their exact bytes, including trailing line endings,\nthrough edits and sends; duplicate `.eml` filenames and identical files remain separate parts.\nProvider draft rewrites correlate attachment identities by metadata and SHA-256 in the actual\nMIME order, so queued edits and removals still select the right part when inline parts move.\nAn imported attachment without a disposition keeps its identity when a draft rewrite\nemits the regular `attachment` disposition.\n\nDraft and send fingerprints are prefixed `v2:` and hash attachment bytes exactly. Approvals\nqueued before that upgrade keep their unprefixed fingerprints and still apply, including a send\nwhose response was lost, which is reconciled rather than sent again. See\n`plans/gmail-simulation.md` for how they are verified.\n\n## Google Chat and Gmail new-message hooks (optional)\n\nGadgets can watch a Chat conversation for new messages (see `docs/google-chat-capabilities.md`),\nand a Gmail inbox, label or thread for new mail (`subscribeNewMessages()` in `src/types.d.ts`).\nGoogle delivers both through Pub/Sub, Chat's by Workspace Events and Gmail's by `users.watch`, so\nthis needs a public URL and these steps in the same Cloud project as the OAuth client:\n\n1. Enable the **Google Workspace Events API**, the **Cloud Pub/Sub API**, and the **Gmail API**.\n2. Create a Pub/Sub topic for Chat, granting `chat-api-push@system.gserviceaccount.com` the\n   **Pub/Sub Publisher** role on it, and one for Gmail, granting\n   `gmail-api-push@system.gserviceaccount.com` the same role. One topic granting both may serve\n   for both.\n3. Create a service account for push authentication (it needs no roles). Whoever creates the push\n   subscriptions needs `iam.serviceAccounts.actAs` on it (e.g. **Service Account User**). Pub/Sub\n   signs push tokens as its service agent, `service-PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com`,\n   which needs `iam.serviceAccounts.getOpenIdToken` on this account. Its default **Cloud Pub/Sub\n   Service Agent** role on the project includes that; only if it lacks the permission, grant it\n   **Service Account Token Creator** on this service account.\n4. Create a **push** subscription on each topic with endpoint\n   `${BASE_URL}/pubsub` (e.g. `https://example.com/gatekeeper/google/pubsub`), **Enable\n   authentication** with the service account from step 3, and audience set to that same endpoint.\n5. Set these values for this worker (in `.env` locally), both topics to the same one if it serves\n   for both:\n\n   ```bash\n   PUBSUB_CHAT_TOPIC=projects/your-project/topics/your-chat-topic\n   PUBSUB_GMAIL_TOPIC=projects/your-project/topics/your-gmail-topic\n   PUBSUB_PUSH_SERVICE_ACCOUNT=your-push-account@your-project.iam.gserviceaccount.com\n   ```\n\nIf the deployment sits behind Cloudflare Access, add a bypass for `/gatekeeper/google/pubsub`;\nthe worker instead accepts only pushes whose Google-signed token names that endpoint as audience\nand `PUBSUB_PUSH_SERVICE_ACCOUNT` as sender. That token proves a push came through a push\nsubscription, not who published to its topic, so each topic's Pub/Sub principals are trusted with\nhooked messages: anyone who can subscribe to it reads them all, and anyone who can publish to it\ncan inject messages into hooks of either kind, since a push is routed by its shape rather than\nits topic. Grant those roles to no one beyond step 2. A Gmail push carries only a mailbox address\nand a history ID, so a forged one can only make the worker read that mailbox's new history with\nthe connected account's own credentials. Without its topic and the push service account a kind of\nhook is refused, and everything else works as before.\n\nDisconnecting a Google account drops its hooks from the mailbox's and each space's driver, so\nnothing goes on reading or renewing with credentials that are gone; a watch or subscription no\nhook uses any more lapses, as when the hooks are disabled.\n\n## Worker Preview OAuth callbacks\n\nDeployments using Worker Preview hostnames can register one stable Google callback and relay the\nresult to the preview that initiated authorization. Configure the stable Worker and its previews\nwith the same `OAUTH_STATE_SIGNING_SECRET` and `OAUTH_ALLOW_PREVIEW_REDIRECTS=true`. Set the fixed\nredirect only on previews:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-google.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the authorized redirect URI in Google. The preview sends that fixed\nURI to Google and carries its own callback in signed, short-lived OAuth state. The stable Worker\naccepts return URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only the OAuth\nresult and state. Normal deployments should omit these settings and continue using\n`${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Wrangler\nstores baseline and Preview secrets separately, so provision the same signing value in both places.\n\n## Creating files\n\nGoogle Docs, Sheets and Slides are creatable resource types: an agent can ask for a new one with\n`createExternalResource`, giving a one-line title (trimmed, at most 256 characters), and its binding\nworks at once, before anything exists at Google. Until the creation is approved the gatekeeper\nsimulates an empty file: a Doc reads as one empty tab and queues edits as usual, a spreadsheet as\none empty `Sheet1` of 1000 × 26 cells, which takes no change until it exists, and a presentation as the one Google creates, a 16:9 deck\nwith a title slide, which queues changes as usual. Of the default theme's layouts it offers only\nfive (title slide, section header, title and body, title only, and blank); the others become\navailable once the presentation exists. Nothing reaches Google and no account is involved.\n\nThe approver picks which of their connected Google accounts to create the file in; it lands in\nthat account's My Drive, and the binding switches to the real file. Doc edits queued against the\nsimulation then apply to the created document like any other edit, and Slides changes apply to the\ncreated presentation unchanged.\n\nThat works for Slides because Google gives every new presentation the same object IDs (master\n`simple-light-2`, title slide `p`, and layouts `p2` to `p12`, seen both on a personal account\nthrough the API and on a Workspace account in the editor), so the simulation reads a recording of\none, `src/blank-presentation.json`: each read a session makes, through the session's own field\nmasks, less the presentation's ID, title and revision and the layouts it does not offer.\n`pnpm --filter @gadgets/google-gatekeeper record:blank-presentation <token-file>` records it again;\nthe file holds an access token with the `presentations` and `drive.file` scopes. It creates two\npresentations, refuses to record unless they read the same, and deletes them; with `--check` it\ncompares them with the committed recording instead. Should Google's new presentation change, a\nqueued change naming a slide, element or layout the created presentation lacks fails when\napproved, writing nothing.\n\nGoogle's create calls take no idempotency key. A retried approval after a lost reply binds the file\nalready created (the gatekeeper records it), but a file can still be orphaned in the approver's\nDrive: a create request that times out or fails with a 5xx after Google acted, a crash between\nGoogle's reply and that record being written, or a creation rejected after an approval whose\nsuccess was lost. Every created file's ID is logged (`google.creation.file.created`), so an orphan\ncan be found.\n\n## Office files\n\nGoogle's Sheets, Docs and Slides APIs cannot edit an Office file stored in Drive (`.xlsx`, `.xls`,\n`.xlsm` and `.ods`; `.docx`, `.doc`, `.odt` and `.rtf`; `.pptx`, `.ppt` and `.odp`): Sheets and\nSlides refuse to open one, and Docs reads one but refuses every edit to it. So the Docs, Sheets and\nSlides pickers list these files too, named with their extension and marked \"Makes a Google …\ncopy\", and connecting one converts it. The connect dialog says what converting does and offers\n**Convert**, which has Drive copy the file into the native type (`files.copy` with the native MIME\ntype: Google converts it, and no file content passes through the Worker). The copy is named after\nthe original without its extension, lands next to it when the original's folder takes new files and\nat the root of My Drive otherwise, and is what gets connected. The original is never changed, and later edits\nto it do not reach the copy. The conversion is Google's, and loses what Google's import loses, such\nas macros, some formulas, and complex layout.\n\n**Convert** asks for the Office import grant (see the scopes above) when the account does not yet\nhold it. Workshop opens Google's consent pop-up, and the dialog waits up to three minutes for the\ngrant before giving up. Each copy carries app properties naming its source (`gadgetsConvertedFrom`)\nand the source's modification time when it was copied (`gadgetsSourceModified`), so choosing the\nsame file again offers the copy made before instead of making another; using it reads only Drive\nmetadata, so it needs no new grant. App properties are private to the OAuth client that set them\nbut not to a user, so only copies this deployment's client made are found, and of those only ones\nthe account can edit; that may include a copy another user made in a folder they both edit. The\nlookup is best effort: a missed match makes another copy. A copy request is never retried, since a\nlost reply may still have made one. A file whose owner has turned off copying, a file in the trash,\nor one larger than Google imports into that type (`about.maxImportSizes`) is refused before\nanything is copied.\n\nA pasted link to an Office file fails to connect, and its error offers to open the connect dialog,\nwhere it can be converted. An Office file bound any other way is refused with a message pointing to\nthe dialog: by Sheets and Slides when they cannot open it, and by Docs on connecting, since Docs\nreads it. An Office file already bound as a Doc answers each approved edit with the same message.\n\n## Google Docs request costs\n\nConnecting a Doc reads its Drive metadata once, to refuse an Office file (see\n[Office files](#office-files)). A read costs one full `documents.get`, replayed for 10 seconds and then revalidated by a\nrevision-only `get`. Queuing an edit costs nothing more. Applying one costs a full `get` (the\nedit is materialized against the current revision), the `batchUpdate` carrying the edit and its\nwrite marker, and a second `batchUpdate` removing the marker; the document is reread after it only\nwhen more edits are queued behind it, since they must be replayed over the new revision.\n\n## Known limitations\n\nVery large Google Docs can exceed Durable Objects' 2 MB value limit after Markdown conversion,\ncausing tab listing, content reads, and edits to fail.\n\nA Google Slides presentation's slide summaries come from one response capped at 10 MiB. It holds the\ntext of every slide's shapes and speaker notes, but no styles or geometry: about 4 KiB a slide on a\nlive deck, so a presentation needs thousands of slides to exceed it. It also holds every master's\nand layout's name and background and the masters' theme colours, a few KiB in all. Slide content is\nread one slide at a time, capped at 2 MiB each. While changes await approval, reads also fetch the\nslides they address, so each can be checked as approving it would: slide content reads the other\nslides of any batch touching a slide it shows, since a batch applies all or none, and the summaries\nread every slide a queued batch changes, since they hold no tables or grouped shapes. Large batches\nawaiting approval therefore cost reads more requests against Google's per-user quota.\n\nThe simulated presentation was recorded from an English-locale account, so until it is created it\nreads with locale `en` and English layout names. Google translates those names: a presentation a\nSpanish-language account made in the editor read locale `es-419` and layout names such as\n`Título y cuerpo`, with the recording's object IDs, placeholders and geometry. One created through\nthe API in a non-English account has not been checked, so an approver's presentation may name its\nlayouts differently from what the agent saw before approval, including in an approval card queued\nthen. `createSlide()` takes a layout's ID, never its name.\n\n## Google Slides reads\n\n`getSlides()` reads each element as the Slides editor shows it: its box and rotation, and what it\nsets rather than inherits. Shapes carry their text and its formatting (links to URLs or to slides,\nfont weight, paragraph indents and bullets), and their fill, its opacity, and their outline with its\ndash. Tables carry their cells, column widths, row heights and borders, collapsed to one `border`\nwhen every edge matches. A table's `size` is nominal, 3,000,000 EMU square on every live read, so\nits box is its columns and rows added up; a row's height is the least it may have, since Google\ndraws it taller to fit its text and reports that nowhere. Lines carry their ends, computed from\ntheir transform, arrowheads, and the elements they connect; videos their YouTube or Drive ID; and\nimages and Sheets charts their source URL and spreadsheet. The `contentUrl` an image or chart also\nhas is a bearer URL, like a thumbnail's, so it is never read. `getPresentation()` lists the\npresentation's layouts with their placeholders, for `createSlide()`.\n\n## Google Slides edits\n\nA `.pptx` or other Office presentation cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound presentation accepts six changes, each queued for approval: `updateSlides()`,\n`duplicateSlide()`, `deleteSlide()`, `moveSlides()`, `createSlide()`, and `setSlidesSkipped()`.\nChanges are journaled with the kit's `ActionJournal`, and apply in the order they were queued.\n\n`updateSlides()` takes up to 50 changes applied together: find-and-replace or whole-text\nreplacement in shapes, table cells, and speaker notes, text and paragraph formatting (including\nbullets), shapes and their fill, outline and vertical alignment, moving, resizing, rotating,\nstacking and deleting elements, alt text, images by public `https:` URL, and tables with their\nrows, columns and cell fills. Elements a batch creates get IDs the gatekeeper mints: later changes\nin the batch can address one by its `ref`, and later batches by the ID returned for it. An image\nURL is never fetched by the gatekeeper: Google downloads it when the change is applied, so nothing\nis read from it before approval.\n\nTwo of its changes reach beyond a slide's elements:\n\n- `setBackground` sets the background of a slide, a layout or a master, named by exactly one of\n  `slideId`, `layoutId` and `masterId`: a colour, `\"none\"`, a picture by public `https:` URL, or\n  `null` to inherit the background of the layout or master the page is made from. A master has\n  nothing to inherit from, and Google turns a reset there into no background, so `null` is refused\n  for one. As with images, Google, not the gatekeeper, downloads a picture URL when the batch is\n  applied. Reads report a picture background only as `\"picture\"`, and never request its content\n  URL, a short-lived URL that reads as the account that fetched it.\n- `setThemeColors` sets some of a master's 12 theme colours. Google takes all 12 at once, so the\n  colours not given are merged in from the master's scheme as the batch is planned against its\n  fresh read: a colour a collaborator changed meanwhile is kept, and one changed after that read\n  makes Google refuse the revision-pinned write, so it is planned again.\n\n`getPresentation()` lists the masters, with their backgrounds and theme colours, and the layouts,\nwith their master and background, but not their elements; each slide reports its layout, its master\nand a background of its own. The Slides API cannot change which layout an existing slide is made\nfrom.\n\nA batch is queued as one of three action kinds, which share everything but which ones a user may\nlet apply without asking: \"Slide text edits\" (only text edits), \"Slide formatting and arrangement\"\n(only formatting text, setting no link or font, since Google keeps their whole strings; formatting\nparagraphs and shapes; moving or stacking elements; and setting slides' backgrounds to a colour,\n`\"none\"` or `null`), and every other batch, mixed ones included, which always waits for approval.\nA picture background, any change to a layout or master, and theme colours therefore always wait:\nthey change slides the user is not looking at, or make Google fetch a URL.\n\n`createSlide()` adds a slide made from one of the presentation's layouts, at the start, the end, or\nafter a given slide. The gatekeeper mints the slide's ID and one for each placeholder it gets from\nthe layout, read from the layout's page when the change is queued, so later changes can fill the\nplaceholders before it is approved. Reads show the new slide with the layout's placeholders, empty,\nat the layout's size and position, as a live probe showed Google makes them, but for a slide\nnumber: Google adds one only while the presentation shows slide numbers, which a new one does not,\nand otherwise ignores its mapping, so none is minted and a slide number appears once the slide\nexists (`instantiatedPlaceholders()` in `slides-simulation.ts`).\nGoogle takes a new slide's layout only from the master of the slide before it, of the first slide\nwhen it goes first, or of the presentation's first master when it has no slides, so a deck with\nslides imported in another theme has layouts that fit only some places: layouts and slide\nsummaries carry their master's ID, and a layout that does not fit where it is to go is refused\nwhen queued, and again on approval against a fresh read, as is one deleted since; approval also\nrefuses one that has lost a placeholder the slide was to get. Google deletes a master and its\nlayouts with the last slide on it, unless it is the presentation's first master, as a live probe\nshowed, so reads drop a master whose last slide a queued deletion removes, with its layouts, and\na slide queued after it on one of those layouts is refused. Google names a new slide's speaker notes\nonly as it creates the slide, so they cannot be edited until it is approved. Adding a slide always\nwaits for approval.\n\n`setSlidesSkipped()` skips slides, leaving them out when presenting, or shows them again. It is\nthe \"Skipping slides\" kind, which a user may let apply without asking: it destroys nothing, and\nis undone the same way. It applies against the presentation's outline alone, reading no slide.\n\nReads show queued changes as if applied, by replaying them over Slides' own JSON before it is\nprojected; thumbnails show the presentation as saved. Each change is replayed as Google documents\nits request, and one the replay cannot follow exactly is refused instead: a bullet list started\nright after another list item, which Google may join to that list; table rows or columns inserted\nor deleted beside a merged cell; deleting a grouped element that would leave its group with one;\nrotating a video, which Google cannot shear; and new text filling a list item whose bullet is\nstyled apart from its text, which Google would restyle to match. The replay makes no claim about\nwhat Google renders: shrinking text to fit, wrapping, the box Google fits an image to, the borders\naround new or deleted table rows and columns, and the formatting new rows and columns take appear\nonce applied. Table rows and columns are sized as live reads show Google sizing them: a new table\nshares its box evenly, a new row or column copies the one beside it, every column then narrows so\nthe table keeps its width, and the lines a deletion leaves keep theirs. It does follow Google in\nturning a shape's autofit off once its text changes, fixing the font sizes and line spacings the\ntext sets at what the autofit had shrunk them to. A replacement takes the style of the text it\nreplaces, and text left unchanged at either end of a match is not rewritten, so it keeps its own.\nFormatting a whole list paragraph also updates its bullet's style in replay, which later\nreplacements use when checking whether they can keep that style. Setting a font sets it at\nregular weight, so the text is no longer bold unless the change says so. A slide number or other\nAutoText can only be replaced whole.\n\nEach approved change is one `batchUpdate`, planned against a fresh read and pinned to its revision\nwith `requiredRevisionId`, so a change applies only to the text it was planned against: one that no\nlonger applies fails without writing, and a concurrent edit makes Google refuse the write so it is\nplanned again. A write whose response is lost may have been committed, so it is only ever resent as\nfirst sent, at the same revision. If that is refused, a read decides whether it landed, counting\nonly what the write would have changed: an element it created that it does not also delete, an\nelement it deleted that was there before, text it edited, a table it adds or deletes rows or columns\nof, by its size and its cells' positions, spans and text, a theme colour it set, and a background\nother than a picture it left a page with, that the page did not already have (every picture reads\nalike). If the read cannot tell, as for a batch that only formats or moves elements or leaves a page\na picture, the change is marked as having an unknown outcome and never retried.\n\nGoogle returns a presentation's revision only to an account that can edit it, so a view-only\naccount can read a presentation but every change to it fails. A queued change is stored in one\nDurable Object value, so each is capped at 100 KiB.\n\n## Google Sheets edits\n\nAn `.xlsx` or other Office spreadsheet cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound spreadsheet accepts `updateSpreadsheet()`, which queues up to 50 changes for\napproval as one batch, applied all or none and in order: `writeCells` enters values in every cell\nof a bounded A1 range that names its sheet, such as `'Sales 2026'!A1:C3`, and `clearRange` clears\na range's contents, keeping its formatting. Batches are journaled with the kit's `ActionJournal`, and\napply in the order they were queued. A spreadsheet opened from a Drive binding has no change method.\n\nInput is literal and typed, so nothing depends on the spreadsheet's locale: a string starting with\n`=` is a formula, any other string is text exactly as given, never read as a number or date,\nnumbers and booleans are entered as such, and `null` clears a cell. A date is entered as its\nserial number in a cell formatted as a date, as `formatCells` can format it. Text starting with\n`=` cannot be written as text, and formula-mode reads show it exactly as a formula, so writing\nback what was read can turn it into one. A batch of only literal values and clears is queued as\n\"Sheet value edits\", which a user may let apply without asking; a batch with any formula always waits for approval.\nA formula naming `IMPORTRANGE`, which reads another spreadsheet and so escapes the binding, or\n`IMPORTDATA`, `IMPORTHTML`, `IMPORTXML`, `IMPORTFEED` or `IMAGE`, which make Google fetch a URL, is\nrefused in any letter case, outside string literals. Formulas the spreadsheet already holds are\nnot inspected, and one of its named functions could wrap a refused function, which is why every\nformula needs approval.\n\nReads show queued changes as entered and never guess what Google computes. Formula-mode reads are\nexact, and raw reads are exact for literal values. A queued formula's result, and in formatted\nreads a queued number or boolean, whose display depends on number format and locale, read `null`,\nand their cells are listed in the range's `pendingCells`. Formulas elsewhere keep their saved\nresults, even when they depend on queued cells. Google tidies the references in a formula as it is entered: column\nletters upper-cased, a sheet named by its title as the spreadsheet spells and quotes it, a range of\none cell collapsed to that cell. A queued formula is shown, approved and written already tidied, but\nwith its whitespace as typed, which Google may later drop. A queued change that no longer applies,\nsuch as one writing to a sheet since shrunk, is reported as `queuedChangeConflict`, and it and the\nchanges queued after it are not shown.\n\nThe same batch can change a spreadsheet's structure: `addSheet`, `renameSheet`, `duplicateSheet`\nand `deleteSheet` act on its sheets, and `insertRows`, `deleteRows`, `insertColumns` and\n`deleteColumns` on a sheet's rows and columns. A change names its sheet by title in `sheet`, as a\nrange does, including a title an earlier change of the batch gives a sheet it adds, copies or\nrenames, so it never needs an ID the spreadsheet has not assigned yet. The gatekeeper mints the ID\nof each sheet a batch adds. A batch with any structural change always waits for approval.\nEvery default is sent explicitly, so the approver sees what will be made: a new sheet has Google's\n1,000 rows and 26 columns and comes last, and a copy is titled `Copy of <title>` and placed right\nafter its source (Google would put it first, under a name of its own choosing). Changes are refused\nwhen queued that would delete the last visible sheet, every row or column of a sheet, or every one\nof its unfrozen ones, delete a sheet of more than 50,000 cells, whose content the guard below could\nnot cover (delete it in Google Sheets instead), grow a sheet past 18,278 columns or the spreadsheet\npast 10,000,000 cells, or change or copy a sheet holding a protected range the account cannot edit. Reads show the\nqueued structure: sheets, rows and columns where the changes put them, cells moved with their rows\nand columns, and formulas rewritten as Google rewrites them, references shifted, grown, shrunk or\nturned into `#REF!`, and sheet titles requoted. A formula's value is shown only while every\nreference it holds keeps exactly its cells; one whose reference grows, shrinks or breaks, or that\ncalls a position-dependent function such as `ROW`, `OFFSET` or `INDIRECT`, or `QUERY`, whose query\nnames columns by letter, or uses a name such as a named range, or calls a function the\ngatekeeper does not know to depend only on its arguments (one of the spreadsheet's named functions\nmay hide references or such calls), reads `null` and is listed in `pendingCells`, as are cells a\nqueued formula enters. Cells of inserted rows and columns and of\nadded sheets read as empty. Deleting a sheet leaves the formulas that refer to it as written, as\nGoogle does; they read `#REF!` once it applies, and stay so even if a later sheet takes the title.\nOnce rows, columns or sheets are queued, cells Google fills with nothing entered in them, such as an\narray formula's results (`SORT`, `SEQUENCE`, `FILTER`, `QUERY`) or a pivot table, read `null` and\nare listed in `pendingCells`.\n\n`formatCells` formats every cell of a range, keeping its contents: bold, italic, underline,\nstrikethrough, font size, text and fill colour (`#rrggbb` or a theme colour such as `ACCENT1`),\nnumber format (a type and an optional pattern in Google's syntax), horizontal and vertical\nalignment, wrapping, and borders along the range's edges and between its rows and columns. A field\nset to `null` is reset to the default, and a border set to `null` is removed; fields left out are\nkept. A batch of only `formatCells` changes is queued as \"Sheet formatting\", which a user may let\napply without asking; mixed with any other change it waits for approval. `readFormats()`, which a\nspreadsheet opened from a Drive binding offers too, reads the formatting set on up to 10,000 cells\nof a range, never their values, and shows queued formatting merged in queue order. Cells of rows\nand columns a queued change inserts, whose formatting Google inherits from a neighbour in ways that\ndiffer between rows and columns, read `null` and are listed in `pendingCells`. A border along a\nrange's outer edge removes the facing border of the cell beside it, since Google gives an edge one\nowner, and reads show that. A formatted value read of a cell whose number format a queued change\nsets or resets reads `null` too, listed in that range's `pendingCells`, unless a queued change\nleaves the cell empty: a format such as `;;;` can hide what a blank-looking cell holds. So does a\nvalue queued on an inserted row or column, which takes the number format of the line before it.\nGoogle accepts any font size and number pattern, so the gatekeeper's checks are the only ones: a font size is an\ninteger from 1 to 400, and a pattern holds 1 to 200 characters and no control characters.\nFormatting is not guarded: a collaborator's formatting change made after a batch is queued is\noverwritten when it applies.\n\nSheets has no revision to pin a write to, so each approved batch is planned against a fresh read\nand guarded by a digest of what it overwrites or removes: the cells it writes and the rows,\ncolumns and sheets it deletes, as entered, the title and size of each sheet it addresses, and,\nwhen it adds or copies a sheet, the order of every sheet, ignoring whitespace in formulas. Cells\nthat changes queued before it write are expected to hold what those enter, and sheets those\nrestructure to have the shape they leave, so a batch built on one that was rejected or failed fails\ntoo, as does a batch with a formula when any structural change queued before it was not applied,\nand a batch adding or copying a sheet when a sheet queued before it to be added, copied or deleted\nwas not. A batch guards at most 50,000 cells. A change whose cells or sheet\nchanged since it was queued fails without writing; a collaborator's edit between that read and the\nwrite is not caught. Each batch also creates a developer-metadata marker whose ID is minted at queue\ntime, which Google stores at most once, so a write whose response is lost is found by its marker or\nresent exactly as first sent. A lost write that cannot be resent, because its cells changed in the\nmeantime or Google refused the resend, or that three sends leave unfound, is marked as having an\nunknown outcome. A marker is visible only to the Google Cloud project of this deployment's OAuth\nclient, leaves no Drive revision, and is deleted by the first write at least ten minutes after its\nbatch was last sent, since a send given up on may commit until then. While a write with an unknown\noutcome could still land, later changes stay pending.\n\nA change is refused when it is queued if the connected account can only view the spreadsheet, or\nif it writes outside a sheet's grid or into a protected range the account cannot edit. A change\naddresses at most 10,000 cells and a batch 20,000, a cell holds at most 50,000 characters, the\nchanges queued at once may set values or formatting in at most 100,000 cells in all (a cell given\nboth counting twice, a copied sheet counting what changes queued to it set) and make at most 50\nchanges to rows, columns and sheets, and a queued batch is capped at 100 KiB. While such changes are\nqueued, a read or a batch may involve at most 5,000,000 characters of formulas times their number,\nsince each formula is rewritten through each of them, and hold at most 5 MiB of text as rewritten.\nGoogle allows 60 write requests a minute per user; a write over that quota stays pending.\nuser; a write over that quota stays pending.\n\n## Google Drive read-only bindings\n\nDrive exposes three permanent resource URL forms:\n\n- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: any ID the account token resolves is in scope. Listings use `corpora=user`, which Google defines as My Drive items the account created or opened plus items shared directly with it, so a shared drive's contents may be readable by ID without appearing in a listing. Bind a folder or file when this authority is too broad.\n- `https://drive.google.com/drive/folders/<folderId>` selects one folder or shared-drive root.\n- `https://drive.google.com/file/d/<fileId>/view` selects one file by its immutable ID.\n\nThe Drive picker opens on Google Drive, whose own choice binds the whole account, above My Drive, Shared drives and Shared with me. Locations list a page at a time. Search spans My Drive, \"Shared with me\" and every shared drive the account is a member of (`corpora=allDrives`) and says where each result lives, and a Drive, Docs, Sheets or Slides link pasted into it answers with the one folder or file the link names. A pre-filled folder or file opens where it lives, with breadcrumbs up to My Drive or its shared drive, or with none when an ancestor is out of reach or more than 20 folders up. All of this runs on the Drive resources' own grant except listing Shared drives, which also needs the shared-drives grant above: until the account holds it, opening Shared drives offers **Allow access**, and a shared drive's folders stay reachable through search, a pasted link or a pre-filled URL. Folders whose children the account cannot list are shown but cannot be opened or bound; on a pre-filled node's breadcrumbs, where Google's [folders with limited access](https://developers.google.com/workspace/drive/api/guides/limited-expansive-access) put them above a file shared directly, they can be opened but are not offered. A shortcut stands for its target, which a listing says nothing of, so a page's shortcut targets are read in one batch: a shortcut left behind when its target was deleted, unshared or trashed is left out, and one to a folder is judged as that folder would be. A search Google reports as incomplete fails rather than presenting partial results as complete.\n\nA folder URL carrying `?resourcekey=` is not supported: the key is dropped, and the binding then fails with a Drive 404 for anyone whose access to that folder comes from the link rather than from a direct grant. Google requires resource keys only for items shared by link before September 2021, and only for link-access users — an owner or anyone granted access directly is unaffected, even when the URL they paste happens to carry a key. Such a folder needs direct access to connect.\n\nThe agent-facing `GoogleDriveReadSession` covers account and exact-file bindings. `GoogleDriveFolderSession` is positioned at the selected root and exposes only its current folder's direct children: `list()`, provider-side structured `search()`, `getEntry()`, native Doc/Sheet/Slides opens, and `openFolder()` for one live direct child. Listing and search return disposable RPC cursors; child folders and native content sessions are independently disposab\n\n<!-- [TRUNCATED AT 50KB] -->","packages/gatekeeper-homeassistant/README.md":"# Gatekeeper Home Assistant\n\nThis package provides a [Home Assistant](https://www.home-assistant.io/) gatekeeper for Gadgets. It lets a Gadget read state from a connected HA instance, call services on devices (turn lights on/off, set thermostats, lock doors, etc.), edit Lovelace dashboards, render templates, and read history — all mediated through the standard Gadgets approval queue.\n\n## Authentication\n\nThe gatekeeper uses Home Assistant's **long-lived access token (LLAT)** + **URL** combination. There's no OAuth flow because:\n\n- Each HA instance has a different URL (no central directory).\n- LLATs don't expire (10-year default lifetime).\n- LLAT is the path every other HA-adjacent tool uses, so users are familiar with it.\n- It works for both Nabu Casa Cloud users and self-hosted users on a LAN.\n\nWhen the user connects an account, they paste their HA URL and an LLAT into a form. The gatekeeper validates by calling `GET /api/`, then stores both in the per-user Durable Object.\n\n### Reachability\n\nThe gatekeeper makes HTTP and WebSocket calls to the configured HA URL. On a Cloudflare-hosted Gadgets deployment, that means HA must be publicly reachable (via Nabu Casa, Cloudflare Tunnel, port-forwarding, etc.). On a self-hosted (workerd) deployment, LAN addresses like `http://homeassistant.local:8123` and `http://192.168.x.x:8123` work fine — that's the intended setup for users running Gadgets on the same network as their HA.\n\n## Resource granularities\n\nFive granularities are exposed; each comes with its own configurator UI for picking a specific resource from the connected HA instance:\n\n| Granularity   | What it grants                                                      |\n| ------------- | ------------------------------------------------------------------- |\n| Whole instance | Access to every area, device, entity, dashboard, and service.       |\n| Area          | A single area (room) — its devices and entities only.               |\n| Label         | All entities carrying a particular HA label.                        |\n| Device        | A single physical device and the entities it provides.              |\n| Entity        | A single entity (light, sensor, switch, etc).                       |\n\n## TypeScript API\n\nBindings expose one of `HomeAssistantSession` (whole-instance), `Area`, `Label`, `Device`, or `Entity`, depending on the granularity granted. Common operations:\n\n```ts\n// Whole-instance:\nconst config = await session.getConfig();\nconst areas = await session.listAreas();\nconst lights = await session.listEntities({ domain: \"light\" });\n\n// Capability-based:\nconst light = await session.getEntity(\"light.kitchen\");\nawait light.turnOn({ brightness: 200 });\nconst state = await light.getState();  // reflects the simulated post-write state\n\n// Area-scoped service call (affects every entity in the area):\nconst livingRoom = await session.getArea(\"living_room\");\nawait livingRoom.callService(\"light\", \"turn_off\");\n\n// Templates:\nconst temp = await session.renderTemplate(\"{{ states('sensor.outside_temp') | float }}\");\n\n// Dashboards:\nconst dashboard = await session.getDashboard(\"lovelace\");\nconst dashboardConfig = await dashboard.getConfig();\n// ... mutate the JSON ...\nawait dashboard.saveConfig(dashboardConfig);\n```\n\nSee `src/types.d.ts` for the complete API and `@example` blocks for every method.\n\n## Approval & simulation\n\nEvery read calls `authorizeObservation` and every write goes through `submitAction`. Writes do **not** execute against HA until the user approves them.\n\nUntil approval, reads reflect a **simulated post-action world**: e.g. after `entity.turnOn()`, an immediate `entity.getState()` shows `state: \"on\"` even though HA hasn't been touched. This lets agents chain reads and writes without waiting for user approval. Simulation predicts final states only — no transition timing, and unrecognized service calls (custom integrations, scenes, scripts, templates) leave state untouched.\n\nSee the **APPROVAL & SIMULATION** section at the top of `src/types.d.ts` for the full list of caveats.\n\n## Phase 2 — not yet implemented\n\n- **Caching.** Every read does a fresh registry fetch. Once caching is added, area / device / label-scoped reads will be much cheaper.\n- **Hooks (push events).** `setHook` is a no-op. WebSocket `subscribe_events` / `subscribe_entities` would enable a `HomeAssistantHook` interface — e.g. a Gadget receiving a callback when a motion sensor fires.\n\n## Implementation notes\n\n- **Service calls go through the WebSocket API.** HA's REST `POST /api/services` endpoint expects target fields flattened at the top level of the body and has uneven support for area / label / floor targets across versions. The WebSocket `call_service` command is HA's modern path and supports the full target shape natively.\n- **All actions get an integer `id`** assigned by a per-DO counter. Pending actions are stored under `pending:<id>` in DO storage so reads can simulate the post-action state.\n- **Defensive validation.** Malformed action bodies (e.g. an agent passing a single options object instead of positional arguments) fail synchronously with a rich error message that includes a suggested corrected call.\n\n## Files\n\n```\nsrc/\n├── homeassistant.ts        # main: Vendor, UserAccount, User, GatekeeperImpl, Session impls\n├── homeassistant-api.ts    # REST + WS clients (incl. timeouts and error-body sanitization)\n├── approvals.ts            # describeAction, executeAction, applyRevertForEntity, ...\n├── simulation.ts           # pure overlay-at-read-time helpers\n├── registry-utils.ts       # shared resolveTargets helper\n├── types.d.ts              # public Session/Area/Label/Device/Entity/Dashboard interfaces\n└── configurator/           # 5 picker UIs (instance / area / label / device / entity)\n```\n","packages/gatekeeper-kit/AGENTS.md":"# Gatekeeper Kit contributor notes\n\n`@gadgets/gatekeeper-kit` is a library, not a deployable Worker. Do not add a `cloudflare.config.ts`:\nthe `wrangler.jsonc` generated from it is what makes release tooling treat this package as a\ngatekeeper deployment.\n\n## Package boundaries\n\n- Only Layer 1 leaf modules are shipped. Layer 2 remains a proposal in\n  [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md).\n- Keep leaf modules independently usable. Do not make one depend on a future assembly layer.\n- Add a leaf, or a leaf option, only for behavior several gatekeepers share under one contract.\n  Behavior only one gatekeeper has belongs in that gatekeeper. Support it with a plain TypeScript\n  hook or callback on the leaf, or by implementing the canonical interface by hand. Do not add a\n  variant for a single provider.\n- Accept the narrowest structural KV surface a module needs. Pass stable `ctx.storage.kv` objects to\n  modules that coordinate work by storage identity.\n- Treat shipped storage keys and prefixes as compatibility. Use existing key and prefix options when\n  porting a gatekeeper with a different layout.\n- Public modules are explicit subpath exports in [`package.json`](package.json). Document new public\n  symbols with JSDoc and add the subpath to the README inventory.\n\n## Documentation ownership\n\n- [`README.md`](README.md) maps the package and its modules.\n- [`USAGE.md`](USAGE.md) owns cross-module integration guidance and operational sharp edges.\n- Exported-symbol JSDoc owns the exact API contract visible in editors.\n- [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md) is the design record, including the\n  unshipped Layer 2 proposal. Do not duplicate that proposal into current usage documentation.\n\n## Verification\n\nUse Node tests for pure logic. Use the workerd project for persisted RPC stubs, `RpcTarget`,\nDurable Object behavior, and `crypto.subtle.timingSafeEqual`.\n\nFrom the repository root:\n\n```sh\npnpm --filter @gadgets/gatekeeper-kit test:run\nvp run -F @gadgets/gatekeeper-kit build\n```\n","packages/gatekeeper-kit/README.md":"# `@gadgets/gatekeeper-kit`\n\nShared building blocks for gatekeeper connect flows, credentials, actions, observations, cursors,\ncaching, and simulation. These modules replace security-sensitive plumbing that gatekeepers had\nimplemented separately.\n\nThe kit provides a pragmatic baseline for common gatekeeper behavior. Provider-specific or esoteric\nbehavior can use the canonical TypeScript interfaces directly while retaining whichever leaf\nmodules still fit.\n\n## Current scope\n\nOnly Layer 1 is shipped: independent leaf modules exposed through package subpaths. Import them à la\ncarte; none requires a gatekeeper assembly.\n\nLayer 2, including `KitUserAccountBase`, `KitVendorBase`, and `KitGatekeeperBase`, remains a proposal\nin [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md). No gatekeeper consumes it.\n\nThe code and tests define the shipped behavior. The plan records the design and the unshipped\nproposal.\n\n## Responsibility boundary\n\nThe kit owns provider-independent mechanisms inside each imported module. The gatekeeper owns\nprovider facts, policy, and the assembly around those modules. Importing a leaf does not transfer\nthe duties in the right-hand column.\nWorkshop owns connect completion ticket minting and redemption.\n\n| Concern | Kit owns | Gatekeeper owns |\n| --- | --- | --- |\n| Assembly and lifetime | Independent leaf contracts; no Layer 2 assembly is shipped. | Worker, account, and resource-facet RPC surfaces; stub disposal; keeping stateful kit objects stable for one Durable Object activation. |\n| Connect | Nonce generation and comparison, the two-stage handshake, staged reconnect escrow, hardened handoff HTML, and browser mutation guards. | Provider exchange, staged payload shape, exact live commit, provider cleanup policy, routes, and authorization parameters. |\n| Preview OAuth | Signed state, stable-to-preview callback relay, and return-host validation. | Deployment configuration, provider callback parameters, issuer checks, and retaining the exact redirect URI for code exchange. |\n| Credentials | Atomic credential records, refresh coalescing, identity and connection generations, replay, and rejection adjudication. | Grant shape and projection, the refresh callback (see OAuth token endpoint), and display-safe errors. |\n| Credential expiry and simple auth retry | Durable deduplication of expiry notifications and one-refresh/one-replay helpers. | Deciding what proves grant expiry beyond an OAuth `invalid_grant`, provider refresh and revoke calls, and choosing the coordinator flow versus the standalone retry helper. |\n| OAuth token endpoint | Token-request hardening, response parsing, PKCE, the default `invalid_grant` death rule, and the canonical refresh merge. | Endpoints and client authentication, provider death evidence beyond `invalid_grant`, non-canonical merges, and non-RFC revocation. |\n| Actions | Durable submission and resolution, serialization, journaling, retention mechanics, connection fences, and dependency tracking and refusal. | Approval text, provider calls, idempotency and reconciliation, action-specific simulation, revert semantics, and retention policy. |\n| Action files | Bounded chunk storage, integrity verification, aggregate accounting, deletion, and orphan-pruning mechanics. | Byte limits and key prefixes, keeping references in action records, describing the same bytes that will be applied, and releasing files with their records. |\n| Simulation | Ordered pending-action views, pure replay with explicit incomplete results, and durable provisional-ID allocation and binding. | Target extraction, state transitions, unsupported-effect policy, provider ID syntax, and projecting pending effects onto every affected read. |\n| Observations | Admission strategies, tracked-collection fencing, exclusion derivation, and the guarded authorization call. | Calling the gate for every read, truthful escaped descriptions, choosing the matching strategy and scope, ACL oracles, and collection canonicalization. |\n| Cursors | Serialized walks, buffering, a per-call provider fetch bound, common page/offset/token continuation, merging injected simulated rows, filtering hooks, and per-page authorization callbacks. | Provider page adapters, page limits and termination semantics, the injected rows and their order, resource lifetime, and the observation description for each returned page. |\n| Cache | Authority partitioning, stale-fill fencing, TTL storage, single-flight loads, and invalidation. | Cache families, names and keys, authority dimensions, TTLs, value projection, and deciding which reads are safe to cache. |\n| HTTP endpoints and responses | Operator-supplied endpoint normalization, dot-segment-safe path-segment encoding, common no-access probes, byte-capped text decoding, and best-effort error-body prefixes. | Host allowlists, redirect and header policy, provider id grammar, response schemas, provider errors, and binary or streaming limits. |\n\nWhen a provider does not fit a leaf, implement the canonical TypeScript interface directly and keep\nthe other leaves that still fit. Bypassing a leaf also means owning its guarantees for that concern;\ndo not call around a stateful module while relying on its journal, fence, or lifetime elsewhere.\n\n## Start here\n\n- For an OAuth-shaped provider, start with [Connect flows](USAGE.md#connect-flows), then read the\n  [credentials guide](USAGE.md#credentials). They cover browser handoff sequencing, account-side\n  storage, consumer-side RPC, refresh, replay, expiry, and action fences.\n- For provider writes, use [`./actions`](#module-inventory) and read\n  [Actions and files](USAGE.md#actions-and-files).\n- For every gatekeeper's observer methods, select a strategy from `./observers` and read\n  [Observations](USAGE.md#observations).\n- For details attached to one class, function, option, or error, read that export's JSDoc.\n\nImport from the narrow subpath:\n\n```ts\nimport {\n  CredentialCoordinator,\n  CredentialSource,\n} from \"@gadgets/gatekeeper-kit/credentials\";\n```\n\n## Module inventory\n\n| Subpath | Purpose | Use it when |\n| --- | --- | --- |\n| `./connect-nonce` | Nonce generation, expiry, and constant-time comparison. | A connect flow mints or checks its own nonce. The handshake and credential modules already use it. |\n| `./connect-handshake` | Two-stage `initiation` to `oauth` nonce storage, and the once-only `complete()` marker. | A connect link or form redirects through an OAuth provider, or has no single-use nonce to keep `complete()` from running twice. |\n| `./credential-stage` | Durable reconnect escrow, exact commit, and exact-stage cleanup. | A reconnect must remain inert until Workshop commits the completed stage. |\n| `./connect-pages` | Hardened connect HTML, escaping, and browser mutation guards. | A gatekeeper serves HTML from its own origin. |\n| `./credentials` | Account-side `CredentialCoordinator` and consumer-side `CredentialSource`. | An OAuth-shaped provider stores, refreshes, or rejects credentials. |\n| `./credential-expiry` | Durable, deduplicated `credentialsExpired()` notification. | An account has a Workshop connect callback to notify. |\n| `./auth-retry` | One refresh and replay without account adjudication. | A token flow has no `CredentialSource`; otherwise use `CredentialSource.run()`. |\n| `./cache` | Authority-partitioned Durable Object TTL caching. | Provider reads repeat and reconnects must fence stale fills. |\n| `./single-flight` | Keyed coalescing of concurrent work, without caching results. | Concurrent callers must share one in-flight request or write, such as two approvals that would each create the same resource. |\n| `./serial-queue` | FIFO serialization of async operations; a failed one never blocks the next. | Operations on shared state must not interleave, such as preparing changes that each check those queued before them. |\n| `./cursors` | Array, page-number, offset, and continuation-token cursors, with injected simulated rows (`CursorInjection`); `PageHookCursor`, which runs a hook on each page of a cursor the gatekeeper already built; and `SessionGitCache`, a git session's `GitCache` stub holder that advertises returned commit ids. | A session returns more rows than one RPC reply should carry. |\n| `./actions` | Action declaration, approval, application, retention, and journaling. | An operation has an externally visible side effect. |\n| `./action-files` | Bounded, integrity-checked action-file storage. | A queued action carries file bytes. Store only its `ActionFileReference` in the action. |\n| `./action-description` | Approval-text builder: prose plus exact typed fields under one byte budget, and the `descriptionIsComplete` claim. | An action's `describe` renders what it will write or send. |\n| `./simulation` | Pending-action replay and provisional-ID mapping. | An action continues with simulation and later reads must include its projected effect. |\n| `./observers` | Observer admission strategies and per-read authorization. | A gatekeeper implements its required observer methods. |\n| `./hook-delivery-queue` | Per-hook queue of pushed events: deduplication, retry with backoff, and bounded delivery runs from the driver's alarm. | A hook driver delivers provider pushes through `HookInitiator.startHook()`. |\n| `./oauth-client` | OAuth 2.0 token-endpoint client, PKCE, and refresh adapter. | A gatekeeper exchanges, refreshes, or revokes OAuth 2.0 tokens. |\n| `./preview-oauth` | Signed OAuth state and stable-to-preview callback relay. | Preview Workers share one callback registered with the OAuth provider. |\n| `./endpoint` | User-supplied provider endpoint normalization, and agent-supplied path-segment encoding (`pathSegment`, `pathSegments`). | A user enters a self-hosted provider URL, or an agent-supplied id goes into a request path. |\n| `./http-errors` | HTTP access-error classification and ACL probes. | A verifier distinguishes no access from provider failure. |\n| `./response-body` | Strict byte-capped body decoding, and `readTextPrefix` for error excerpts. | A gatekeeper reads any provider response body, or a connect form posted to a route that reads the body before checking the nonce. |\n| `./git-transport` | Git smart-HTTP framing: protocol-v2 fetch (pkt-line, filter specs, sideband demux into a raw pack stream) and send-pack ref updates, plus `simulateBranchHead` for a branch with queued pushes. | A gatekeeper implements `Gatekeeper.gitPull()` or a `push` action against a git host; it supplies only the URL, auth, and timeouts as a fetch callback. |\n| `./git-pull-transfer` | `startGitPull`: a one-shot raw PACK stream with a separate RPC-visible terminal status. | A gatekeeper implements `Gatekeeper.gitPull()` and must preserve provider errors across Workers RPC. |\n| `./git-objects` | Commit-id validation, raw commit-object parsing, and commit advertising (`advertiseCommits`, and `advertisePages` as a `PageHookCursor` hook). | A session returns commit ids (advertise them) or simulates reads of commits queued for push from their local bytes. |\n| `./git-diff` | Tree-to-tree diff over an injected `TreeDiffSource`, and `parseUnifiedPatch` for provider patches, both producing the unified-patch hunk shape agents see. | A pull/merge request's source branch has queued pushes and the provider cannot compute the diff yet, or a provider returns unified patches. |\n\n## Internal modules\n\nThe package does not export `kv`, `positive-int`, `per-storage`, `action-journal`, or\n`observer-tracker`. The last two are re-exported through `./actions` and `./observers`.\n\n## More documentation\n\n- [`USAGE.md`](USAGE.md): integration sequencing, storage, bounds, and operational sharp edges.\n- Exported-symbol JSDoc: exact API contracts and examples.\n- [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md): design record and Layer 2 proposal.\n- [`AGENTS.md`](AGENTS.md): package-specific contributor constraints and verification commands.\n","packages/gatekeeper-mcp-portal/README.md":"# MCP Server Portals gatekeeper\n\nConnects the deployment's own MCP portal as a Gadgets capability. An administrator configures one\nURL; everyone in the organization then reaches every MCP server the organization has approved\nthrough it, and no user ever types an endpoint. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_MCP_PORTAL` binding.\n\nThe intended deployment is a\n[Cloudflare MCP server portal](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/):\nAccess decides who may connect, Cloudflare Gateway logs and inspects what crosses, and this\nconnector turns what is behind it into typed capabilities that Gadgets can grant one server at a\ntime.\n\nFor endpoints a user supplies themselves, use [`gatekeeper-mcp`](../gatekeeper-mcp/README.md).\n\n## What it provides\n\nOne resource type, **MCP portal server**, at two grant breadths. A grant always names one upstream\nserver; \"everything the portal offers\" is not offered, since it would hand a Gadget every tool of\nevery system the organization has connected in one click.\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool of one upstream server, including ones it adds later | `<endpoint>#server=github` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools of that server | `<endpoint>#server=github&tool=github_a&tool=github_b` | `Mcp<Name><tag>Session` |\n\n`<tag>` is four hex characters derived from the resource URL. It matters most here: two grants\npinning different tools of one upstream server share both the name and the endpoint, so the scope is\nthe only thing that distinguishes them.\n\nThe session API — a typed method per described tool, plus `callTool`, `getActionResult`, and\n`listTools` with progressive search/name options — is the same as\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).\n\nA server-wide grant can cover more tools than one catalog describes, so its authority is not limited\nto the ones with generated signatures. `listTools({ search })` searches beyond the bounded preview\nand returns up to 20 compact matches within the shared 5,000-tool / 4 MiB discovery scan;\n`listTools({ name })` loads one exact bounded definition, and `callTool` resolves that name under the\nsame bound before dispatch. Every path rejects names outside the\nbinding's scope before loading the catalog or contacting the endpoint. Discovery and read results are\nrecorded as observations; writes retain the ordinary approval flow.\n\nScoping to one server also shrinks what the agent reads: only that server's tools are rendered as\nsignatures, and only as many as the budget describes. Additional tools are discovered on demand\nwithin the explicit scan limits above; exceeding a limit fails rather than pretending a tool is absent.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `MCP_PORTAL_URL` | The portal's MCP endpoint. Unset means the connector hides itself. |\n| `MCP_PORTAL_NAME` | Display name in the connector list and every approval prompt. Defaults to `MCP Server Portal (<host>)`. |\n| `MCP_PORTAL_AUTH` | `oauth` (default), `none`, or `token`. |\n| `MCP_PORTAL_TOKEN` | Secret bearer token, for `MCP_PORTAL_AUTH: \"token\"`. |\n| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default; see below. |\n| `MCP_PORTAL_HIDDEN_SERVER_IDS` | Comma-separated upstream server IDs to hide from the configurator and refuse at the grant boundary. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, for the portal and every OAuth URL discovered from it. Local dev only. |\n\nThe portal must expose upstream tools directly. Use a portal where Code Mode is off or opt-in, or\nappend `?codemode=off` when its policy is default-on. Enforced Code Mode is unsupported. Do not add\nan `optimize_context` parameter or opt in to Code Mode on `MCP_PORTAL_URL`.\n\nOnly `MCP_ALLOW_INSECURE` is set in the repo's `cloudflare.config.ts`, pinned to `\"false\"` so the default\nis explicit rather than merely absent. None of the others is, and a portal URL committed there would\nbecome the default for every deployment of this repo and would send their users' OAuth flows to\nwhichever host it named, so it belongs in the deployment's own configuration — for Cloudflare's\ninternal deployment, the per-package overrides in `gadgets-internal`.\n\nUnconfigured, `getSupportedResources()` returns nothing and the Workshop drops the vendor. A\n`MCP_PORTAL_URL` that cannot be used — a non-`https` typo or a URL containing `username:password`,\nsay — is treated the same way, so a misconfiguration hides the connector rather than producing one\nthat fails on first use or copying URL credentials into account state and configurator fields.\n\nChanging `MCP_PORTAL_URL` on a deployment that already has connected accounts is a **repoint**.\nExisting bindings fail closed at once: the minting path checks facet props against current\nconfiguration, and an already-minted facet must name its endpoint when asking the account for\ncredentials, which the account refuses after it has moved. The user recovers by reconnecting, which\nis the one endpoint change an account will accept. It is accepted only because the new endpoint\ncomes from this Worker's configuration rather than from a form. Nothing held for the old portal\nsurvives the move: tokens, the transport session, and any in-progress authorization are dropped, so\nthe user re-authorizes against the new host. The account advances a persisted generation before\nprobing; refreshes, expiry notifications, and session writes that started under the old generation\nare ignored when they eventually return. Always-approve action kinds also include the exact endpoint,\nso consent for the old portal does not carry over to the new one.\n\n`MCP_PORTAL_TRUST_ANNOTATIONS` is read at each point of use (`portalTrust(env)`) and never\npersisted on an account or a binding's props, so clearing it de-escalates every existing connection\non the next call. Setting it does not retroactively auto-apply anything; the user must still enable\na rule for each action kind.\n\n## How the connect flow works\n\nThere is no connect form: the endpoint is a deployment setting, so pressing \"connect\" goes straight\nto the portal's own sign-in. Under `MCP_PORTAL_AUTH: \"oauth\"` the gatekeeper runs the same\ndiscovery chain as [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#how-the-connect-flow-works)\nagainst the portal; under `\"token\"` it presents `MCP_PORTAL_TOKEN` and no user interaction is\nneeded; under `\"none\"` it connects unauthenticated.\n\nThe account records `provenance: \"deployment\"`, which is what keeps an upstream server from renaming\nitself over `MCP_PORTAL_NAME` in every approval prompt.\n\n## Granting\n\nThe configurator asks which server behind the portal, how broad the grant is, and — if it is\npinned — which tools:\n\n```\nServer · Which server behind this portal to grant. Its tools appear next.\n[ 🔍 GitHub ]\n\nTools · Choose how much of this server the Gadget may call.\n(•) All tools      Every tool this server offers, including ones it adds later.\n( ) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · Read-only tools return data straight away. The rest queue for your approval.\n[ 🔍 Filter tools...                                                   ]  12 of 12 selected\n☑ List issues                                                                     read-only\n☑ Create issue                                                               needs approval\n```\n\n**All tools** is the default. The breadth is asked outright rather than inferred from whether every\nbox is ticked, since the two look identical and diverge as soon as the server publishes another\ntool. A portal with one server behind it collapses to the tool questions alone, because the choice\nis made for the user rather than skipped — the grant still records that server.\n\nEvery grant here names exactly one upstream server. There is no breadth that spans the portal, so a\nconfigured endpoint with no upstream servers — a plain MCP server, or a portal that currently fronts\nnothing — is **not grantable through this connector**: the configurator has no server to offer and\nthe form stays unsubmittable. That is deliberate. The alternative is a grant recorded against the\nbare endpoint, which would silently include every server added to it later. Point a plain MCP\nendpoint at the **MCP** connector (`gatekeeper-mcp`) instead, which is built for exactly that and\noffers endpoint-wide and per-tool grants.\n\n## How upstream servers are recovered\n\nA portal flattens every upstream server's tools into one `tools/list`, so the seam has to be\nrecovered from two facts in the portal's documented contract:\n\n1. Every tool is named `{server_id}_{original_name}`, split on the first underscore only, and an\n   alias replaces the tool name but never the server-id prefix. Membership is therefore a pure\n   string test needing no network call, so a server scope cannot fail open on a transient error.\n2. `portal_list_servers` is available in every portal session and returns each upstream server's id,\n   name, and enabled state.\n\nDetection is a capability probe — does the endpoint offer `portal_list_servers`? — not a hostname\nmatch, so it works for a custom portal hostname and for any other aggregator adopting the\nconvention. A *truncated* listing counts as a portal whether or not the probe tool is in it:\n`tools/list` is unordered, so concluding \"not a portal\" because the evidence fell past the cut would\nfail open on the `portal_*` exclusion below. Truncation is reported by the client rather than\ninferred from the tool count, because either cap can stop a listing — the count the caller asked for,\nor the 96 KiB UTF-8 budget, and the latter can cut a listing of verbose tools short while leaving an\narray that looks complete. The byte budget leaves 32 KiB below Durable Object's per-value limit for\nthe cache wrapper and serialization overhead; if storage nevertheless rejects the cache value, the\nfresh catalog is still used for that operation rather than turning a cache miss into a failure.\n\n### Surveying a portal too large for one catalog\n\nThe configurator normally gets server names directly from `portal_list_servers`, without surveying\nevery upstream tool. If that response is unavailable or only partly understood, it falls back to a\n**name-only tool index** of up to 1,000 entries. The index detects the portal and recovers server\nmembership from tool-name prefixes; it carries no descriptions, schemas, or policy claims, so the\n96 KiB result budget covers as many names as possible. A truncated fallback cannot establish the\ncomplete server list and blocks the form rather than presenting a partial list as complete.\n\nAfter a server is selected, a separate filtered scan returns up to 200 compact summaries from that\nserver. Each summary carries its bounded title, description, and annotations, and is classified\nthrough the shared `tools.ts` trust boundary before it becomes a read-versus-approval label. The\nfilter is applied before result budgets, so unrelated servers cannot crowd the selected one out.\nOnly the returned prefix is offered for an individual-tool grant; additional tools require the\nserver-wide grant, and a call resolves the full definition before approval or dispatch.\n\nIndex entries are typed separately (`IndexedTool`) to distinguish name-only survey results from tool\ndefinitions rendered into approval prompts or handed to an agent.\n\nThe server list is advisory: it supplies display names and ordering while tool-name prefixes remain\nthe authority on membership, and a failed call degrades to bare ids recovered from tool prefixes.\nThe gatekeeper makes that call\nwhile building a form, so it does not pass through the approval queue. Failing to reach the portal\nat all is different, and blocks the grant rather than falling back to the bare endpoint — the\nconfigurator reports it and stays unsubmittable.\n\n`portal_*` tools are excluded from every grant at every scope. `portal_toggle_servers` and friends\nchange which upstream servers the session can reach, so granting one would let a Gadget widen its\nown authority. This is a capability-boundary rule, not a policy preference, and is not configurable.\n\n## Approvals and sharing\n\nIdentical to [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#approvals-and-sharing), except for the\ntrust tier. A portal is an aggregator: `destructiveHint` and `idempotentHint` are written by\nwhichever upstream server it fronts, servers the administrator who chose the portal never reviewed,\nand any one of them could self-declare both and get writes that skip the approval prompt. So the\ntier is `byo` unless a deployment sets `MCP_PORTAL_TRUST_ANNOTATIONS=true`. That flag is the\n\"trusted server\" assertion MCP's guidance asks for, and it has to be about the upstreams themselves,\nnot just the portal in front of them.\n\n`addObserver` refuses everyone, as it does for user-supplied endpoints. Reaching the portal is not\nthe same as being allowed to see a particular tool result, and the Gadget runs on the owner's\ncredentials regardless. A sharper check is available here than for a bare endpoint —\n`portal_list_servers` is per-user and Access-filtered, so it could confirm a collaborator may reach\nthe same server — but that is still server-granular, not record-granular. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\n## Notes and current limitations\n\nThe limitations in\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#notes-and-current-limitations) apply here too: no\nsimulation, no revert, no hooks, no scoping below tool names, and `tools/*` only. SSRF is enforced\nthe same way, by `global_fetch_strictly_public` rather than by hostname patterns. In addition:\n\n- **One portal per deployment.** `MCP_PORTAL_URL` is a single value; a second portal needs a\n  second Worker.\n- **Server ids come from tool-name prefixes.** An aggregator that flattens tools without prefixing\n  them exposes no upstream seam, so this connector finds no server to scope a grant to and the form\n  stays unsubmittable. It is not granted as a whole endpoint — that grant does not exist here. Use\n  the **MCP** connector (`gatekeeper-mcp`) for such an endpoint, where endpoint-wide breadth is an\n  explicit, reviewable choice.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/portal.ts` | Vendor, account DO, user, gatekeeper facet, session, configurator RPC |\n| `src/config.ts` | Reading and validating the deployment's portal configuration |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nEverything else comes from [`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-portal-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-portal-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n","packages/gatekeeper-mcp/README.md":"# MCP gatekeeper\n\nConnects any [Model Context Protocol](https://modelcontextprotocol.io) server as a Gadgets\ncapability. The user pastes an endpoint URL, the gatekeeper runs the OAuth discovery chain against\nit, and each of the server's tools becomes a typed method on the session. One Worker covers every\nMCP server, so a server needs no Gadgets-specific work to be usable from a Gadget. Runs as its own\nCloudflare Worker and is auto-discovered by the backend from its `GATEKEEPER_MCP` binding.\n\nIf your organization runs an MCP server portal, use\n[`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) instead: it takes one\nadministrator-configured URL, needs no user to paste an endpoint, and scopes a grant to a single\nupstream server behind the portal.\n\n## What it provides\n\nOne resource type, **Any MCP server**, at two grant breadths:\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool the endpoint offers, including ones it adds later | `<endpoint>` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools; anything else is refused | `<endpoint>#tool=a&tool=b` | `Mcp<Name><tag>Session` |\n\nA `#server=` fragment is refused here; scoping to one server behind a portal is the MCP Server Portals\nconnector's grammar. `@gadgets/mcp-shared/scope` owns the grammar and the enforcement.\n\n`<Name>` comes from the endpoint's host and is for reading, not identity — `acme.com` and `acme.io`\nboth give `Acme`. `<tag>` is four hex characters derived from the resource URL above, so two grants\nthat differ in what they may call get different type names. The agent is shown each binding's types\nin its own block with no way to compare them, so a shared name would present two unrelated tool\nsurfaces as one.\n\nThe session carries a named method per tool, generated from the server's own `inputSchema`:\n\nThe binding is named after the endpoint's host, so `https://mcp.linear.app/mcp` suggests\n`MCP_LINEAR` (see `server-id.ts`):\n\n```ts\n// Read-only tool: resolves immediately, recorded as an observation.\nlet search = await env.MCP_LINEAR.searchIssues({ query: \"state:open\" });\nif (search.status !== \"ok\") throw new Error(search.message);\nlet { text } = search;\n\n// Anything else: queued for approval, result collected afterwards.\nlet queued = await env.MCP_LINEAR.createIssue({ title: \"Fix the thing\" });\nif (queued.status === \"pending\") {\n  let outcome = await env.MCP_LINEAR.getActionResult(queued.actionId);\n}\n\n// The same call by its exact wire name, for a tool whose name cannot be a method.\nawait env.MCP_LINEAR.callTool(\"search_issues\", { query: \"state:open\" });\n```\n\nEach generated method is a one-line delegate to `callTool`, so the scope check, approval queue, and\nobservation record stay in one place. Tools whose names the RPC layer cannot deliver (`then`, `map`,\n`dup`), names that collide with session methods (`listTools`, `callTool`, `getActionResult`), names\nthat are not identifiers (`2fa`), and both\nsides of a case collision (`list_issues` and `listIssues`) get no method and remain callable through\n`callTool`.\n\nThe agent normally discovers tools statically: `describeGatekeeper()` sends it the binding name and\nthe bounded generated `.d.ts`, where each method carries the tool's own description as JSDoc and\nstates whether calling it needs approval. A server can publish more definitions than that bounded\ncatalog holds: `listTools({ search })` returns up to 20 compact summaries from a bounded scan of the\ngrant, limited to 5,000 tools / 4 MiB; `listTools({ name })` fetches one exact granted definition and\nschema under the same bound, and `callTool()` invokes it by wire name.\n`listTools()` remains the list of definitions currently described in the generated surface.\n\nSee `src/types.d.ts` in `@gadgets/mcp-shared` for the base session API.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `BASE_URL` | Public base URL of this Worker, for OAuth redirects. |\n| `MCP_CLIENT_NAME` | Client name sent in `initialize` and dynamic client registration. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, on the endpoint and on every OAuth URL discovered from it. Local dev only. |\n\nThere is nothing to configure per server: users supply endpoints, and an administrator's only lever\nis whether this connector is offered at all, in the Gatekeepers admin panel. There is no server\ncatalog — a deployment that wants to offer a chosen set of servers should front them with a portal\nand use [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md).\n\nFor local development no credentials are needed. Set `MCP_ALLOW_INSECURE=true` in the repo-root\n`.dev.vars` to connect a server running on localhost.\n\n## How the connect flow works\n\n1. The user starts a connection and gets a form asking for the server's endpoint URL. The URL is\n   validated against the host blocklist in `endpoint.ts` (no private, loopback, or metadata hosts;\n   HTTPS required unless `MCP_ALLOW_INSECURE`). The form says plainly that connecting a server is a\n   decision to trust it, since the server's own annotations decide which of its tools run without\n   asking (see below).\n2. The gatekeeper opens a Streamable HTTP session and calls `initialize`. A server that completes\n   the handshake unauthenticated is recorded as public and no token is demanded of it later.\n3. A `401` starts the official MCP client's OAuth flow:\n\n   ```\n   401 + WWW-Authenticate  ->  protected resource metadata   (RFC 9728)\n                           ->  authorization server metadata (RFC 8414)\n                           ->  dynamic client registration   (RFC 7591)\n                           ->  authorization code + PKCE     (RFC 7636)\n                               + resource indicator          (RFC 8707)\n   ```\n\n   The SDK falls back to the conventional\n   paths (`/authorize`, `/token`, `/register`) that the Workers OAuth provider and most MCP servers\n   use. That means a server which does not implement dynamic client registration is not detected as\n   such up front — the synthesized `/register` is tried and refuses, so the failure reads as a\n   rejected registration rather than a missing capability. The remedy is the same either way:\n   connect a server that supports registration, or reach it through a portal an administrator has\n   configured with a preissued token.\n4. Tokens are stored in the `McpAccount` Durable Object and refreshed proactively, before the\n   recorded expiry. Nothing outside this Worker can obtain one.\n\n   A `401` mid-session is not a refresh trigger: it means the server rejected a token this Worker\n   believed was valid, so the account is marked as needing attention and the user is asked to\n   reconnect. Refreshing in response would paper over a revoked or repudiated grant. Refresh\n   failures are classified — only the authorization server's own verdict on the credential\n   (`invalid_grant` and friends) marks the account expired, while transport and unrecognised failures\n   leave it alone to be retried.\n\nThe endpoint is fixed at first connect. Reconnecting an account cannot point it at a different\nserver, since the binding's props still name the original. (The MCP Server Portals connector is the\nexception: its endpoint comes from deployment configuration rather than a form, so a reconnect there\nmay adopt a repointed gateway. See its README.)\n\n## Scoping a binding\n\nThe configurator asks how broad the grant is and, if it is pinned, which tools:\n\n```\nTools · Choose how much of this server the Gadget may call.\n( ) All tools      Every tool this server offers (14 today), including ones it adds later.\n(•) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · 3 selected. Read-only tools return data straight away; the rest queue for approval.\n[ 🔍 Filter tools...                                  ]  3 of 14 selected   Select all   Clear\n☑ Search issues                                                                        read-only\n☑ Create issue                                                                    needs approval\n```\n\nThe breadth is asked outright rather than inferred from whether every box is ticked: \"all 14 ticked\"\nand \"these 14 by name\" look identical and diverge as soon as the server publishes a fifteenth. The\nlist stays on screen in both modes, disabled under **All tools**.\n\nTools named `portal_*` are never grantable on an endpoint that identifies itself as a portal, since\nthey let a session change which upstream servers it can reach.\n\n## Approvals and sharing\n\nA call to a tool the server annotates `readOnlyHint: true` is an observation and returns straight\naway. Every other call is queued via `submitAction()` and reaches the server only when the Overseer\ncalls `applyAction()`. Annotations are optional in MCP and most servers publish none; every hint is\ntested with `=== true` or `=== false`, so an unannotated tool is an action, needs approval, and can\nnever auto-apply.\n\nMCP's own guidance is that a client must treat tool annotations as untrusted unless they come from a\ntrusted server. The trust tier is that distinction, and it is the deployment's to make rather than\nthe server's: endpoints here are user-supplied rather than administrator-vetted, so they get `byo`.\n`readOnlyHint` classifies reads, but no claim the server makes can auto-apply a write.\n\nHonouring `readOnlyHint` on `byo` is a knowing departure from that rule, and the limit of what this\nconnector can promise. A server that labels a destructive tool `readOnlyHint: true` gets that tool\nrun without a prompt. Refusing the hint would mean an approval prompt for every `search` and `list`,\nwhich makes the connector unusable for the thing people connect it to do; and it would buy less than\nit looks, since a dishonest server can act on any call, including one the user did approve. What the\ntier does guarantee is that no BYO server gets a write *auto-applied*, and that every call trusted on\nthe server's word is recorded as such.\n\nSo the trust decision is made once, by the person pasting the URL, and the connect form says so\nplainly: an annotation is only as trustworthy as the server that sent it. Auto-approval requires a\n`vetted` endpoint, which only [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) can\nproduce. Every call records which side classified it (`McpToolInfo.classifiedBy`), so an audit can\nfind each one taken on the server's word. See `tools.ts` in `@gadgets/mcp-shared` for the tier\nrules.\n\nA Gadget bound to an MCP server can only be opened by its owner: `addObserver` refuses\nunconditionally. Being able to authenticate to a server is not evidence of being allowed to see what\nthe *owner* read from it, and the Gadget runs on the owner's credentials throughout. Writes still\nwork — the alternative, marking every observation `containsRestrictedData`, would set that\nflag, whose restricted mode blocks every action for the rest of the session. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\nTo share the work rather than the binding, publish the Gadget as a blueprint and let each person\nconnect their own server.\n\n## Notes and current limitations\n\n- **No simulation.** MCP describes no way to predict a tool's effect, so a queued call is not\n  reflected in later reads. `awaitDecision` is set and the agent's turn suspends until the user\n  decides.\n- **No revert.** MCP describes no inverse for a tool call, so `implementsRevert` is false.\n- **No hooks.** `notifications/tools/list_changed` is session-scoped; a Gadget hook is durable and\n  must survive restarts.\n- **No scoping below tool names.** MCP tools take arguments, not capabilities, so \"this repo only\"\n  cannot be expressed. A list of allowed tool names is the narrowest grant available.\n- **Only `tools/*`.** Prompts, resources, sampling, and elicitation are not implemented. Sampling\n  and elicitation would let a server drive the agent.\n- **Tool-list changes are adopted, not pinned.** A changed list is taken and logged\n  (`catalog.changed`), since refusing to see new tools would break working Gadgets. A binding with a\n  tool scope cannot widen this way, which is the reason to prefer **Choose tools** for anything that\n  writes.\n- **SSRF is enforced after DNS, not by the blocklist.** The hostname patterns in `endpoint.ts` are a\n  legible refusal at connect time; they cannot see through a public hostname that resolves, or\n  rebinds, to a private address. The actual boundary is the `global_fetch_strictly_public`\n  compatibility flag in `cloudflare.config.ts`, which makes workerd reject reserved IP ranges after\n  resolution on every request and redirect hop. It does not apply under `wrangler dev`, which is\n  what keeps `MCP_ALLOW_INSECURE` usable locally.\n- **Sharing UI reports late.** `GadgetMetadata.containsRestrictedData` derives only from\n  `ObservationDescription.containsRestrictedData`, so creating a share key appears to succeed and\n  fails when the recipient opens it (their observer verification is refused). Fixing this needs a\n  kernel change.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/mcp.ts` | Vendor, account DO, user, verifier, gatekeeper facet, session |\n| `src/connect-form.ts` | The endpoint prompt served during connect |\n| `src/server-id.ts` | Endpoint to display slug, for the binding name and session type |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nThe MCP client, OAuth, tool classification, generated TypeScript, and the scope grammar come from\n[`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n","packages/gatekeeper-notion/README.md":"# Notion gatekeeper\n\nMediates a Gadget's access to a user's [Notion](https://www.notion.so) workspace: pages and\ndatabases. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_NOTION` binding.\n\n## Auth\n\nOAuth 2.0 public connection. Configure a Notion **public integration** and provide its client\ncredentials to the worker as `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts`\nmaps `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nThe integration's **redirect URI** must match `<BASE_URL>/oauth`, which in local dev defaults to\n`http://localhost:8787/gatekeeper/notion/oauth`. Recommended capabilities: read/insert/update\ncontent, read/insert comments, read user info (no email needed — `providesAuth` is false).\n\nThe connect flow uses a two-phase nonce (initiation → OAuth) and stores the access + refresh\ntokens in a `UserAccount` Durable Object; access tokens are refreshed on a 401.\n\n## Resources\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all) | `NotionWorkspace` |\n| A page or database | `https://www.notion.so/:path+` | `NotionPage` or `NotionDatabase` (detected server-side) |\n\nOnly the pages/databases the user shares with the integration during the OAuth page-picker are\nreachable.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Page bodies are\nexchanged as Markdown and property values use a simplified union. Highlights:\n\n- `NotionWorkspace`: `search`, `getPage`, `getDatabase`, `createPage`, `listUsers`\n- `NotionPage`: `getMetadata`/`getProperties`/`getContent`, `appendContent`, `setTitle`/\n  `setProperties`/`setIcon`, `createSubPage`, `archive`/`restore`, comments, `listChildPages`\n- `NotionDatabase`: `getSchema`, `query` (typed filters/sorts), `getPage`, `createPage`\n\n## Approvals, caching & simulation\n\nEvery read calls `authorizeObservation()`; every write is staged via `submitAction()` and only\nperformed in `applyAction()`. Reads simulate pending (unapproved) writes so a Gadget sees its own\nchanges immediately — including provisional IDs for created pages. Page/database/data-source/user\nresponses are cached in DO storage with short TTLs.\n\n## Data sources\n\nNotion's newer model splits a database into one or more **data sources**. Database `query`,\n`getSchema`, and row creation resolve the database's primary data source under Notion-Version\n`2025-09-03`; pages/blocks/comments/search use `2022-06-28` so user-facing IDs/URLs stay\nconsistent. This split is hidden from the Session API.\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/notion-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/notion-gatekeeper test:run    # vitest\n```\n","packages/gatekeeper-scheduler/README.md":"# Scheduled Tasks\n\nScheduled Tasks is an ambient Gatekeeper that lets workspace code register persistent callbacks for\nelapsed intervals, wall-clock recurrences, and one-time runs. Each account also gets a read-only\nmanagement app at `/gatekeepers/scheduler`.\n\n## User flow\n\n1. Ask the agent to create a scheduled task, or open **Scheduled** and choose a starter prompt.\n2. Confirm what the task should do, the workspace and resources it may use, its cadence, and its\n   IANA timezone when it uses wall-clock time.\n3. The agent registers a persistent callback. Registration creates a disabled hook and returns its\n   schedule ID; it does not start the schedule.\n4. Enable the hook in the Workshop's Connections UI.\n5. Use **Scheduled** to search and inspect schedules across the account. Select a schedule row to\n   open its workspace.\n\nThe Scheduler app is intentionally read-only. Enabling and disabling remain in Connections; the app\ndoes not provide editing, pausing, deletion, run history, or a second hook toggle.\n\n## Agent API\n\nThe ambient binding exposes `ScheduleSession`. The exact agent-facing contract and examples live in\n[`src/types.d.ts`](src/types.d.ts).\n\n```ts\n// executeCode, where `callback` is a persistent stub for the params `{ type: \"dailyBrief\" }`\nconst scheduleId = await env.SCHEDULER.calendarAt(\n  {\n    timeZone: \"America/Chicago\",\n    freq: \"weekly\",\n    byDay: [\"MO\", \"TU\", \"WE\", \"TH\", \"FR\"],\n    hour: 8,\n    minute: 0,\n  },\n  callback,\n  {\n    title: \"Daily brief\",\n    description: \"Prepare the morning calendar and inbox brief.\",\n    occurrences: { count: 10 },\n  },\n);\n```\n\nUse the three registration methods according to the user's intent:\n\n- `every(everyMs, callback, options)` uses elapsed UTC time. The minimum interval is 60 seconds.\n- `calendarAt(rule, callback, options)` follows local wall-clock time and requires an explicit IANA\n  timezone. It supports hourly, daily, and weekly rules.\n- `runAt(when, callback, options)` runs once at an absolute epoch-millisecond timestamp or an\n  explicit timezone-aware wall-clock time.\n\nRecurring `every()` and `calendarAt()` calls may stop after a finite bound. Give one or the other,\nnever both; `runAt()` accepts neither.\n\n- `occurrences: { count: 10 }` allows ten logical scheduled occurrences.\n- `occurrences: { until: { timeZone, year, month, day, hour, minute } }` allows occurrences through\n  that instant, inclusively. An absolute epoch-millisecond `until` is also accepted. Registration\n  rejects a cutoff that precedes the schedule's first occurrence.\n\nThe count bounds *due slots*, not successful runs. A slot consumes one count as soon as it becomes\ndue and takes a `runId`, even if admission or callback delivery then fails; admission failures skip\nthe slot without retrying it. Retries reuse the same `runId` and do not consume another count.\nMissed occurrences remain skipped and do not count.\n\nA bound is checked against live state, so a schedule that reaches it reports `completed`. A schedule\nwhose cutoff already passed by the time its hook is enabled reports `expired` instead: it never got\na slot. A schedule whose callback exhausts its eight attempts reports `dead` and stops there,\nwhether or not the bound was reached — a `count: 10` schedule that dies on its third occurrence\nnever reaches the fourth. Disabling a hook drops its driver state, so re-enabling the same schedule\nrestarts the count — treat `count` as a bound per enablement, not a lifetime guarantee.\n\n`list()` returns active and terminal schedules for enabled hooks in the current workspace only. It\ndoes not expose schedules from other workspaces in the account.\n\n## Persistent callbacks and retries\n\nCallbacks implement `ScheduledTaskHook.onSchedule()` and must be made persistent before\nregistration: `this.ctx.restore(params)` inside the Gadget, or through the Gadget's binding from\n`executeCode`, as the agent's instructions on persistent stubs describe. Each firing contains:\n\n- `scheduleId`, the stable registration ID;\n- `runId`, stable across retries of one logical occurrence;\n- `scheduledTime`, the planned Unix epoch time;\n- `actualTime`, the current delivery-attempt time; and\n- `timeZone`, the schedule's display timezone (`UTC` for elapsed intervals and numeric one-shots).\n\nCallback delivery is best-effort within a bounded retry window and may occur more than once. Callback\ncode should use `runId` as an idempotency key. Authorization or callback failures retry up to eight\ntotal attempts with exponential delays beginning at one minute and capped at one hour. Exhausted\nschedules enter the **Needs attention** state.\n\nThe Workshop admission check runs before every attempt. If the hook, gatekeeper, or account is no\nlonger allowed, the occurrence is skipped without consuming a callback attempt. Recurring schedules\nadvance to their next future occurrence; a due one-shot expires.\n\n## Cadence behavior\n\n- Recurrences preserve the phase established at registration.\n- Missed occurrences are skipped, not replayed or caught up.\n- Fixed intervals measure elapsed time and therefore shift relative to local clocks across DST.\n- Calendar schedules retain their requested local wall-clock cadence across DST.\n- A nonexistent spring-forward time moves forward by the transition gap.\n- An ambiguous fall-back time uses the earlier instant and fires once.\n- Date-less wall-clock one-shots resolve to the next future occurrence of that local time.\n\nAlways ask the user for the timezone of a wall-clock schedule. Do not infer it from locale or silently\nchoose UTC.\n\n## Lifecycle\n\nRegistration only binds a disabled Workshop hook; it writes no schedule row. Enabling records the\ntarget workspace ID and optional gadget ID, then creates the account-driver row and arms its alarm.\nDisabling removes the row and its stored capabilities. Re-enabling creates fresh active state,\npreserves the original recurrence phase, and does not replay missed work. A repeated enable while\nthe row still exists instead preserves its current schedule state and refreshes its activation details.\n\nSuccessful one-shots become **Finished**. Past or rejected one-shots become expired. Terminal rows\nremain visible and consume enabled-schedule quota until their hook is disabled in Connections.\nCreating a workspace from a blueprint does not copy schedules or capabilities: the new workspace must\nregister its callback and receive fresh enablement.\n\nDisconnecting the Scheduler account revokes its driver, deletes schedule state, and leaves a permanent\ntombstone so retained stale controllers cannot recreate the account.\n\n## Management app\n\nThe account advertises its UI through the generic `AccountDescription.providesUi` mechanism, the same\nmechanism used by other Gatekeeper management apps. The Workshop discovers it dynamically and hosts\nthe single-file app in an opaque-origin, network-isolated `srcDoc` frame.\n\nThe app can only call its account-scoped, read-only `list()` capability plus bounded host methods for\ntheme updates, workspace-title resolution, navigation, and starter prompts. Pages contain at most 100\nschedules. Search is case-insensitive over title and description and is limited to 200 characters.\nCursors are opaque, bounded, and tied to the normalized search and status filters.\n\nThe **All**, **Active**, **Needs attention**, and **Finished** tabs map to the projected\n`active`, `dead`, and `completed`/`expired` statuses. Target metadata is presentation data only;\nopening its validated internal route grants no new authority. Create and starter actions place fixed\neditable text in the Home composer and never submit it automatically.\n\n## Architecture and security\n\nEach Scheduler account owns one SQLite-backed `ScheduleDriver` Durable Object and one alarm for all of\nits workspaces. Workspace sessions inherit their opaque scope from the containing Overseer facet;\ncallers cannot supply an account or workspace ID. Plain schedule metadata and reconstructable RPC\ncapabilities are stored under separate keys and changed transactionally.\n\nThe alarm persists state before crossing an RPC boundary. It processes at most 20 due schedules per\npass with four concurrent deliveries, then arms an immediate continuation when a backlog remains.\nStable `runId` fencing prevents late completion or retry continuations from mutating a disabled,\nre-enabled, or revoked schedule.\n\nOne account-wide driver keeps management and revocation simple, but it is also a shared failure domain:\na user callback that never settles can delay other schedules in that account until the runtime aborts\nthe alarm. Bounded batches limit ordinary load; they do not eliminate that tradeoff.\n\nThe Scheduler is capability-authorized. It does not receive Workshop user identity, assert its own\nambient policy, expose external network authority, or implement actions. The Workshop's existing hook\nadmission and observation authorization remain the security boundaries.\n\n## Limits\n\n- 500 enabled or terminal schedule rows per account.\n- 100 enabled or terminal schedule rows per workspace.\n- 100 rows per management page.\n- 20 due schedules per alarm pass and four concurrent deliveries.\n- Eight callback attempts per logical occurrence.\n- Schedule titles: 200 characters; descriptions: 2,000 characters.\n\nThese are fixed policy limits rather than deployment settings.\n\n## Development\n\nInstall and build from the repository root:\n\n```sh\npnpm install\npnpm --filter @gadgets/gatekeeper-scheduler test:run\npnpm --filter @gadgets/gatekeeper-scheduler build\npnpm run dev-server\npnpm run dev-client\n```\n\nThe development server discovers `gatekeeper-*` packages, builds `app/` into the generated single-file\nasset, and creates the local `GATEKEEPER_SCHEDULER` service binding. Do not edit generated local\nWrangler configuration.\n\nWorker code gets its runtime and `Cloudflare.Env` types from generated\n[`worker-configuration.d.ts`](worker-configuration.d.ts), referenced by `tsconfig.json`. Regenerate it\nwith Wrangler after changing Worker configuration; do not hand-maintain a parallel `Env` interface.\nThe browser app uses the separate DOM-only `tsconfig.app.json` and imports only environment-neutral\nmanagement DTOs.\n\n## Deployment\n\nBefore exposing Scheduler:\n\n1. Deploy the Workshop hook target-metadata contract and the `startHook()` admission backstop.\n2. Deploy this Worker so its `ScheduleDriver` and `SchedulerGatekeeper` SQLite migration exists.\n3. Add `GATEKEEPER_SCHEDULER` to the deployment's Workshop service bindings.\n\nScheduler declares only that it can auto-provision an account. Workshop provisioning policy defaults\nthe `scheduler` vendor to **optional**; an administrator can explicitly choose disabled, optional, or\nenabled. Production service bindings are site-specific and are not generated by this package.\n\nThe `allow_irrevocable_stub_storage` compatibility flag is required while stored callback capabilities\nexist and must not be removed from an existing deployment.\n\n## Troubleshooting\n\n- **Scheduled is missing from navigation:** confirm the Scheduler binding exists, the vendor is enabled\n  or optional with a connected account, and account discovery returns `providesUi`.\n- **A new schedule is not listed or running:** registration starts disabled. Enable its hook in the\n  workspace Connections UI.\n- **A schedule says Needs attention:** callback authorization or execution exhausted its retry budget.\n  Fix the callback or resource access, then disable and re-enable the hook.\n- **A one-shot says Expired:** its firing time passed without admission or it was already past when\n  enabled. Register a new future one-shot.\n- **A recurrence appears an hour off:** use `calendarAt()` for local wall-clock intent and `every()` for\n  elapsed time; verify the explicit IANA timezone.\n- **A blueprint-created workspace has no schedules:** schedules are deliberately not copied. Register\n  them again in the new workspace.\n\n## Non-goals\n\nV1 does not provide schedule editing, local pause/toggle/delete controls, recurring run history,\n`lastRunAt`, account default timezones, actor attribution, blueprint schedule cloning, catch-up delivery,\nor collaborator-owned schedule routing. Hook lifecycle remains in the Workshop Connections UI.\n","packages/gatekeeper-slack/README.md":"# Slack gatekeeper\n\nMediates a Gadget's **read-only** access to a user's [Slack](https://slack.com) workspace:\nchannels, direct messages, threads, members, and search. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_SLACK` binding.\n\nThis gatekeeper is read-only and never sends or modifies Slack data.\n\n## Auth\n\nOAuth 2.0 using a **user token** (`xoxp-…`), requested via `user_scope` (not a bot token) so the\nagent sees exactly what the connecting user can see — including private channels, DMs, and search.\n\nCreate a Slack app (https://api.slack.com/apps) and provide its client credentials to the worker\nas `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts` maps `SLACK_CLIENT_ID` /\n`SLACK_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nApp configuration:\n\n- **Redirect URL** must match `<BASE_URL>/oauth`, which in local dev defaults to\n  `http://localhost:8787/gatekeeper/slack/oauth`.\n- Enable **token rotation** (OAuth & Permissions → *Token Rotation*). Tokens are then short-lived\n  (~12h) and refreshed via `oauth.v2.access?grant_type=refresh_token`. Non-rotating tokens also\n  work as a fallback (they're returned as-is).\n- Request the **User Token Scopes** the granted resources need (see below). `users:read` is always\n  requested for connected-account display and user-name resolution.\n\n## Org-wide installations\n\nBoth workspace and org-wide installations use the connecting user's token; the bot token in an\norg OAuth response is never used for reads. Org OAuth can return `team: null`, an `enterprise`\nidentity, and `is_enterprise_install: true`. Those describe the installation, not a resource's\nworkspace. In particular, an `E…` enterprise ID must never be used in a `/client/T…` workspace URL.\n\nAn org admin must also assign the app to the desired workspaces. Installing it at the org level\nalone does not make every workspace available. The workspace picker intersects all pages of\n`auth.teams.list` (the app's assignments) with `users.info.enterprise_user.teams` (the connecting\nuser's memberships). See Slack's [Enterprise development guide](https://docs.slack.dev/enterprise/developing-for-enterprise-orgs/)\nand [auth.teams.list reference](https://docs.slack.dev/reference/methods/auth.teams.list/).\n\nEach granted resource still selects exactly one workspace. Workspace and conversation pickers\nretain its `T…` ID in the resource URL and Durable Object props. For thread URLs, workspace\ninstallations verify the permalink host against their own workspace's metadata; only org\ninstallations use the Enterprise-only `team.info(domain)` lookup, followed by membership checks.\nThe agent-facing session API is unchanged.\n\n| API calls | Workspace selector |\n| --- | --- |\n| `users.conversations`, `users.list`, `search.messages` | `team_id` |\n| `team.info` | `team` (not `team_id`) |\n| Channel-addressed conversation calls | `client_context_team_id`, plus workspace membership verification |\n\nPassing channel context is not itself an authority grant. Before reading a channel by ID, the\nclient verifies it belongs to the selected workspace, allowing shared channels linked to that\nworkspace. Responses without decisive membership metadata (including DMs) must appear in a\nworkspace-scoped conversation listing. Search matches undergo the same check. Direct user lookup\nis limited to the workspace directory; author/mention resolution can still resolve participants\nfrom other workspaces in shared channels.\n\nEnterprise DM responses can name another workspace in `shared_team_ids` even when the selected\nworkspace's DM discovery includes the conversation. For DMs and group DMs, that mismatch falls\nthrough to the existing workspace-scoped `users.conversations` proof instead of rejecting the\nread. An exact conversation ID must still be found in the selected workspace's listing; neither\norg-token visibility nor `context_team_id` is sufficient. Shared-channel mismatch denials are\nunchanged. See the [users.conversations reference](https://docs.slack.dev/reference/methods/users.conversations/).\n\nObserver checks use each collaborator's own credentials and the selected workspace ID, never\nenterprise-ID equality. Workspace bindings additionally track the conversations actually read,\nexcluding collaborators who cannot read newly observed private channels or DMs.\n\n### Upgrading existing connections\n\nOlder credentials need not be reconnected solely to learn the installation kind: discovery can\nrecognize an org token through `auth.test`. Legacy bindings did not retain the workspace from their\nURL. On first use, they recover a stored workspace ID, a thread's permalink workspace, or the sole\neligible workspace, then persist that selection separately from credentials. A subsequent reconnect\ncannot silently switch a pinned binding to another workspace.\n\nThe access proof is cached separately from that workspace selection and keyed to the access\ntoken. Every outgoing bound read, including rate-limit retries, checks the exact credential it\nwill use. Replacement credentials must demonstrate access to the pinned workspace before any\ndata request is sent. Failed checks are evicted so transient failures can recover without\nchanging the selected workspace; concurrent checks using the same credential are coalesced.\n\nIf a legacy org binding has several eligible workspaces and no recoverable selection, it fails\nclosed: add the resource again and choose its workspace. Old resource URLs containing an `E…` ID\nalso need to be replaced with a selected `T…` workspace URL.\n\nLegacy conversation-only/thread-only grants may lack the metadata scopes listed below. Reconnect\nor grant the resource again to approve the additional scopes; the reconnect flow preserves those\nprevious resource selections while requesting the new consent. Reads do not treat old scopes as\nsufficient. Workspace grants already requested the required scopes.\n\n## Resources\n\nAccess is granted at one of three granularities. Each grantable resource maps to a URL pattern\nthat drives both consent (which OAuth scopes are requested) and routing:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all whole-instance) | `SlackWorkspaceSession` |\n| A conversation (channel, DM, or group DM) | `https://app.slack.com/client/:teamId/:conversationId` | `SlackConversation` |\n| A thread | `https://*.slack.com/archives/:conversationId/:messageId` | `SlackThread` |\n\nWorkspace grants use the framework's account-wide `https://*` pattern; more-specific conversation\nand thread URLs take precedence. Channels and DMs share one \"Conversation\" grant.\n\n### Scopes per resource (user token scopes)\n\n- **Workspace**: `team:read`, conversation read scopes, `search:read`\n- **Conversation**: `team:read`, conversation read scopes, `search:read`\n- **Thread**: `team:read`, conversation read scopes\n- **Always**: `users:read`\n\nwhere the conversation read scopes are `channels`/`groups`/`im`/`mpim` `:read` + `:history`.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Highlights:\n\n- `SlackWorkspaceSession`: `getInfo`, `listChannels`, `listDirectMessages`, `listUsers`,\n  `getUser`, `getConversation`, `search`\n- `SlackConversation`: `getInfo`, `members`, `listMessages`, `getThread`, `search`\n  (conversation-scoped search is **hard-restricted** to the bound conversation regardless of query)\n- `SlackThread`: `getRoot`, `listReplies`\n\nList and search methods return paginated `Cursor` objects. Known mentions are rendered with readable\nnames.\n\n## Build\n\n```\npnpm exec vp run -F @gadgets/slack-gatekeeper build\npnpm --filter @gadgets/slack-gatekeeper test:run\n```\n\nNode regression tests cover OAuth response shapes, workspace API parameters, shared-channel/DM\nboundaries, legacy recovery, reconnects, observer exclusion, and configurator field dependencies.\nEnterprise DM regressions include nonmatching shared-team metadata, membership on a later listing\npage, nonempty search validation, and denial when the conversation is absent from that workspace.\nValidate the Worker RPC schema with `pnpm exec capnweb-validate build --out .wrangler/validate`\nfrom this package before a local Wrangler dry-run.\n","packages/gatekeeper-spotify/README.md":"# Gatekeeper Spotify\n\nThis package provides Spotify Web API integration for Gadgets. Connect a Spotify account and gadgets\ncan search the catalog, read and edit your library and playlists, manage follows, and control\nplayback on your Spotify Connect devices — all behind the approval queue.\n\nIt offers two resource granularities:\n\n- **Spotify Account** (whole-instance): profile, catalog search, library (saved tracks/albums, top\n  items, recently played), follows, playlists, and playback control via `getPlayer()`.\n- **Spotify Playlist**: read / edit / follow / unfollow a single playlist.\n\nThis gatekeeper is **not** an authentication provider (`providesAuth: false`) — Spotify isn't offered\nas a \"Continue with…\" sign-in method.\n\n> **Targets Spotify's post-February-2026 development-mode API.** Library writes use the generic\n> `PUT/DELETE /me/library`, playlist contents use `/playlists/{id}/items`, etc. Some fields Spotify\n> removed in dev mode (e.g. track `popularity`, parts of the user profile) are returned as `null`.\n\n## Setting up Spotify OAuth credentials\n\nIf you're running this project locally and want to use Spotify integration, you'll need to create\nyour own Spotify app.\n\n### Step 1: Create a Spotify app\n\n1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and click\n   **Create app**.\n2. Fill in a name and description (anything).\n3. **Redirect URI**: add exactly\n\n   ```\n   http://127.0.0.1:8787/gatekeeper/spotify/oauth\n   ```\n\n   > Spotify **does not allow `localhost`** as a redirect URI for new apps — you must use the\n   > loopback IP literal `127.0.0.1`. Replace host/port with your `BASE_URL` when not running\n   > locally (production must use HTTPS).\n4. Under **APIs used**, select **Web API**.\n5. Save, then copy the **Client ID** and **Client secret**.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-spotify/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n# Must match the redirect URI host you registered. Spotify rejects \"localhost\", so use 127.0.0.1.\nBASE_URL=http://127.0.0.1:8787/gatekeeper/spotify\n```\n\n> **Note**: `.env` is gitignored and should never be committed. Because the redirect uses\n> `127.0.0.1`, open the Workshop at `http://127.0.0.1:8787` (not `localhost`) so the OAuth popup\n> stays on the same host.\n\n### Step 3: Add yourself as a user (development mode)\n\nA new Spotify app starts in **development mode**, which limits it to a small number of users you add\nexplicitly. In the dashboard, under **User Management**, add the Spotify account (name + email) you\nintend to connect — otherwise authorization will fail. Lifting this limit requires requesting\nextended quota from Spotify.\n\n> **Playback control requires Spotify Premium.** Player write commands (play/pause/seek/volume/…)\n> return 403 for free accounts.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see the root README.md).\n2. Create or open a gadget and go to the **Connections** tab.\n3. Click **+ New Connection** and choose a Spotify resource type (whole account, or a playlist).\n4. If prompted, connect your Spotify account — you'll be redirected to Spotify's authorization page\n   in a new tab; after granting access the tab closes and you're back in Gadgets.\n5. For a playlist resource, use the picker to choose the playlist to connect.\n6. Create the connection.\n\nYou can see and manage connected accounts in settings (via the account menu in the upper-right).\n\n## Notes & limitations\n\n- **Approvals & simulation.** Reads are logged and writes are queued for human approval; nothing is\n  performed on Spotify until approved. Reads optimistically reflect your own pending edits (e.g. a\n  pending playlist add shows in `listTracks`) so a gadget can keep working before approvals land.\n  Playback commands are gated but **not** simulated — `getState()` always shows real device state.\n- **Non-owned playlists.** Spotify withholds track contents for playlists you don't own/collaborate\n  on, so `listTracks()` returns an empty list (metadata via `getDetails()` still works).\n- **Spotify Connect quirks.** Some third-party Connect endpoints (e.g. Music Assistant) can be\n  invisible to `getDevices()` and `getState().device` may misidentify the active device; confirm\n  device transfers with a follow-up `getState()`.\n\n## Troubleshooting\n\n### \"Spotify Gatekeeper Not Configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Ensure `packages/gatekeeper-spotify/.env` exists with both\nvalues, then restart the dev server.\n\n### \"INVALID_CLIENT: Invalid redirect URI\"\n\nThe redirect URI sent doesn't exactly match one registered on the app. Confirm the dashboard has\n`http://127.0.0.1:8787/gatekeeper/spotify/oauth` and that `BASE_URL` in `.env` is\n`http://127.0.0.1:8787/gatekeeper/spotify` (note `127.0.0.1`, not `localhost`; no trailing slash).\n\n### Authorization fails with \"User not registered in the Developer Dashboard\"\n\nThe connecting Spotify account isn't on the app's **User Management** list (development-mode limit).\nAdd it in the dashboard and retry.\n","packages/gatekeeper-supabase/README.md":"# Gatekeeper Supabase\n\nThis package provides Supabase integration for Gadgets via the [Supabase Management\nAPI](https://supabase.com/docs/reference/api/introduction), using OAuth2.\n\nIt exposes two resource granularities:\n\n- **Project** (`https://supabase.com/dashboard/project/:ref`) — the recommended unit. A project is a\n  hosted Postgres database plus its auth, storage, and edge functions. Gadgets can run read-only and\n  (approval-gated) mutating SQL, introspect schema, and list edge functions and storage buckets.\n- **Organization** (`https://supabase.com/dashboard/org/:slug`) — broader access to discover and act\n  across every project in an organization.\n\nA connected account corresponds to the organization the user authorizes during the OAuth consent\nflow.\n\n## Creating the OAuth app\n\n1. In your Supabase **organization settings**, open the **OAuth Apps** tab\n   (`https://supabase.com/dashboard/org/_/apps`) and **Add application**.\n2. Set the **Redirect URI** to your deployment's callback (replace the host with `PUBLIC_BASE_URL`):\n\n   ```\n   ${PUBLIC_BASE_URL}/gatekeeper/supabase/oauth\n   ```\n\n   For local development that is `http://localhost:8787/gatekeeper/supabase/oauth`.\n3. After creating it, copy the **Client ID** and **Client Secret** (the secret is shown once).\n\n> Use the OAuth app's **Client ID + Client Secret** — not a personal access token or project API\n> key. The `authorization_code` exchange requires the OAuth app credentials.\n\n## Configuration\n\nThe gatekeeper Worker reads `CLIENT_ID` and `CLIENT_SECRET`. In local development these are seeded\nfrom shell/`.dev.vars` variables by `run-dev-server.ts`:\n\n```\nSUPABASE_CLIENT_ID=<oauth app client id>\nSUPABASE_CLIENT_SECRET=<oauth app client secret>\n```\n\n## Notes\n\n- Reads are logged as observations; mutating SQL (`execute()`) is submitted to the approval queue\n  and only runs once a human approves it.\n- Mutating SQL is **not** simulated — arbitrary statements cannot be reliably previewed through the\n  stateless query endpoint, so a `query()` will not observe an un-approved change. This is\n  documented in the API types.\n","packages/gatekeeper-websearch/README.md":"# Web Search\n\nWeb Search is an ambient gatekeeper that lets the agent and gadgets search the public web through\nCloudflare's [Web Search API](https://developers.cloudflare.com/web-search/). It has no connect\nflow, no settings and no management UI.\n\n## Who gets it\n\nThe admin Gatekeepers panel sets the mode, like every auto-provisioned gatekeeper\n(`provisioning-policy.ts` in workshop-backend):\n\n- **Optional** (the default): onboarding offers it switched on, and a user can turn it on or off\n  later on the Gatekeepers page.\n- **Enabled**: every user gets it.\n- **Disabled**: no one gets it, and existing accounts go dormant.\n\nA user who has it gets a `WEB_SEARCH` binding in the agent's `executeCode`, in every workspace. The\nagent can bind it into a gadget with `setGadgetBinding`. The binding appears when the workspace\nnext opens, from the next turn of each chat, including chats that started before. The agent is\ntold about it in that chat; the system prompt doesn't change. A spawned agent's chat keeps the\nbindings its spawner gave it.\n\nA change of mode, or a user turning it off, also takes effect when each workspace next opens. Until\nthen, its open chats and scheduled gadgets can still search. Uninstalling the Worker stops every\nsearch at once.\n\n## Agent API\n\nThe binding exposes `WebSearchSession`. The contract lives in [`src/types.d.ts`](src/types.d.ts).\n\n```js\nexport default async function(self, env) {\n  const results = await env.WEB_SEARCH.search(\"durable objects alarms\");\n  // [{ title, url, description?, lastModifiedDate? }, ...]\n}\n```\n\n`query` is 1 to 1,024 characters, the API's limits. The caller picks no provider and no result\ncount, and gets up to 10 results.\n\n## How a search runs\n\n<p align=\"center\"><img src=\"../../docs/images/web-search-flow.svg\" alt=\"A search passes the gatekeeper's query check and is recorded in the Activity log before it goes through the Workers AI binding and the deployment's AI Gateway to Ceramic\" width=\"860\"></p>\n\n`src/search.ts` does the work:\n\n1. It refuses a query outside 1 to 1,024 characters. Nothing is sent or recorded.\n2. It records the query through the session's `ApprovalQueue`. The query goes in a literal field,\n   built with gatekeeper-kit's `buildDescription`, so the record shows exactly what was sent. If\n   the record is refused or fails, nothing is sent.\n3. It calls `env.AI.websearch({ gatewayId: env.CF_AI_GATEWAY ?? \"default\", query, provider: \"ceramic\", limit: 10 })`.\n4. It parses the response with zod, which drops the image and favicon fields. A failed search\n   throws `Web search failed with HTTP <status>.` without the provider's body, which can quote the\n   query back. The record stays.\n\n## Security\n\nA query goes only to the provider that `search.ts` pins. The agent cannot choose where it goes, as\nit can with a `webFetch` URL, so a workspace that has observed restricted data can still search,\nas it can search GitHub or Slack through their gatekeepers.\n\n- **Observers.** `addObserver` admits every collaborator, because search results are public.\n- **Ambience.** The gatekeeper declares only that it can mint an account and that the account has\n  a singleton. The admin's mode and the user's opt-in decide who gets it.\n\n## Why Cloudflare's Web Search API\n\n- It runs on a Workers AI binding and the deployment's AI Gateway, so the gatekeeper needs no\n  secrets or inputs.\n- Logs, billing and provider keys stay in AI Gateway.\n- The standalone `web_search` Worker binding (`env.WEBSEARCH.search`) is not an option. Cloudflare\n  removed it before launch ([workers-sdk#15453](https://github.com/cloudflare/workers-sdk/pull/15453)).\n  Its types are still in the generated `worker-configuration.d.ts` files.\n\n## Why Ceramic\n\n<p align=\"center\"><img src=\"../../docs/images/web-search-providers.svg\" alt=\"Price per 1,000 searches and data retention: Ceramic $0.25 with ZDR, Linkup $5 with ZDR, Exa $7 without ZDR, model-native search $10 to $35\" width=\"860\"></p>\n\n- **Zero data retention.** A query can carry workspace text, so the provider must not keep it.\n  Exa offers no zero data retention, so we exclude it. Ceramic's zero data retention applies to\n  searches billed to AI Gateway credits. With an operator's own Ceramic key, their Ceramic terms\n  apply instead.\n- **Cost.** $0.25 per 1,000 searches: a twentieth of Linkup and a twenty-eighth of Exa.\n- **Content.** Ceramic runs its own index of 40B+ pages and returns descriptions of up to 8,000\n  characters, so a result often answers the question without a fetch.\n- **Pinned in code.** `search.ts` sets `provider: \"ceramic\"` instead of trusting the API's default,\n  which Cloudflare could change to a provider that keeps data. There is no fallback chain: each\n  fallback would be one more way out of the platform.\n- **Linkup** is the other provider with zero data retention. Switching to it changes one constant.\n\nWe rejected model-native search (Anthropic, OpenAI, Gemini). It runs at the model provider, where\nthe Workshop cannot record it, and costs $10 to $35 per 1,000. We rejected direct\nvendor APIs (Brave, Tavily, Parallel and others) because each needs a new secret and sends traffic\noutside AI Gateway.\n\n## Deployment\n\n- The release manifest gives the Worker an `AI` binding and asks for no inputs. The deploy service\n  installs it on every new deploy, like Scheduled Tasks and the Context Library.\n- **Gateway.** `CF_AI_GATEWAY` names the AI Gateway searches go through. The deploy service sets\n  it on every gatekeeper it uploads to the instance's gateway, the one the backend's\n  `CF_AI_GATEWAY` names. Everywhere else (the dev server, previews, a deploy you run yourself) it\n  stays `default`, which every account has, unless you set it on this Worker. The gateway must be\n  in the Worker's own account: the `AI` binding can't reach another.\n- **Billing.** Each search spends the deployment account's AI Gateway credits, unless the gateway\n  holds a Ceramic key (see Known limits). The free daily allowance and Cloudflare-credits top-up\n  for models do not cover searches, and there is no per-user limit. On a public server, set the\n  mode to Disabled unless you accept that: under the default, Optional, every new user who leaves\n  it on in onboarding gets it.\n- **Local dev.** Start with `pnpm dev-server -- --use-workers-ai-binding`, which needs a Cloudflare\n  login. Without the flag the Worker has no `AI` binding and every search fails.\n\n## Known limits\n\n- **No approval.** A search is a read: recorded, not queued. Code that runs on a schedule can send\n  workspace data in a query with no one present.\n- **Untrusted results.** Results are page text. An agent that reads them can meet a prompt\n  injection, as with `webFetch`.\n- **Gateway logs.** The gateway that `CF_AI_GATEWAY` names logs each query (log collection is on\n  by default). When that is `default` and the backend uses a gateway with another name, search\n  logs sit apart from model logs. Ceramic's zero data retention covers Ceramic, not those logs.\n  That gateway's own settings, such as rate limits, apply to searches.\n- **Your own Ceramic key.** If the gateway that `CF_AI_GATEWAY` names holds a Ceramic key under\n  the alias `default`, AI Gateway uses it. Ceramic bills the operator, their Ceramic terms apply,\n  including data retention, and a search no longer falls back to credits: a revoked or empty key\n  fails every search. The deploy wizard can store the key; a key on any other gateway is not used.\n- **Blueprints.** A blueprint export drops ambient bindings, so a gadget installed from a blueprint\n  loses `WEB_SEARCH` until the agent binds it again with `setGadgetBinding`.\n","packages/gatekeeper-x/README.md":"# Gatekeeper X\n\nConnects X (formerly Twitter) accounts to Gadgets. A gadget can read an account's timelines,\nmentions, search, bookmarks and likes, and draft posts, threads, replies, likes, reposts, follows,\nmutes and List changes. Nothing reaches X until a user approves it; every draft is shown to the\ngadget as if it had already happened, so a gadget keeps working while it waits. See\n`plans/x-gatekeeper.md` for the design record.\n\nFour resource types, each its own grant:\n\n| Resource | Link | What it reaches |\n|---|---|---|\n| X Account | `https://x.com/settings/account` | The whole account. |\n| X Post | `https://x.com/<user>/status/<id>` | One post and its conversation: read, like, repost, bookmark; reply where X allows it (to the account's own posts, or posts that mention or quote it); hide replies in conversations the account started. |\n| X List | `https://x.com/i/lists/<id>` | One List's posts and members; its details and members, if the account owns it. |\n| X Profile | `https://x.com/<user>` | One user's public profile and posts. Read-only. |\n\nThe account resource's link names no user, so a blueprint that binds it binds whichever account\nre-resolves it.\n\n## What it costs\n\nX's API is pay-per-use, billed to the X app's prepaid credits: the deployment pays for every user's\nreads and posts. As of October 2026:\n\n| Request | Price |\n|---|---|\n| A post read | $0.005 |\n| A user read (a profile, or a post's author) | $0.010 |\n| A post created | $0.015 |\n| A post containing a link | $0.20 |\n\nX bills each resource once per UTC day, however often it is read. For example, a daily digest of\n200 posts costs $1-3 a day depending on how many distinct authors it shows, and a single post with a\nlink costs $0.20, which approval descriptions point out.\n\nTwo limits keep a gadget from running up the bill:\n\n- **A daily read limit per connection**, set by the deployment's `X_DAILY_READ_LIMIT` var: the\n  billable resources one connection may read per UTC day, across every binding and gadget using it.\n  The default is 2,000 (about $10 a day at the post-read price); `0` removes the limit. Reads past\n  it fail with a message naming when it resets.\n- **Approvals** bound writes: nothing is posted, liked or followed without a user approving it.\n\nReads are cached briefly (a post for 5 minutes, a profile for an hour, a listing's first page for a\nminute), so a gadget polling one page pays at most once a minute.\n\n## Setting up an X app\n\n1. In the [X Developer Console](https://console.x.com), create a Project and an App.\n2. Under **User authentication settings**, enable OAuth 2.0 with the type **Web App, Automated App\n   or Bot** and the permission **Read and write**.\n3. Set the **Callback URI** to `{PUBLIC_BASE_URL}/gatekeeper/x/oauth`, and give the app a website\n   URL.\n4. Buy credits, and set a spending limit.\n5. Copy the **OAuth 2.0 Client ID** and **Client Secret** (not the API key and secret, which are for\n   OAuth 1.0a) into the deployment's `CLIENT_ID` and `CLIENT_SECRET`.\n\n### Local development\n\nExport `X_CLIENT_ID` and `X_CLIENT_SECRET` in your shell or the root `.dev.vars`; `pnpm dev-server`\nmaps them into the worker's `CLIENT_ID` and `CLIENT_SECRET`, and passes `X_DAILY_READ_LIMIT`\nthrough when it is set.\n\nRegister `http://localhost:8787/gatekeeper/x/oauth` as a callback. If the console refuses\n`localhost`, register `http://127.0.0.1:8787/gatekeeper/x/oauth` instead and start the dev server\nwith `VITE_BACKEND_HOST=127.0.0.1:8787`, so the callback the gatekeeper sends matches.\n\n## Obligations that come with an X app\n\nX's developer agreement puts these on the app's owner, not on Gadgets:\n\n- **AI-generated replies need X's prior approval.** An app that replies to posts with AI-generated\n  content must be approved by X before it does. Replies drafted by agents are sent only after a user\n  approves each one, but X still counts them as the app's.\n- **Automated accounts must say so.** An account run by an agent must carry X's \"Automated\" label,\n  set in the account's settings.\n- **Likes, follows, reposts and posts must be the user's own.** X forbids automating them. That is\n  why only bookmarks, mutes and hiding replies can be auto-approved: they are private to the account\n  or reversible moderation. Everything else waits for a user to approve it.\n\n## Sharing a workspace\n\nA collaborator can observe a workspace that reads X only with an X connection of their own (and,\nfor a Post or List binding, one that can see the bound post or List). What the account reads that\nis private to it -- bookmarks, likes, mutes, private Lists, and posts by protected accounts -- is\nshown only to collaborators connected as the same X user. See `docs/observers.md`.\n\n## Troubleshooting\n\n### \"The X connection has expired or was revoked. Reconnect the X account.\"\n\nX refused the connection's refresh token: the user revoked the app in X's settings, the app's\ncredentials changed, or the refresh token was used twice. Reconnect the account from the Workshop's\nConnections page.\n\n### \"That's a different X account\"\n\nThe reconnect was authorized by another X user than the one the connection was made for. A\nconnection stays pinned to its X user; sign in to X as that user and reconnect, or connect the other\naccount separately.\n\n### \"This X connection has used today's 2,000 reads; the limit resets at 00:00 UTC.\"\n\nThe connection reached the deployment's daily read limit. Wait for the reset, or raise\n`X_DAILY_READ_LIMIT`.\n\n### \"The X API credits for this deployment are used up, or its spending limit was reached.\"\n\nThe X app's prepaid credits ran out, or its spending limit was hit. Add credits or raise the limit in\nthe X Developer Console.\n\n### \"X's rate limit for this request is used up for this account.\"\n\nX limits each endpoint per account per 15 minutes; the message names when the window resets. The\ngatekeeper never retries on its own.\n\n### \"X never confirmed whether this post was published, so it may already be on X and can't be rejected.\"\n\nAn approval sent the post, and X's answer was lost. Approve it again: the gatekeeper first checks the\naccount's recent posts and records the post if it landed, so it is never posted twice. Revert it\nafterwards if it shouldn't stay.\n\n### The callback URI does not match\n\nX compares the callback exactly. It must be the deployment's `{PUBLIC_BASE_URL}/gatekeeper/x/oauth`,\nwith the same scheme, host and port the gatekeeper's `BASE_URL` uses.\n","packages/gatekeeper-zoominfo/README.md":"# Gatekeeper ZoomInfo\n\nThis package provides [ZoomInfo](https://www.zoominfo.com) integration for Gadgets via ZoomInfo's\nGTM (go-to-market) API, using OAuth2 (Authorization Code + PKCE).\n\nIt exposes a single **whole-account** resource (`https://app.zoominfo.com/`): a session scoped to one\nconnected ZoomInfo account's OAuth grant and package entitlements. Through it a gadget can:\n\n- **Lookup** — resolve controlled filter values (industries, intent topics, scoop types, …) and\n  discover the enrich fields the account is entitled to.\n- **Search** (free) — companies, contacts, intent signals, scoops, and news, with full firmographic\n  and technographic filter fidelity.\n- **Enrich** (consumes credits) — turn matched records into full detail: companies, contacts,\n  corporate hierarchy, hashtags, intent, scoops, and news.\n- **Copilot** — company/contact lookalikes, contact recommendations, AI account summaries (+ ask),\n  and curated company insight signals.\n- **Usage** — read the account's credit/limit counters.\n\nA connected account corresponds to the ZoomInfo user who authorizes the OAuth consent flow. ZoomInfo\nis **not** offered as a \"Continue with…\" sign-in method (`getAuthenticatedEmail()` returns `null`).\n\n## Creating the OAuth app\n\n1. In the [ZoomInfo Developer Portal](https://api.zoominfo.com/), create an OAuth application.\n2. Set the **Redirect URI** to your deployment's callback (replace the host with your base URL):\n\n   ```\n   ${BASE_URL}/gatekeeper/zoominfo/oauth\n   ```\n\n   For local development that is `http://localhost:8787/gatekeeper/zoominfo/oauth`.\n3. Enable all API scopes the account is entitled to. The gatekeeper requests exactly the scopes\n   ZoomInfo's authorization server recognizes:\n\n   ```\n   api:data:company  api:data:contact  api:data:intent  api:data:news  api:data:scoops\n   api:recommendations:read  api:account-summary:read  api:insights:read\n   ```\n\n   There is no `lookup` scope — lookup/enrich-field reference data comes with the data scopes.\n4. Copy the **Client ID** and **Client Secret**.\n\n> Use the OAuth app's **Client ID + Client Secret**. The `authorization_code` exchange posts them to\n> the token endpoint as HTTP Basic auth (form-urlencoded), with PKCE (S256) and refresh-token\n> rotation.\n\n## Configuration\n\nThe gatekeeper Worker reads `CLIENT_ID` and `CLIENT_SECRET`. In local development these are seeded\nfrom the root `.dev.vars` by `run-dev-server.ts`:\n\n```\nZOOMINFO_CLIENT_ID=<oauth app client id>\nZOOMINFO_CLIENT_SECRET=<oauth app client secret>\n```\n\nOptional overrides read by the Worker:\n\n- `BASE_URL` — the gatekeeper's public base (default `http://localhost:8787/gatekeeper/zoominfo`);\n  the OAuth redirect is `${BASE_URL}/oauth`.\n- `ZOOMINFO_API_BASE_URL` — the ZoomInfo API root (default `https://api.zoominfo.com/gtm`).\n\n## Notes & limitations\n\n- **Credits.** Search, lookup, recommendations, insights, and account summaries are free. Enrichment\n  consumes ZoomInfo credits (roughly one per newly-enriched record; already-owned records and\n  no-match/error results are free). `getCreditUsage()` is the authoritative source for accounting —\n  the per-record `creditCharged` flag is a display-only upper bound, because ZoomInfo often omits\n  management status from enrich responses and so over-reports records served for free.\n- **Approvals.** Reads are logged as observations. Each `enrich*` call is submitted to the approval\n  queue and spends credits only once a human approves it; results are fetched afterward via\n  `getEnrichmentResult(ticket)`. Enrichments are **not** simulated — the action carries\n  `awaitDecision`, so the agent turn suspends until the decision rather than reading back\n  un-enriched state.\n- **Intent/scoop company targeting.** Intent and scoop *search* filter by firmographics only —\n  ZoomInfo has no company-identity inputs there. To pull intent or scoops for a specific known\n  company, use `enrichIntent` / `enrichScoops` (which take a company identifier). The API surface and\n  a runtime guard both enforce this with a clear message instead of ZoomInfo's opaque 400.\n- **`state` vs `country`.** These are mutually exclusive: ZoomInfo silently ignores `state` when\n  `country` is also set and returns the whole country. The gatekeeper rejects the combination up\n  front rather than returning broadened results with no signal. `state` already scopes to the\n  US/Canada, so pass just one.\n- **Entitlements.** Every call is scoped to the account's package: ZoomInfo returns only the fields\n  and datasets the account is entitled to; unentitled fields come back empty.\n\n## Troubleshooting\n\n### \"ZoomInfo Gatekeeper Not Configured\" during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Ensure `ZOOMINFO_CLIENT_ID` / `ZOOMINFO_CLIENT_SECRET`\nare set in the root `.dev.vars`, then restart the dev server.\n\n### `invalid_scope` on the authorization page\n\nA requested scope isn't recognized by ZoomInfo's authorization server. Confirm the app has the eight\nscopes listed above enabled and that no others are being requested.\n\n### Redirect URI mismatch\n\nThe redirect URI sent must exactly match one registered on the app. Confirm the portal has\n`${BASE_URL}/gatekeeper/zoominfo/oauth` (for local dev,\n`http://localhost:8787/gatekeeper/zoominfo/oauth`, no trailing slash).\n\n### \"ZoomInfo did not return a refresh token\"\n\nThe OAuth app isn't issuing refresh tokens. Enable the refresh-token grant for the app in the\nDeveloper Portal and reconnect — the gatekeeper needs a refresh token to keep the long-lived session\nalive after the access token expires.\n","packages/integration-tests/README.md":"# integration-tests\n\nEnd-to-end tests that drive the real Workshop and a real gatekeeper over the actual RPC API, plus the\ntoolkit those tests are built from. Part of `pnpm test`, so CI runs it like any other package.\n\n```bash\npnpm --filter @gadgets/integration-tests test:run\n```\n\n## The toolkit\n\nThree source-only modules, consumed both by the tests here and by per-vendor suites in repos that\nvendor this one as a submodule:\n\n- **`src/harness.ts`** — boots `workshop-backend` and any set of gatekeepers as real Workers under\n  [`wrangler`'s `createTestHarness()`](https://developers.cloudflare.com/changelog/post/2026-07-21-integration-test-harness/),\n  patching their checked-in `wrangler.jsonc` in memory. Parameterised over gatekeepers on purpose: a\n  suite for a new gatekeeper should be \"point the harness at the package\", not a forked copy.\n  `Harness.redeployWorkshop()` deploys another build of the Workshop over the running one, keeping\n  its storage, and `bundleBlueprints()` is a patch that changes which blueprints a build ships with.\n- **`src/network-interceptor.ts`** — `NetworkInterceptor`, mechanism only. It patches\n  `globalThis.fetch` (the harness routes Worker subrequests back through the Node process, so that is\n  enough), passes loopback through, and **throws on anything a handler didn't match** — a test cannot\n  reach the real internet. What a given vendor's endpoints answer lives in a handler module you pass\n  in, which is what makes it reusable across gatekeepers.\n- **`src/rpc-client.ts`** — speaks Cap'n Web over a WebSocket to `/api`, the same transport the\n  browser uses: sign-up, reading connected accounts, and `ObserverConfigRecorder`, which records the\n  overseer's `configure()` calls and answers from a scripted queue.\n\n## Writing a test here\n- **No test may assume a clean slate.** Everything in a file shares one harness, `it.concurrent` runs\n  the cases together, and storage is never reset. Take fresh identities from `nextUsernames()` and use\n  per-test resource URLs; account labels are allocated for you, so two tests can't pick the same one.\n- **The escape assertion lives in `afterAll`, not `afterEach`** — an `afterEach` fires while sibling\n  tests are still running, so it would inspect and clear state they are still using.\n- **A test that redeploys the Workshop starts a harness of its own.** A redeploy restarts the Workers\n  under every session they have, which would break the tests sharing the file's harness.\n\n## The fixture gatekeeper\n\n`fixtures/gatekeeper-test/` is a real Worker speaking the real gatekeeper protocol, whose verification\noutcome the tests set over an HTTP control route. It exists because the overseer cases need a\ngatekeeper that will refuse an observer *on command*, and every shipping one can do that only at a\ncost that would dominate the test:\n\n- The OAuth ones need a whole vendor auth surface mocked before an account exists at all.\n- The Context Library only refuses after an observation has been *recorded*, which takes a gadget read\n  session or a slash command — and it is a singleton, so it cannot produce two simultaneously failing\n  bindings.\n\nAdding a test hook to those workers was considered and rejected: a \"mark observed\" hook would stub the\nvery state the tracker maintains, and an injected dev credential for an OAuth gatekeeper would bypass\nexactly the flow that makes a real vendor worth testing.\n\nOne deliberate departure from a shipping gatekeeper, to keep the fixture cheap:\n\n- One control knob, `allow`. A settled denial and an expired credential reach the overseer identically\n  — both as a thrown error, which it deliberately cannot tell apart because it treats every failure as\n  repairable — so the reason string is what carries the difference. Tests cover both narratives by\n  choosing reason text.\n\n## A real gatekeeper: GitHub's hooks\n\n`__tests__/github-hooks.test.ts` runs a GitHub hook's whole life through the real Workshop and the\nreal `gatekeeper-github` — subscribe, enable, a signed delivery, the gadget's write approved, then\ndisable, connection removal and disconnect — and `github-hooks-off.test.ts` what a deployment\nwithout `WEBHOOK_ORIGIN` gets. GitHub is `__tests__/github-fake.ts`, a handler module for the\ninterceptor. The gatekeeper boots from the tree `vp run -F @gadgets/github-gatekeeper\nbuild:integration-worker` validates, which the `test` task and `test:prebuild` build first.\n\n## An upgrade: Google's hooks\n\n`__tests__/google-hooks-upgrade.test.ts` enables Gmail and Chat hooks under the release of\n`gatekeeper-google` that came before its hook delivery queue moved into gatekeeper-kit, then\nupgrades that Worker in place to this tree's build, keeping its storage. It checks that queued\ndeliveries, the hooks' capabilities and alarms, the Gmail watch and cursor and the Chat subscription\nall carry over, and that disconnecting reaches hooks the old release never told the account about.\nGoogle is `__tests__/google-fake.ts`. `vp run -F @gadgets/integration-tests build:upgrade-base`\n(`scripts/build-upgrade-base.ts`) bundles the old release from a temporary worktree of its revision.\nThe new one comes from `vp run -F @gadgets/google-gatekeeper build:integration-worker`. The `test`\ntask and `test:prebuild` build both first.\n\n## Further reading\n\n[`docs/integration-testing.md`](../../docs/integration-testing.md) covers the reasoning behind the\nshape of all this: why fake timers cannot work here, why a fixture gatekeeper rather than a real one,\nhow storage isolation works, and the capnweb, wrangler, and workerd traps to expect. Read it before\nchanging the toolkit or starting a suite of your own.\n","packages/mcp-shared/README.md":"# MCP shared\n\nThe protocol client, policy decisions, and stateful machinery common to the two MCP-speaking\ngatekeepers. Not a Worker: a library both of them import.\n\n| Package | Endpoint comes from | Grant is scoped to |\n| --- | --- | --- |\n| [`gatekeeper-mcp`](../gatekeeper-mcp/README.md) | a user pastes a URL | the whole server, or named tools |\n| [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) | a deployment var, `MCP_PORTAL_URL` | one upstream server behind the portal, or named tools |\n\nCode lives here when two copies of it would eventually disagree and the disagreement would be a\nsecurity bug: tool classification, the scope grammar, the OAuth lifecycle, the approval-queue\nwiring. A Worker's own vendor entrypoint, Durable Object classes and migrations, `Env`, connect\nform, and configurator UI stay in the connector. Where a connector must vary shared behaviour it\ndoes so through a named hook (`staticToken`, `mintAccount`), not a private copy.\n\n## Modules\n\n| Module | Purpose |\n| --- | --- |\n| `client` | Bounded Streamable HTTP transport (`initialize`, `tools/list`, `tools/call`) using official MCP wire types |\n| `oauth` | Small adapter around the official MCP client's OAuth errors and token revocation gap |\n| `tools` | The trust boundary: read/action classification, auto-approval eligibility, approval prompts, catalog fingerprinting |\n| `schema-to-ts` | JSON Schema to TypeScript, strict `callTool` overloads plus progressive discovery |\n| `session-methods` | Installs those methods at runtime, so the generated types are not a fiction |\n| `tool-search` | The one query matcher every catalog search uses, so a query cannot mean two things |\n| `portal` | Gateway detection, tool-name to upstream-server mapping, server listing |\n| `scope` | The resource-URL scope grammar, and the check every call passes through |\n| `endpoint` | Validation and host blocklist for a user-supplied endpoint |\n| `fetch` | Every outbound request; redirects are followed by hand and each hop re-checked, including SDK OAuth fetches |\n| `account` | Durable Object base and persisted SDK OAuth state: connect, refresh, revocation |\n| `facet` | Common session, catalog, action, and sharing behavior for connector-owned Durable Object facets |\n| `catalog` | One binding's tool list: fetched, cached, scoped to the grant, classified; plus the cache for tools hydrated past that list |\n| `connection` | `withClient` — transport sessions, retries, credential-expiry reporting |\n| `action-store` | Staged to applied/rejected/failed, with a bound on what is retained and a claim so one approval is never sent twice |\n| `session` | The Gadget-facing capability, and the one path every tool call takes |\n| `sharing-policy` | The owner-only sharing rule |\n| `html` | The connect-flow pages, so both connectors look like one product |\n| `http` | Base-path, OAuth callback, and connect-link routing shared by both Workers |\n| `log` | The field vocabulary both connectors log against |\n| `user` | The common account description, revocation, and reconnect lifecycle |\n| `util` | Hex encoding, host extraction, binding-name slugs; no policy |\n| `types.d.ts` | Base types prepended to every generated per-server `.d.ts` |\n\nNothing outside `tools.ts` reads a tool's `annotations`.\n\n## Trust tiers\n\nA tier governs how far a server's own claims about its tools are believed. MCP's own guidance is\nthat a client must treat tool annotations as untrusted unless they come from a trusted server, and\nthe tier is where this deployment records which servers those are. It is named by provenance, is\ndeployment configuration rather than account state, and is read at each point of use so withdrawing\nit takes effect at once.\n\n- **`byo`** — a user typed the URL in. `readOnlyHint` classifies reads; nothing the server says can\n  auto-apply a write.\n- **`vetted`** — a deployment has asserted this endpoint's annotations can be relied on, so\n  `destructiveHint: false` plus `idempotentHint: true` may drive auto-approval. Configuring an\n  endpoint is not by itself enough to earn this: a portal aggregates upstream servers whose\n  annotations the administrator never saw, which is why `gatekeeper-mcp-portal` defaults to `byo`\n  and requires `MCP_PORTAL_TRUST_ANNOTATIONS=true`.\n\nHonouring `readOnlyHint` on `byo` is a tradeoff, not a free win: a tool the server mislabels runs\nwith no approval, where an unlabelled one would have been queued. It is accepted because prompting\non every read makes the connector unusable for its main purpose, and because the owner chose to\nconnect the server. Auto-applying a write is not accepted on those terms.\n\nAnnotations are optional in MCP and most servers publish none. Every hint is compared with\n`=== true` or `=== false`, so an unannotated tool is an action, needs approval, and can never\nauto-apply, on either tier. That matches the spec's own defaults (`readOnlyHint: false`,\n`destructiveHint: true`, `idempotentHint: false`).\n\nNeither tier can be shared. A Gadget bound to any MCP endpoint is owner-only, for reasons unrelated\nto provenance — see [`sharing-policy.ts`](src/sharing-policy.ts).\n\nWhat an account records instead of a tier is `provenance`, `\"user\"` or `\"deployment\"`, settled when\nit connects. Provenance decides whether a server may rename itself over an administrator's chosen\nlabel in an approval prompt, which is a question that should not move when an annotation setting\ndoes.\n\n## Applying an approved call\n\nThe guarantee is *at most once*, not exactly once. MCP has no idempotency key that would make a\nrepeated call harmless and no inverse operation that would undo one, so where the two conflict the\nstore prefers losing a result over repeating a write.\n\nAn approval is claimed in storage before the call is sent, which is what stops two concurrent\n`applyAction` calls from both reaching the server. Once the call returns, the record is settled in\nits own small write *before* the result is attached, so nothing about handling a server-controlled\npayload — normalizing it, encoding it, or finding it too large for the Durable Object to store — can\nlose the fact that the write already happened.\n\nFailures are split by what the server is known to have done, because that is not something the\ncaller can work out afterwards. Only a `401` or `403` proves the tool was refused before dispatch.\nGeneric HTTP and JSON-RPC errors, dropped connections, malformed replies, and oversized bodies all\nleave the outcome unknown; those are closed as failed and **not** retryable, since the request may\nalready have been carried out. The classification (`callMayHaveTakenEffect`) fails safe: anything it\ncannot positively identify as declined counts as possibly performed.\n\nThe same rule covers an activation dying between sending the call and recording the reply. The claim\nis not released for another attempt: after `APPLY_CLAIM_TIMEOUT_MS` the action is closed the same\nway, saying it may or may not have taken effect.\n\nSo the cost of a network blip is a call someone has to stage again, deliberately. That is the trade\nthis module makes everywhere: an approval is never spent twice without a person saying so.\n\nA failed call can always be discarded. The Workshop keeps a failed approval pending until the user\nretries or discards it, and a call the agent awaits blocks its chat until then, so `reject` accepts a\n`failed` record instead of refusing it. Discarding keeps the failure on record for the Gadget to\ncollect, so an unknown outcome is still reported as unknown, and rules out any further attempt.\n\n## Limits\n\nFixed rather than configurable.\n\n| Limit | Value | Where | Why |\n| --- | --- | --- | --- |\n| Described or individually granted tools per server | 200 | `tools.ts` | Bounds the picker and generated `.d.ts`; a server-wide grant can discover additional tools later |\n| Catalog size | 96 KiB UTF-8 | `client.ts` | Leaves room below Durable Object's 128 KiB per-value limit for the cache wrapper and serialization overhead |\n| Filtered discovery scan | 5,000 tools / 4 MiB | `client.ts` | Bounds work spent skipping unrelated tools while searching a large endpoint |\n| Search query / results | 200 chars / 20 tools | `tool-search.ts` | Bounds agent-supplied matching work and the summaries returned to it |\n| Hydrated definitions per facet | 200 tools / 1 MiB | `catalog.ts` | Bounds definitions fetched individually beyond the described catalog |\n| Tool description | 4 KB | `client.ts` | As above, per tool, before it reaches storage |\n| Tool input schema | 20 KB | `client.ts` | Dropped rather than clipped; half a schema is not a schema |\n| `tools/list` pages | 50 | `client.ts` | Stops a cursor that never ends; exhaustion truncates catalogs and fails exact/search discovery as a scan limit |\n| Response body | 1 MiB | `fetch.ts` | Every response is buffered whole before it can be parsed, and a `tools/call` result is otherwise unbounded |\n| Bounded outbound operation | 30 seconds | `fetch.ts` | OAuth and discovery callers opt into one deadline covering redirects, pagination, body streaming, and session retry |\n| Retained result | 128 KB | `action-store.ts` | Held until the Gadget collects it; oversized ones are replaced by a note |\n| Retained actions | 100 | `action-store.ts` | Records are for collecting a result, not an audit log |\n| Actions awaiting a decision | 50 | `action-store.ts` | These cannot be pruned, so uncapped they are an unbounded write |\n| Tool description in a prompt | 600 chars | `tools.ts` | Server-controlled text in a security decision |\n| Tool arguments in a prompt | 4000 chars | `tools.ts` | Agent-controlled text in the same decision |\n| Server name | 60 chars | `account.ts` | As above; also stripped of Markdown |\n| Redirect hops | 3 | `fetch.ts` | Each one re-checked; more is a loop, not a deployment |\n| Connect link | 10 min | `connect-nonce.ts` | Single-use, and consumed on success |\n| Unfinished connect | 1 hour | `connect-nonce.ts` | After which a half-built account deletes itself |\n\n## Build & test\n\n```\npnpm --filter @gadgets/mcp-shared build   # tsc\npnpm --filter @gadgets/mcp-shared test:run    # vitest\n```\n","packages/observability/README.md":"# Observability\n\n`@gadgets/observability` is a shared library for code that runs in Cloudflare Workers. It is not a\nstandalone Worker and has no deployable entrypoint or Wrangler project configuration.\n\nThe package's Vitest configuration uses the Workers test pool to exercise runtime-specific APIs.\nConsumers that import `@gadgets/observability/observability-context` must enable `nodejs_als` (or\n`nodejs_compat`); the default `@gadgets/observability/logger` entry point has no such requirement.\n\nAn observability context exposes typed `.with()` and `.get()` methods, and creates loggers that\ninherit its ambient fields.\n\nThe optional `@gadgets/observability/error-reporting` entry point dispatches bounded error events to\na private Reporter bound as `ERROR_REPORTER`; `reportIssue(failureSite, caught, options?)` accepts\nambient fields under `options.attributes`, so callers can spread the context's `.get()` result and\naugment it inline. Reporting is a no-op when the binding is absent.\n\nThe optional `@gadgets/observability/metrics` entry point writes usage metrics to a Workers\nAnalytics Engine dataset bound as `METRICS`, and is likewise a no-op without the binding. Each event\nis written once to its actor's index, the canonical copy to count, and once more per owner or\nworkspace it names, so per-entity queries read their own index. The column layout lives in the import-free `metrics-schema.ts` (also\nexported as `@gadgets/observability/metrics-schema`), so dashboards can name columns rather than\n`blobN`.\n","packages/ui/AGENTS.md":"# Shared UI\n\n`@gadgets/ui` is the shared runtime React UI layer for the Workshop and gatekeeper management apps.\nKumo owns low-level controls and semantic tokens; this package owns reusable Gadgets interaction\npatterns and composed components.\n\n## Ownership\n\n- Add a component here when at least two independent frontends need the same feature-independent\n  behavior or when consistency across those frontends is an explicit product requirement.\n- Keep product data fetching, routing, RPC, permissions, and domain workflows in the consuming app.\n- Check Kumo before adding a primitive. Do not wrap Kumo solely to restyle or rename it.\n- Export source directly. This package has no publish or emitted-build step and is marked private.\n- Keep React and Kumo as peer dependencies so consumers use one runtime and design-system version.\n- A Tailwind consumer must include `packages/ui/src` as an `@source`, because the shared components'\n  utility classes are compiled by the consuming app.\n\n## APIs And Tests\n\n- Prefer a headless behavior primitive plus a Kumo adapter when both are real consumers' needs.\n- Keep the headless model free of presentation policy and styled-only fields where practical.\n- Preserve accessibility and input parity across mouse, keyboard, touch, and hybrid devices.\n- Colocate tests and test observable contracts rather than package boundaries or framework behavior.\n","packages/workshop-frontend/AGENTS.md":"# Workshop Frontend\n\nThis package is the Workshop single-page application. It uses React, TanStack Router, Tailwind,\nand Kumo. Follow the repository-wide guidance in `../../AGENTS.md` in addition to this file.\n\n## Architecture\n\nOrganize new code by product ownership first and implementation type second.\n\n```text\nsrc/\n  routes/       TanStack route declarations and route-level wiring\n  pages/        Substantial route-level screens that compose one or more features\n  features/     Product features and their owned UI, hooks, and logic\n  components/   UI and application primitives shared across unrelated features\n  hooks/        Hooks shared across unrelated features\n  utils/        Feature-independent utilities\n```\n\nThe existing tree predates this convention. Apply it to new code and to code being substantially\nreworked; do not migrate unrelated files opportunistically.\n\n### Features\n\nFeature directories own product behavior. A feature may contain components, hooks, tests, and\nutilities that belong to that behavior.\n\nStart a feature directory flat:\n\n```text\nfeatures/chat/composer/\n  ChatComposer.tsx\n  ComposerAddMenu.tsx\n  ComposerAddMenu.test.tsx\n  useComposerDraft.ts\n  composerTokens.ts\n```\n\nDo not create `components/`, `hooks/`, `helpers/`, or `tests/` subdirectories merely to classify\nfiles by implementation type. File names already communicate those types, and tests should usually\nremain beside their subject.\n\nIntroduce a subdirectory when a coherent internal subsystem has several files or the flat directory\nbecomes difficult to scan. Name it after its responsibility, such as `draft/`, `tokens/`,\n`attachments/`, or `slash-commands/`; avoid generic `helpers/` buckets.\n\nOrganize by the reason files change together. Feature-based organization does not replace the\none-component-per-file model.\n\n### Components\n\nCreate a separate component file when the component has at least one of these properties:\n\n- It owns meaningful state, effects, or interaction behavior.\n- It is independently testable or reusable.\n- It represents a distinct UI responsibility.\n- Keeping it inline makes the parent difficult to understand.\n- It has grown supporting types or logic that obscure the parent's main flow.\n\nKeep small stateless rendering helpers private in their parent's file when they are only used there\nand remain easy to understand. Extract them beside the parent when they develop their own behavior\nor tests.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Prefer direct imports; do not add a barrel file solely to shorten paths.\n\n### Pages And Routes\n\nFiles under `src/routes/` define routes. Keep them focused on route concerns: parameters, search\nvalidation, loaders, navigation, and composing the route's page.\n\nA page is a composition boundary, not necessarily a feature. A page may combine several features,\nand a feature may appear on several pages.\n\nA small page may remain in its route file. Move a substantial page implementation to\n`src/pages/<page>/`, where it can compose feature components. Components and hooks meaningful only\nto that page stay in the page directory rather than global `components/` or `hooks/`. Apply the same\nflat-first and responsibility-based subdivision rules used for features. Keeping page support code\noutside `src/routes/` also keeps it out of TanStack Router's file scanning.\n\nName every route-level screen component under `src/pages/` with a `Page.tsx` suffix, such as\n`HomePage.tsx` or `WorkspacePage.tsx`. Supporting components keep names that describe their own\nresponsibility, such as `HomeTaskSuggestions.tsx`.\n\nDo not move product behavior into a page merely because the page currently consumes it. Behavior\nwith independent product meaning or use across pages belongs to `src/features/<feature>/`.\n\nDefault uncertain code to the page and promote it when ownership broadens. The current Home page is\nan example of code that predates this rule: `components/AppShell/HomeTaskSuggestions.tsx` and\n`components/MeshBackground.tsx` have `routes/index.tsx` as their only production consumer. They are\nHome-specific composition and presentation rather than shared AppShell primitives, so their target\norganization would be:\n\n```text\npages/home/\n  HomePage.tsx\n  HomeTaskSuggestions.tsx\n  MeshBackground.tsx\n```\n\nIf task suggestions later become an independently meaningful workflow used by other pages, promote\nthat code to a feature such as `features/task-suggestions/`. Do not begin there based only on\nspeculative reuse.\n\nDo not edit `src/routeTree.gen.ts`; it is generated.\n\n### Shared Code\n\n`src/components/` and `src/hooks/` are shared application infrastructure, not default destinations.\nCode should begin under the feature that owns its behavior.\n\nReuse alone does not make code generic:\n\n- If multiple parts of one feature use an item, move it to their nearest common feature directory.\n- If another feature consumes a concept still owned by the original feature, keep it exported from\n  that feature.\n- Promote an item to global `components/` or `hooks/` only when unrelated features use the same\n  feature-independent abstraction and its API no longer depends on its original feature.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase important domain behavior.\n\nMove code only as high in the hierarchy as its ownership requires. For example, a skill pill shared\nby the composer and chat history belongs to `features/chat/`, while a generic application icon\nbutton belongs to `components/`.\n\n### Component APIs And Composition\n\nRepresent props that are valid only together as one object or a discriminated union. Do not permit\npartial configurations:\n\n```tsx\n// Avoid: callers can provide a count with no way to dismiss it.\ncount?: number;\nonDismiss?: () => void;\n\n// Prefer: the capability is either complete or absent.\nnotice?: { count: number; onDismiss: () => void };\n```\n\nA controlled value requires a change callback. Otherwise, the component owns the value. Do not copy\na controlled prop into local state with an Effect:\n\n```tsx\n// Controlled\nvalue: string;\nonValueChange: (value: string) => void;\n\n// Uncontrolled\ninitialValue?: string;\n```\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events. For\nexample, expose `onModelChange(modelId)` rather than `setSelectedModel` or `onChange(event)`.\n\nAdd `children`, named slots, variants, `className`, DOM prop passthrough, or imperative refs only\nwhen a current caller needs that control. Do not add them in anticipation of reuse.\n\nUse context for application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props; do not introduce context merely to avoid\npassing them through one or two component levels.\n\nExtract a component or hook when the extracted unit owns a complete concern, such as state and its\ntransitions, an Effect lifecycle and cleanup, an accessible interaction, or a pure transformation\nthat can be tested independently. Keep code together when the child would mostly forward markup or\nwould need the parent's refs, setters, and synchronization callbacks to function.\n\n## Kumo And Styling\n\nUse Kumo components and Kumo semantic design tokens by default. Check Kumo before creating a custom\ncontrol, interaction pattern, or visual primitive. Existing Workshop wrappers may be used when they\nprovide established application behavior that Kumo does not provide directly.\n\nCustom colors and design tokens outside Kumo are prohibited unless the user explicitly requests\nthem. In particular, do not add:\n\n- Hex, RGB, HSL, or OKLCH color literals in component styles.\n- Arbitrary Tailwind color values.\n- New application-specific color variables or token families.\n- Feature-local replacements for Kumo surfaces, borders, text, status, focus, or interaction tokens.\n\nUse Kumo semantic classes such as `bg-kumo-base`, `text-kumo-subtle`, and `border-kumo-line` rather\nthan palette colors. Global Kumo token theming is a deliberate application-level decision and must\nnot be introduced or changed as part of ordinary feature work.\n\nLegacy custom tokens and color declarations in the current codebase are not precedent for new code.\nDo not expand their use. Migrate them only as part of explicitly scoped cleanup.\n\nTailwind remains appropriate for structure: layout, spacing, sizing, positioning, responsive\nbehavior, and typography. Use custom CSS only for technical behavior that Kumo and utilities cannot\nexpress, such as editor integration or measured overlays; custom CSS does not relax the color and\ntoken rules.\n\nWhen a Kumo component is unsuitable, record the concrete behavioral or accessibility gap before\nadding a shared Workshop abstraction. Do not wrap Kumo solely to restyle it.\n\n## React\n\n- Define components and hooks as named `const` arrow functions by default. This keeps declarations\n  before use and avoids relying on function-declaration hoisting. Type props directly rather than\n  using `React.FC`. Ensure components wrapped in `memo`, `forwardRef`, or similar APIs retain an\n  explicit name for React DevTools and stack traces, using `displayName` when inference is unclear.\n- Treat Effects as an escape hatch for synchronizing React with an external system, not as a\n  general state-management tool. Follow React's\n  [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) guidance.\n- Do not use an Effect to derive render data from props or state. Calculate it during render instead.\n- Do not use an Effect for logic caused by a user interaction. Run it in the event handler that\n  knows what happened.\n- Do not use an Effect to keep two pieces of React state synchronized. Prefer a single source of\n  truth, derived values, controlled components, or lifting state.\n- Prefer a component `key` when an identity change should reset the component's state.\n- Prefer `useSyncExternalStore` for external store subscriptions when it fits.\n- Effects that fetch or subscribe must clean up stale work and subscriptions. Components must remain\n  correct when Effects are restarted or mounted again in development.\n- Avoid chains of Effects that update state only to trigger another Effect.\n- Keep state as close as possible to the behavior that owns it.\n- Extract a hook when it represents a coherent behavior or is reused, not simply to shorten a file.\n- Do not add `useMemo` or `useCallback` by default; use them when identity or expensive computation\n  has a concrete effect, and follow existing compiler guidance.\n- Preserve keyboard behavior, focus management, and accessible names when extracting interactive UI.\n- RPC stubs must follow the disposal and React state rules in the repository-wide `AGENTS.md`.\n\n## Comments\n\nPrefer code that communicates its intent through names, types, and structure. Do not add comments\nthat narrate what the next line or block already says.\n\nComments are appropriate for gotchas, non-obvious invariants, external constraints, security or\nperformance reasoning, and deliberate departures from a convention. Explain why the surprising\nchoice is necessary, not the mechanics visible in the code. Remove or update comments when the\nconstraint they describe no longer exists.\n\n## Tests\n\nColocate unit tests with their subject using `*.test.ts` or `*.test.tsx`. Use a feature-level\n`__tests__/` directory only for scenarios that span several modules and have no single owner.\n\nTests should either protect behavior whose regression would be important, or verify real logic\nintroduced by the change. Do not test React, JavaScript, TypeScript, Kumo, or another framework's\nown behavior merely because it appears in the implementation.\n\nTest observable contracts, product rules, state transitions, accessibility behavior, race handling,\nand failure paths. Avoid assertions coupled only to implementation details, trivial passthrough\nmarkup, or behavior already guaranteed by types and the underlying platform. A test should have a\nclear failure whose meaning matters to this application.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage when\nan extraction exposes important previously untested logic; do not add tests solely because a file\nor component boundary now exists.\n\nUseful commands from the public workspace root:\n\n```bash\npnpm --filter @gadgets/workshop-frontend test:run\npnpm exec tsc -p packages/workshop-frontend/tsconfig.json\npnpm exec tsc -p packages/workshop-frontend/tsconfig.vite.json\npnpm exec vp run -F @gadgets/workshop-frontend build\n```\n\nRun `pnpm lint` from the public workspace root before pushing when the broader workspace state\nallows it.\n","packages/workshop-frontend/README.md":"# Gadgets Workshop Frontend\n\nSingle-page app for the Gadgets Workshop UI. Built with React, Kumo, and Vite.\n\n## Development\n\n```sh\npnpm dev                # start dev server on http://localhost:3000\npnpm exec vp run build  # type-check and build for production\npnpm preview            # preview production build locally\n```\n\n`build` is a Vite+ task, not a package.json script, so that it can declare the `VITE_*` flags below\nas fingerprinted env: a cached `vp` run executes each task in a clean environment, which drops any\nambient value, and a flag the fingerprint ignores means a changed flag replays the old bundle.\n\nIt always produces a production bundle, whatever `NODE_ENV` the shell holds, and it deletes `dist/`\nbefore building — a cache hit restores archived files without deleting any, so the previous build's\nsourcemaps would otherwise linger. `vite.config.ts` documents both.\n\n## Gatekeeper user selection\n\nGatekeeper management apps can open the Workshop-owned **Select users** picker through\n`selectUsers(receiver)`. Selections are delivered through the receiver's `receive(selection)` method.\nWith directory search enabled, users select only directory results; empty or failed searches do not\noffer an exact-username/email fallback. With search disabled, users enter an exact identifier\ndirectly. Arrow keys navigate options while focus stays in the input; Tab moves to the next control\nwithout selecting a user. Picking someone does not re-run the current search: people already added\nor being added are excluded when each search is issued and hidden from results returned earlier.\nIf minting or delivery fails, the current query is refreshed so the person can be selected again,\nincluding when the server excluded them from a search made while their selection was pending.\nRefreshed results stay dismissed after blur or Escape until the user focuses, types, or uses an\narrow key to reopen them.\nEach person the app accepts is announced to assistive technology by their snapshotted name.\n\n**Done** and the close button both wait for ticket minting and selection deliveries before closing.\nIf a pending mint or delivery fails, closing is cancelled so the error remains visible and the user\ncan retry or choose **Done** again. Every picker session starts with fresh search and selection state,\nincluding when closing one and opening another are batched in the same render.\n**Stop waiting and close** cancels the picker and disposes its outstanding mint and delivery RPC\npromises as well as its receiver; frame teardown does the same. Closing and cancelling both resolve\n`selectUsers()`; after a cancellation, a delivery already sent to the app may still settle, and\ncancellation cannot undo a selection the app already accepted. `selectUsers()` rejects only when the\npicker cannot be opened or, on teardown, when the app's connection to the host closes. A superseded\nor abandoned directory search is disposed as soon as it is no longer needed.\n\n## Authentication modes\n\nThe frontend supports two authentication modes, selected at build time.\n\n### Password mode (default)\n\nUsers log in with a username and password. Account creation is available via `/signup`.\nThis is the default — no extra configuration needed.\n\n### Cloudflare Access mode\n\nWhen the backend is deployed behind [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/), Access handles identity before the user ever reaches the app. In this mode:\n\n- The password-based login page and signup page are disabled.\n- On load, the app authenticates automatically using the CF Access session that Access has already established (via `authenticateFromCfAccess()` on the server).\n\nTo build in CF Access mode, set `VITE_CF_ACCESS_MODE=true`:\n\n```sh\nVITE_CF_ACCESS_MODE=true pnpm exec vp run build\n```\n\nOr add it to a `.env` file for persistent local configuration:\n\n```sh\n# .env.local\nVITE_CF_ACCESS_MODE=true\n```\n\nThe backend also needs to be configured with the `CF_ACCESS_ISS` and `CF_ACCESS_AUD` environment variables (see the workshop-backend package) for the JWT verification to work.\n"},"files":{".agents/skills/frontend-conventions/SKILL.md":"---\nname: frontend-conventions\ndescription: Use for creating, modifying, moving, or reviewing React frontend code anywhere in packages/*, including Workshop pages, gatekeeper management apps, shared UI, components, hooks, forms, interactions, styling, accessibility, and frontend tests.\n---\n\n# Frontend Conventions\n\nApply these conventions to the Workshop SPA, gatekeeper management SPAs, and `@gadgets/ui`.\nPackage-level `AGENTS.md` files add product-specific rules but do not replace this guidance.\n\n## Ownership And Organization\n\nOrganize code by product ownership before implementation type. Feature directories own product\nbehavior and may contain components, hooks, tests, and utilities that change for the same reason.\n\nStart directories flat. Do not introduce `components/`, `hooks/`, `helpers/`, or `tests/`\nsubdirectories merely to classify files. Introduce a responsibility-named subsystem directory only\nwhen several files form a coherent unit or the flat directory becomes difficult to scan.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Colocate `*.test.ts(x)` files with their subject. Feature organization does not replace\nthe one-component-per-file model.\n\nKeep product behavior with its product even when another feature consumes it. Promote code only as\nhigh as its ownership requires:\n\n- Code shared within one feature belongs at the nearest common feature directory.\n- Feature-independent code shared across unrelated areas of one app may live in that app's\n  `components/` or `hooks/` directory.\n- Runtime UI shared by independent frontends belongs in `@gadgets/ui`.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase domain behavior.\n\n## Components And Hooks\n\nCreate a separate component when it owns meaningful state, effects, interactions, accessibility\nbehavior, or reusable responsibility; represents a distinct UI concern; or obscures its parent's\nmain flow. Keep small stateless render helpers private until they develop an independent concern.\n\nExtract a hook when it owns a coherent behavior or external synchronization lifecycle, not simply\nto shorten a file. Keep code together when an extracted child would mostly forward markup or depend\non the parent's refs, setters, and synchronization callbacks.\n\nPrefer named arrow-function components and hooks. Type props directly rather than using `React.FC`.\nGive wrappers such as `memo` and `forwardRef` stable DevTools names.\n\n## Component APIs\n\nRepresent props that are valid only together as an object or discriminated union. A controlled\nvalue requires a change callback; otherwise expose an uncontrolled initial value. Do not copy a\ncontrolled prop into local state with an Effect.\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events.\nAdd `children`, slots, variants, `className`, DOM passthrough, and imperative refs only for current\ncallers, not speculative reuse.\n\nUse context for genuinely application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props.\n\n## Kumo And Styling\n\nUse Kumo components and semantic tokens by default. Check Kumo and `@gadgets/ui` before creating a\ncontrol or interaction pattern. A shared Gadgets component should compose Kumo behavior, not merely\nrename or restyle a primitive.\n\nDo not add custom color literals, arbitrary Tailwind colors, feature-local token systems, or local\nreplacements for Kumo surfaces, borders, text, status, focus, and interaction tokens unless the user\nexplicitly requests them. Existing legacy colors are not precedent.\n\nTailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and\ntypography. Use custom CSS only for technical behavior Kumo and utilities cannot express. Global\nKumo token theming is an application-level decision and must not be changed during ordinary feature\nwork.\n\nWhen Kumo is unsuitable, identify the concrete behavioral or accessibility gap before introducing\na shared abstraction.\n\n## React\n\nTreat Effects as synchronization with external systems, not as derived-state machinery or a way to\nsequence user interactions. Calculate render data during render, keep state near its owner, prefer a\ncomponent `key` for identity resets, and use `useSyncExternalStore` for suitable external stores.\n\nEffects that fetch or subscribe must clean up stale work and remain correct when restarted. Avoid\nchains of Effects and do not synchronize two pieces of React state when one can be derived.\n\nDo not add `useMemo` or `useCallback` without a concrete identity or performance need. Preserve\nkeyboard behavior, focus management, accessible names, announcements, and mouse/touch/hybrid input\nparity. RPC stubs must follow the disposal and React state rules in the root `AGENTS.md`.\n\n## Comments\n\nPrefer names and types that communicate intent. Comments should explain non-obvious constraints,\nsecurity or performance reasons, and deliberate departures from conventions. Do not narrate the\nnext line. Remove or update comments when their constraint changes.\n\n## Tests\n\nTests should protect observable behavior, product rules, accessibility, state transitions, races,\nand failure paths. Do not test React, JavaScript, Kumo, or another framework's own behavior merely\nfor coverage. Avoid assertions coupled only to implementation details or trivial passthrough.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage only\nwhen an extraction exposes important previously untested logic.\n",".agents/skills/write-gatekeeper/SKELETON.md":"# Gatekeeper Implementation Skeleton\n\nReplace all `My`/`my`/`MY` prefixes with the service name.\n\n## Main implementation (`src/<name>.ts`)\n\n```typescript\nimport { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from \"cloudflare:workers\";\nimport {\n  GatekeeperUser,\n  GatekeeperUserVerifier,\n  GatekeeperVendor as GatekeeperVendorIface,\n  Gatekeeper,\n  HookController,        // Remove if no hooks\n  HookInitiator,         // Remove if no hooks\n  HookTargetMetadata,    // Remove if no hooks\n  ResourceDescription,\n  ApprovalQueue,\n  ObservationDescription,\n  VendorDescription,\n  GatekeeperConnectCallback,\n  AccountDescription,\n  SupportedResource,\n  ResourceConfiguratorFrame,\n} from '@gadgets/workshop-shared/gatekeeper';\nimport { MySession, MyHook as MyHookIface } from \"./types\";  // Remove MyHook if no hooks\nimport TYPES_CODE from \"./types.txt\";\nimport MY_CONFIGURATOR_HTML from \"./generated/my-configurator-ui.txt\";\n\nconst NONCE_BYTES = 32;\nconst NONCE_LIFETIME_MS = 10 * 60 * 1000;  // 10 minutes\n\nfunction hexEncode(bytes: Uint8Array): string {\n  return [...bytes].map(b => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction generateNonce(): string {\n  return hexEncode(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n  let encoder = new TextEncoder();\n  let bufA = encoder.encode(a);\n  let bufB = encoder.encode(b);\n  if (bufA.byteLength !== bufB.byteLength) return false;\n  return crypto.subtle.timingSafeEqual(bufA, bufB);\n}\n\ntype Env = Cloudflare.Env & {\n  BASE_URL?: string,\n};\n\nfunction getBaseUrl(env: Env) {\n  return (env.BASE_URL || \"http://localhost:8787/gatekeeper/<name>\").replace(/\\/+$/, \"\");\n}\n\nfunction getBasePath(env: Env) {\n  const path = new URL(getBaseUrl(env)).pathname;\n  return path === \"/\" ? \"\" : path;\n}\n\nconst SELF_CLOSING_HTML = `<!DOCTYPE html>\n<html lang=\"en\">\n  <body>\n    <script type=\"text/javascript\">window.close();</script>\n    <p>Authorization complete. You may close this tab and return to the Gadgets Workshop.\n  </body>\n</html>`;\n\nconst MY_RESOURCE: SupportedResource = {\n  urlPattern: \"https://example.com/*\",\n  title: \"My Resource\",\n  description: \"Access a specific resource.\",\n};\n\n// ---------------------------------------------------------------------------\n// HTTP handler — serves the browser-based auth flow.\n// For a complete OAuth example, see gatekeeper-google/src/google.ts.\n\nexport default {\n  async fetch(req: Request, env: Env, ctx: ExecutionContext) {\n    let url = new URL(req.url);\n    let basePath = getBasePath(env);\n    if (!url.pathname.startsWith(basePath + \"/\") && url.pathname !== basePath) {\n      throw new Error(`Request path ${url.pathname} does not match BASE_URL path ${basePath}`);\n    }\n    let relPath = url.pathname.slice(basePath.length);\n    let path = relPath.slice(1).split(\"/\");\n\n    if (path.length === 2 && path[0].length === 64 && path[1].length === NONCE_BYTES * 2) {\n      // Auth initiation: the user has visited the connect URL.\n      let doId = path[0];\n      let nonce = path[1];\n      let stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId));\n      if (!await stub.verifyNonce(nonce)) {\n        // Show a friendly error page for expired/replayed links.\n        return new Response(\"TODO: error HTML\", {\n          headers: { \"Content-Type\": \"text/html; charset=utf-8\" }\n        });\n      }\n\n      // TODO: Redirect to external auth provider, or present an auth form.\n      // For OAuth, generate a second nonce for the `state` parameter here —\n      // see gatekeeper-google for the full pattern.\n      throw new Error(\"TODO: implement auth initiation\");\n    } else {\n      return new Response(\"Not Found\", {status: 404});\n    }\n  }\n};\n\n// ---------------------------------------------------------------------------\n// Vendor — top-level API exposed to the Workshop\n\nexport class GatekeeperVendor extends WorkerEntrypoint<Env> implements GatekeeperVendorIface {\n  async describe(): Promise<VendorDescription> {\n    return {\n      displayName: \"My Service\",\n      url: \"https://example.com\",\n      logo: { url: \"TODO: data URL or hosted image URL\" },\n      color: \"#f0f4ff\",\n      tagline: \"Short summary of what this connector enables\",\n      description:\n          \"Explain what Gadgets can do with this connector in plain language.\",\n    };\n  }\n\n  // This skeleton has no independently grantable resources, so it ignores\n  // `options.resourceUrlPatterns` and stores an empty requested-resource list. Gatekeepers with\n  // grantable resources should follow gatekeeper-google.\n  async connectAccount(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      _options?: {resourceUrlPatterns?: string[]}): Promise<{url: string}> {\n    let userObjectId = this.ctx.exports.UserAccount.newUniqueId();\n    let nonce = generateNonce();\n    await this.ctx.exports.UserAccount.get(userObjectId).setCallback(callback, nonce, []);\n    return { url: `${getBaseUrl(this.env)}/${userObjectId.toString()}/${nonce}` };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserAccount DO — stores per-user credentials (tokens, API keys, etc.).\n// For a full OAuth implementation with two-phase nonces and reconnect support,\n// see gatekeeper-google.\nexport class UserAccount extends DurableObject<Env> {\n  async setCallback(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      nonce: string,\n      requestedResourceUrlPatterns: string[]) {\n    // Self-destruct if the connect flow is never completed.\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.setAlarm(Date.now() + 3600 * 1000);\n    }\n    this.ctx.storage.kv.put(\"callback\", callback);\n    this.ctx.storage.kv.put(\"nonce\", { value: nonce, expiresAt: Date.now() + NONCE_LIFETIME_MS });\n    this.ctx.storage.kv.put(\"requestedResourceUrlPatterns\", requestedResourceUrlPatterns);\n  }\n\n  // Verify and consume the nonce from the initiation URL. Prevents replay.\n  // Returns false if the nonce is invalid or expired.\n  async verifyNonce(nonce: string): Promise<boolean> {\n    let stored = this.ctx.storage.kv.get<{value: string, expiresAt: number}>(\"nonce\");\n    if (!stored || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, nonce)) {\n      return false;\n    }\n    this.ctx.storage.kv.delete(\"nonce\");\n    return true;\n  }\n\n  // Called when the user completes authorization. Store credentials and notify the workshop.\n  async completeConnection(/* auth result params */) {\n    let callback = this.ctx.storage.kv.get<Fetcher<GatekeeperConnectCallback>>(\"callback\");\n    if (!callback) {\n      throw new Error(\"Authorization timed out. Please try again.\");\n    }\n\n    // TODO: Store credentials obtained from the auth flow\n    this.ctx.storage.kv.put<string>(\"credentials\", \"TODO\");\n\n    let props: MyUserImplProps = { userObjectId: this.ctx.id.toString() };\n    try {\n      await callback.complete(this.ctx.exports.MyUserImpl({ props }));\n    } catch (err) {\n      this.ctx.storage.kv.delete(\"credentials\");\n      throw err;\n    }\n  }\n\n  async getAccessToken(): Promise<string> {\n    // TODO: Return a cached short-lived token, refreshing credentials if needed.\n    let token = this.ctx.storage.kv.get<string>(\"credentials\");\n    if (!token) throw new Error(\"No credentials set.\");\n    return token;\n  }\n\n  async alarm() {\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.deleteAll();\n    }\n  }\n\n  async revoke() {\n    // TODO: Revoke credentials with the external service\n    this.ctx.storage.deleteAlarm();\n    this.ctx.storage.deleteAll();\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserImpl — maps resource URLs to gatekeeper DO classes\n\ntype MyUserImplProps = {\n  userObjectId: string;\n};\n\nexport class MyUserImpl extends WorkerEntrypoint<Env, MyUserImplProps>\n                         implements GatekeeperUser {\n  async describe(): Promise<AccountDescription> {\n    // TODO: Fetch account info from external service using stored credentials\n    return {\n      displayName: \"TODO\",\n      avatar: { url: \"\" },\n    };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async startResourceConfigurator(resourceUrlPattern: string): Promise<ResourceConfiguratorFrame> {\n    if (resourceUrlPattern !== MY_RESOURCE.urlPattern) {\n      throw new Error(`Unsupported resource configurator type: ${resourceUrlPattern}`);\n    }\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    let userAccount = this.ctx.exports.UserAccount.get(id);\n    let ui = new MyConfiguratorUI(async () => {\n      // TODO: Return a short-lived token or construct a read-only API client.\n      // Keep this helper private; do not expose it as an RPC method.\n      return await userAccount.getAccessToken();\n    });\n    return {\n      iframeHtml: MY_CONFIGURATOR_HTML,\n      ui: new RpcStub(ui),\n    };\n  }\n\n  async getGatekeeperClassFor(url: string): Promise<{\n    class: DurableObjectClass<Gatekeeper<any>>;\n    resource: SupportedResource;\n  }> {\n    // Parse URL to determine resource type and extract identifiers.\n    // Return a DO class with props baked in via ctx.exports.<ClassName>({props}).\n    // The Overseer will instantiate this class as a facet.\n    let props: MyGatekeeperImplProps = {\n      userObjectId: this.ctx.props.userObjectId,\n      // TODO: resource-specific fields extracted from URL\n    };\n    return {\n      class: this.ctx.exports.MyGatekeeperImpl({ props }),\n      resource: MY_RESOURCE,\n    };\n  }\n\n  async revoke(): Promise<void> {\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    await this.ctx.exports.UserAccount.get(id).revoke();\n  }\n\n  async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> {\n    return {};\n  }\n\n  // Mint a verifier representing this account (see Observers in SKILL.md). The overseer only ever\n  // hands it back to a gatekeeper of THIS vendor, so MyGatekeeperImpl.addObserver may trust it.\n  // For a strategy-D (low-stakes) gatekeeper, MyVerifier has no methods and this still works.\n  async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n    let props: MyVerifierProps = { userObjectId: this.ctx.props.userObjectId };\n    return this.ctx.exports.MyVerifier({ props });\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Verifier — answers \"can this observer access X?\" against the OBSERVER's own credentials.\n// For strategy D, replace this with a no-op public method; an empty WorkerEntrypoint is not registered\n// in ctx.exports. Keep/extend it for strategy B (single-unit ACL) or C (data-set tracking). See SKILL.md.\n\ntype MyVerifierProps = {\n  userObjectId: string;\n};\n\n// A vendor-specific interface adding non-standard methods to the opaque GatekeeperUserVerifier.\n// addObserver casts the Fetcher back to this; the overseer's same-vendor guarantee makes that safe.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    let account = this.ctx.exports.UserAccount.get(\n        this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId));\n    void account; void resourceId;\n    // TODO: query the service with the observer's own token. Return true on success; return false\n    // for access errors (e.g. 401/403/404); rethrow anything else so the open fails loudly rather\n    // than silently denying.\n    return false;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Resource configurator — narrow iframe helper API\n\nconst configuratorTokenGetters = new WeakMap<object, () => Promise<string>>();\n\nclass MyConfiguratorUI extends RpcTarget {\n  constructor(getToken: () => Promise<string>) {\n    super();\n    configuratorTokenGetters.set(this, getToken);\n  }\n\n  async listResources(query: string): Promise<{ value: string; title: string }[]> {\n    let getToken = configuratorTokenGetters.get(this);\n    if (!getToken) throw new Error(\"Configurator is not initialized.\");\n    let token = await getToken();\n    void token;\n    // TODO: Call read-only external APIs and return bounded search results.\n    return [{ value: query || \"example\", title: query || \"Example\" }];\n  }\n\n}\n\n// ---------------------------------------------------------------------------\n// Hook type — remove this section (and the HookController/HookInitiator/HookTargetMetadata\n// imports, the `subscribe` method, the `hookTsType` field, and MyHookControllerImpl) if the\n// gatekeeper doesn't push events.\n//\n// The hook interface from types.d.ts is implemented by the Gadget as an RpcTarget. Intersect it\n// with RpcTarget so it satisfies the HookController/HookInitiator generic constraints (Hook\n// extends RpcTarget).\n//\n// Note that you can also use a plain function as a hook type, e.g. `RpcStub<() => Promise<void>>`.\n// In that case you would not need to merge the type with `RpcTarget`.\n\ntype MyHook = RpcTarget & MyHookIface;\n\n// ---------------------------------------------------------------------------\n// GatekeeperImpl DO — per-resource instance, runs as a facet of the Overseer\n\ntype MyGatekeeperImplProps = {\n  userObjectId: string;\n  // ... resource-specific fields (e.g., documentId, repoOwner)\n};\n\nexport class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>\n    implements Gatekeeper<MySession> {\n\n  async describe(): Promise<ResourceDescription> {\n    return {\n      url: \"TODO: canonical resource URL\",\n      title: \"TODO\",\n      snippet: \"TODO\",\n      suggestedBindingName: \"MY_RESOURCE\",  // Based on type, not instance\n      tsType: \"MySession\",\n      hookTsType: \"MyHook\",  // Remove if hooks are not supported\n    };\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n\n  async startSession(approvalQueue: RpcStub<ApprovalQueue>): Promise<MySession> {\n    return new MySessionImpl(\n      approvalQueue.dup(),  // Always dup() before storing\n      this.ctx,\n      // ... API client, props, etc.\n    );\n  }\n\n  async applyAction(actionId: number): Promise<void> {\n    // Look up the action by ID from this gatekeeper's own storage, then perform it\n    // against the external service.\n    // TODO: Implement action lookup and execution.\n    throw new Error(`Unknown action: ${actionId}`);\n  }\n\n  async rejectAction(actionId: number): Promise<void | { restart?: boolean }> {\n    // Clean up simulation state for this action.\n    // Return { restart: true } if the session can't recover from rejection.\n  }\n\n  revertAction(actionId: number):\n      Promise<void | { message?: string; canRetry?: boolean; restart?: boolean }> {\n    // TODO: Undo the action (look up what was done from own storage)\n    throw new Error(\"Revert not implemented\");\n  }\n\n  // Observers (see SKILL.md \"Observer verification\"). This skeleton shows strategy B — ACL check on\n  // a single atomic resource. The overseer calls addObserver on EVERY open by every observer, so it\n  // re-verifies live access; throw to deny.\n  //   - Strategy A (private-only): `async addObserver() { throw new Error(\"...not shareable...\"); }`\n  //   - Strategy D (low-stakes):   make both methods no-ops.\n  //   - Strategy C (data-set tracking): record observed sets + store verifiers, and route every read\n  //     through an authorizeSetObservation helper that sets `excludeObservers` (see SKILL.md).\n  async addObserver(_id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void> {\n    let verifier = user as unknown as Fetcher<MyVerifierApi>;\n    if (!(await verifier.hasResourceAccess(/* this.ctx.props.resourceId */ \"TODO\"))) {\n      throw new Error(\n        \"This collaborator does not have access to the bound resource, so they cannot observe \" +\n        \"data the Gadget read from it.\");\n    }\n  }\n\n  // Idempotent: ignore unknown ids. A no-op for strategy A/B (nothing is tracked).\n  async removeObserver(_id: string): Promise<void> {}\n}\n\n// ---------------------------------------------------------------------------\n// HookController — remove if the gatekeeper doesn't push events.\n//\n// A WorkerEntrypoint the overseer uses to enable/disable the hook after the user approves it.\n// It is constructed with `props` carrying the specifics of that particular registration, so it\n// needs no other state. If your gatekeeper offers several kinds of hooks, give each its own\n// controller class.\n\n// Bind-time details for a single hook registration, baked into the controller's props.\ntype MyHookProps = {\n  // TODO: e.g. an event kind, a filter, a sub-resource id, etc. — whatever the registration\n  // method received and the controller/event source will need later.\n  filter?: string;\n};\n\ntype MyHookControllerImplProps = MyGatekeeperImplProps & MyHookProps;\n\nexport class MyHookControllerImpl extends WorkerEntrypoint<Env, MyHookControllerImplProps>\n    implements HookController<MyHook> {\n  // Called when the user enables the hook. Store `initiator` somewhere it can be reached when an\n  // event arrives — typically an event-source DO. Don't store other state until now; everything\n  // else is already in `this.ctx.props`. If already enabled, replace the previous initiator.\n  //\n  // `target` identifies where the hook delivers (workspace, and gadget when pinned to one). Store\n  // it too if you display or link to the target; otherwise ignore it, but keep the parameter\n  // declared — RPC argument validation rejects arguments the receiver doesn't declare.\n  async enable(initiator: Fetcher<HookInitiator<MyHook>>, target: HookTargetMetadata): Promise<void> {\n    // TODO: persist `initiator` (e.g. forward it to an event-source DO keyed by props).\n  }\n\n  // Called when the hook is disabled or deleted. Forget the stored initiator and clean up all\n  // related state. May never be called again, though the overseer may later call enable() afresh.\n  async disable(): Promise<void> {\n    // TODO: forget the stored initiator.\n  }\n}\n\n// Event delivery (sketch). When the external event arrives — e.g. in the event-source DO that\n// holds the `initiator` — invoke the hook like so:\n//\n//   async onEvent(initiator: Fetcher<HookInitiator<MyHook>>, event: MyEvent) {\n//     // startHook() begins a fresh session and returns the callback (re-bound to it) plus an\n//     // ApprovalQueue. `using` disposes the result (and its stubs) at end of scope.\n//     using result = initiator.startHook();\n//\n//     // A hook event is almost always an observation. (Register actions too if invoking the\n//     // callback can cause side effects.) Pipeline through the not-yet-resolved promise.\n//     await result.approvalQueue.authorizeObservation({\n//       title: \"TODO: short event summary\",\n//       description: \"TODO: details about the event being delivered\",\n//     });\n//\n//     // Deliver the event to the Gadget's callback.\n//     await result.callback.onMyEvent(event);\n//   }\n\n// ---------------------------------------------------------------------------\n// SessionImpl — the RPC interface exposed to the Gadget\n\nclass MySessionImpl extends RpcTarget implements MySession {\n  #approvalQueue: RpcStub<ApprovalQueue>;\n  #ctx: DurableObjectState<MyGatekeeperImplProps>;\n\n  constructor(\n      approvalQueue: RpcStub<ApprovalQueue>,\n      ctx: DurableObjectState<MyGatekeeperImplProps>) {\n    super();\n    this.#approvalQueue = approvalQueue;\n    this.#ctx = ctx;\n  }\n\n  [Symbol.dispose]() {\n    this.#approvalQueue[Symbol.dispose]();\n  }\n\n  // Example: observation (read). Fetch data, then authorize before returning.\n  async getData(): Promise<string> {\n    let result = \"TODO: fetch from service or cache\";\n\n    await this.#approvalQueue.authorizeObservation({\n      title: \"Read data\",\n      description: \"Fetched data from the service.\",\n    });\n\n    return result;\n  }\n\n  // Example: action (side effect). Submit for approval; do NOT perform here.\n  // Assign a sequential action ID, store the action details in the gatekeeper's\n  // own storage, then submit the ID to the approval queue. If you read current state to build\n  // the description or capture revert info, do NOT log it as an observation: nothing is\n  // returned to the caller.\n  async updateData(newValue: string): Promise<void> {\n    let actionId = /* assign next sequential ID and store action details */ 0;\n\n    await this.#approvalQueue.submitAction(actionId, {\n      title: \"Update data\",\n      description: `Update value to: ${newValue}`,\n      implementsRevert: true,\n    });\n\n    // TODO: Update cache/simulation state so subsequent reads reflect this\n  }\n\n  // Example: hook registration — remove if the gatekeeper doesn't push events.\n  // `callback` is a persistent stub the Gadget created with ctx.restore(). Construct a controller\n  // whose props capture the specifics of THIS registration (e.g. `filter`), then hand it, the\n  // callback, and a user-facing description to the overseer via bindHook(). For multiple hook\n  // kinds, pick the appropriate controller class here. Do NOT store the callback yourself — it is\n  // bound to this session and would be revoked when the session ends.\n  async subscribe(callback: RpcStub<MyHook>, filter?: string): Promise<void> {\n    let controller = this.#ctx.exports.MyHookControllerImpl({\n      props: { ...this.#ctx.props, filter },\n    });\n    await this.#approvalQueue.bindHook(controller, callback, {\n      title: \"TODO: short hook title\",\n      description: `TODO: what events this hook delivers${filter ? ` (filter: ${filter})` : \"\"}`,\n    });\n  }\n}\n```\n\n## `cloudflare.config.ts`\n\n```typescript\nimport {\n  DEFAULT_GATEKEEPER_WRANGLER, OBSERVABILITY, defineGadgetsWorker, type DurableObjectMigration,\n} from \"@gadgets/scripts/worker-config\";\n\nexport default defineGadgetsWorker({\n  name: \"gatekeeper-<name>\",\n  entrypoint: \".wrangler/validate/src/<name>.ts\",\n  compatibilityFlags: [\"allow_irrevocable_stub_storage\"],\n  observability: OBSERVABILITY,\n});\n\nexport const wrangler = DEFAULT_GATEKEEPER_WRANGLER;\n\nexport const migrations: DurableObjectMigration[] = [\n  { tag: \"v0\", new_sqlite_classes: [\"UserAccount\", \"MyGatekeeperImpl\"] },\n];\n```\n\nRun `pnpm configs:generate`; the generated `wrangler.jsonc` is committed and never edited by hand.\n\nOnly Durable Object classes go in `new_sqlite_classes`. `MyVerifier` and `MyHookControllerImpl` are `WorkerEntrypoint`s, so they need no `migrations` entry — but, like all entrypoints, they must be `export`ed from the worker's main module (so `ctx.exports.MyVerifier(...)` resolves). If your hook uses a dedicated event-source DO to hold the `initiator`, add that DO here too.\n\n## Creating the `types.txt` symlink\n\n```bash\ncd packages/gatekeeper-<name>/src\nln -s types.d.ts types.txt\n```\n\n## Resource configurator files\n\nAdd per-resource configurator types and UI modules:\n\n```\nsrc/configurator/my-configurator-types.d.ts\nsrc/configurator/my-configurator-types.txt -> my-configurator-types.d.ts\nsrc/configurator/my-configurator-ui.tsx\n```\n\n`my-configurator-types.d.ts` should describe the iframe helper API. The UI module's own `resourceUrl()` hook returns the resource URL to Workshop.\n\n```typescript\nexport interface MyConfiguratorUI {\n  listResources(query: string): Promise<{ value: string; title: string }[]>;\n}\n```\n\nAdd package scripts:\n\n```json\n{\n  \"scripts\": {\n    \"deploy\": \"vp run --no-cache build:configurator && wrangler deploy\"\n  },\n  \"dependencies\": {\n    \"@gadgets/configurator-ui\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"@gadgets/scripts\": \"workspace:*\"\n  }\n}\n```\n\n`@gadgets/scripts` has to be declared, not just imported. The shared task configs and the\n`gadgets-*` bins that run them belong to that package, and the workspace root depends on it too —\nso its bins are already on `PATH` and a `vite.config.ts` importing it resolves even with no\ndeclaration here. That is a phantom dependency: it works until this package is built somewhere that\ndoes not happen to hoist it.\n\nNo `build` script: `build` is one of the shared Vite+ tasks below, and vp forbids a task and a\nscript sharing a name. Build this one package with `pnpm exec vp run -F <package-name> build` —\n`pnpm --filter` cannot see a task, so `pnpm --filter <package-name> build` reports nothing to run;\nthe workspace-wide `pnpm build` picks it up as usual. `deploy` goes through the task rather than\ncalling\n`build-gatekeeper-configurator.ts` itself, so the codegen command lives in one place and cannot\ndrift from the task that declares its env — `wrangler deploy` stays outside vp, since it has side\neffects and needs real credentials.\n\nAlso add a `vite.config.ts` re-exporting the shared `build` and `build:configurator` Vite+ tasks —\nthe latter is what the `pnpm dev-server` pre-flight builds (`vp run -r --cache build:configurator\n--dev`). Both are tasks rather than package.json scripts so `VITE_FRONTEND_ERROR_REPORTING` is\npassed through and cache-fingerprinted; a `build` script running the builder with `&&` would bypass\nthat and silently bake the wrong value into the shipped HTML (do not add either script back):\n\n```typescript\n// Vite+ per-package settings. The build:configurator task definition is shared by all gatekeepers\n// with a configurator UI and ships as an export of `@gadgets/scripts`, alongside the builder it\n// runs.\nexport { default } from '@gadgets/scripts/gatekeeper-configurator'\n\n// ...or, if the gatekeeper has tests, `withTests` instead: the same settings plus the shared vitest\n// `test` task. Add a `\"test:run\": \"vitest run\"` script too, for iterating without the cache.\nexport { withTests as default } from '@gadgets/scripts/gatekeeper-configurator'\n```\n\nKeep tokens and broad API clients out of public `RpcTarget` properties; use closures, `#private`, or `WeakMap` state and expose only narrow read-only helper methods.\n",".agents/skills/write-gatekeeper/SKILL.md":"---\nname: write-gatekeeper\ndescription: Guides implementation of Gatekeeper Workers that bridge Gadgets to external services. Covers auth, capability-based API design, approval queue integration, caching, and action simulation. Load when creating, modifying, or reviewing the implementation of a gatekeeper.\n---\n\n# Writing a Gatekeeper\n\nA Gatekeeper is a Cloudflare Worker that mediates all access between a Gadget and an external service. It implements a three-tier hierarchy:\n\n- **Vendor** (`GatekeeperVendor`, a `WorkerEntrypoint`) — top-level entry for the service. One per service.\n- **User** (`GatekeeperUser`, a `WorkerEntrypoint` with `ctx.props`) — a human user's authenticated connection.\n- **Instance** (`Gatekeeper<Session>`, a DO facet of the Overseer) — per-resource, per-Gadget binding that provides the Session API.\n\nRead `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces and detailed JSDoc.\n\n## Seven responsibilities\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\n4. **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. 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)).\n\n5. **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.\n\n6. **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.\n\n7. **Observer verification** — When a Gadget is shared, collaborators may \"observe\" data the Gadget previously read through the gatekeeper. The gatekeeper must ensure each collaborator could access that data themselves, via `getVerifier()` / `addObserver()` / `removeObserver()`. The interface methods are mandatory — a gatekeeper won't type-check without them — so include at least minimal versions in Phase 1; but *choosing and implementing the right strategy* is a Phase 2 security concern, like logging/approvals. See [Observer verification](#observer-verification).\n\n## Phase 1: Core implementation\n\nIn the first phase, focus only on responsibilities 1 - 3, though keeping in mind that 4 - 7 will need to be implemented later. Note the observer methods of responsibility 7 (`getVerifier`/`addObserver`/`removeObserver`) are required for the code to type-check, so the skeleton includes minimal versions; you flesh out the actual strategy in Phase 2.\n\n### Step 1: Understand the external service\n\nStudy the service's API docs. Identify:\n- Auth model (OAuth 2.0, API keys, etc.)\n- Resources to expose and what access granularities make sense\n- Which operations are observations (read-only) vs. actions (side effects)\n\n### Step 2: Design the Session types\n\nCreate `src/types.d.ts` defining the Session interface (and Hook interface if the service pushes events — see [Hooks](#hooks-push-notifications)).\n\nBefore 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.\n\nDesign principles:\n- One interface per logical resource type, not a god-object\n- Methods return structured data, not raw API responses\n- 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\n- Simplify API complexities that are not likely to matter to agents and gadgets; design for a more novice user and common use cases\n- Consider what URL patterns `getGatekeeperClassFor()` should match — each pattern maps to a resource granularity\n- Include JSDoc comments; these types serve as the agent's API documentation. See [Documenting the API](#documenting-the-api-typesdts) below.\n\n#### Documenting the API (`types.d.ts`)\n\nThis 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.\n\nDo 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`.\n\nThe 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.\n\n### Step 3: STOP — Present API for review\n\n**Do not proceed without operator approval.**\n\nPresent 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.\n\nAlso 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.\n\n### Step 4: Implement\n\nSee [SKELETON.md](SKELETON.md) for a complete implementation template.\n\nPackage structure:\n```\npackages/gatekeeper-<name>/\n├── src/\n│   ├── configurator/         # Optional resource-picker UI modules and UI-facing types\n│   ├── <name>.ts              # Vendor, UserAccount, UserImpl, GatekeeperImpl, SessionImpl\n│   ├── types.d.ts             # Session/Hook types (compile-time)\n│   ├── types.txt -> types.d.ts  # Symlink (runtime, for getTypeScriptTypes())\n│   └── <name>-api.ts          # (optional) Helper wrapping the service's HTTP API\n├── cloudflare.config.ts      # wrangler.jsonc is generated from it\n├── package.json\n└── tsconfig.json\n```\n\n### Step 5: Register\n\nThere 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`).\n\n### Step 6: Add resource selection UI\n\nAdd 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.\n\n- 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.\n- Return `iframeHtml` of the selection UI and `ui` for any RPCs that UI needs.\n- When the user selects \"Add connection\", Workshop asks the iframe for the selected resource URL.\n- Group types (`SupportedResource.groupedWith`) when they are one choice at different granularities, such as a whole drive, a folder or a file. Each type is still granted separately, and a grouped card asks for the missing ones in one consent request, so group only types that need the same authorization. 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.\n\nKeep the iframe-facing capability narrow, only what's necessary to provide desired interface to help user find and select the resource.\n\n#### Optional helper: `@gadgets/configurator-ui`\n\nFor 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`.\n\nComponents: `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; breadcrumbs, optional search, paging and `loadItem`, which pre-filling anything but the root needs; nodes name icons the configurator supplies in `icons`; 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), `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). The runtime strips control and bidi-formatting characters from titles, subtitles and meta it shows, since those come from third parties.\n\nIf you use this:\n\n- UI modules live in `src/configurator/*-ui.tsx`.\n- `resourceUrl()` returns the selected resource URL.\n- 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.\n- `src/configurator/*-types.d.ts` describes the iframe-facing `ui` API.\n- 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.\n- 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`.\n- `scripts/build-gatekeeper-configurator.ts` generates `src/generated/*.txt`, using the runtime and build in `packages/configurator-ui` (`src/runtime/`, `build/`).\n- Nothing invokes `build-gatekeeper-configurator.ts` by hand. `vite.config.ts` re-exports the\n  shared `build` and `build:configurator` Vite+ tasks from\n  `scripts/gatekeeper-configurator-vite-config.ts`; `build` is just `tsc` and depends on\n  `build:configurator`, which carries `VITE_FRONTEND_ERROR_REPORTING` in its fingerprint, and\n  `deploy` runs `vp run --no-cache build:configurator && wrangler deploy` — deploys never replay a\n  cached artifact. There is no `build` script\n  and no direct builder call, because a script running the builder gets vp's stripped environment\n  and bakes the wrong flag into the shipped HTML.\n\n##### Pre-filling the form from a known resource URL\n\nWhen 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:\n\n- 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.\n- 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`):\n\n  ```ts\n  initialValuesFromResourceUrl({ resourceUrl }) {\n    const [owner, repo] = new URL(resourceUrl).pathname.split(\"/\").filter(Boolean);\n    return owner && repo ? { repoFullName: `${owner}/${repo}` } : {};\n  },\n  ```\n\n  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.\n\n### Step 7: STOP — Ask operator whether to proceed to phase 2\n\nThe operator may prefer to implement phase 2 later, perhaps in a new context. Stop here and ask the operator whether to proceed.\n\n## Phase 2: Logging, approvals, caching, simulation, and observers\n\nIn this phase, we focus on responsibilities 4-7. These are typically added as a second pass, after the core gatekeeper works. They may be implemented in a separate session.\n\n### Logging and approvals\n\nGo through all the API methods and decide where to insert calls to the `ApprovalQueue`.\n\n- Any operation which reads external data and returns it to the caller (but with no side effects) must call authorizeObservation().\n- Any operation which has visible side effects on the world must call submitAction(), and must not actually apply the action until approved.\n\nStudy the `ApprovalQueue` API in `gatekeeper.ts` for details.\n\n#### What counts as an observation\n\nThe 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.\n\nDo **not** call `authorizeObservation()` for:\n\n- **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\".\n- **Session setup.** `startSession()` resolving the account, a bound label, and so on returns only a session stub.\n- **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.\n- **Information that leaks only through a validation error** (\"label already exists\", \"branch is not a fast-forward\").\n\n**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.\n\nReturning 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.\n\n**Exception: observer tracking.** Under observer strategy C (below), `authorizeObservation()` does more than log. Its `excludeObservers` and set-tracking side effects gate which collaborators may see the workspace, and `containsRestrictedData` / `ownerInvitesOnly` change workspace state. A call that carries any of these is a security control, and removing it is a security decision, not a logging cleanup (see the Drive session's \"Check Google Drive folder\" fences).\n\nIt's critically important that you add `ApprovalQueue` to all API operations that interact with the outside world, otherwise the gatekeeper security model is broken.\n\n### Caching\n\nStore fetched data in the gatekeeper's DO storage (`this.ctx.storage`) to avoid redundant API calls. The cache also enables a better API shape when the service's native data model is awkward — e.g., Gmail's list API returns thread IDs without metadata, but with caching the Session can return richer summaries directly.\n\n- Use TTLs or revision IDs to keep the cache fresh.\n- Cache transformed data (e.g., Markdown) rather than raw API responses when the transformation is expensive.\n\nPROTIP: The relatively new API `this.ctx.storage.kv` provides synchronous versions of the traditional Durable Object storage API, e.g. `get` and `put`. Use these instead of the old asynchronous methods. (Note that the synchronous API does not provide \"batch\" versions of `get()` and `put()`, but you don't really need them since simply making multiple calls is efficient.)\n\nPROTIP: `this.ctx.storage.sql` gives you access to a full, private SQLite database. Use this when the full power of SQL is useful, but prefer KV for simple things.\n\nThe full Durable Objects storage API (including synchronous KV and SQLite) is documented at: https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/\n\n### Simulation\n\nWhen `submitAction()` has been called but `applyAction()` hasn't, reads should reflect the pending action. This allows the calling agent or gadget to be unaware of the approvals mechanism, and proceed with follow-on work immediately. The end user is able to approve a whole batch of changes at once, later on.\n\nTwo possible implementation approaches include:\n\n1. **Mutate the cache** — Apply the action's effects to cached data on submit. On `rejectAction()`, invalidate or rebuild the cache. Simple; works well when the cache is already a transformed view. Don't forget to re-apply any queued actions when updating the cache.\n\n2. **Overlay at read time** — Store pending actions separately; merge them into read results on demand. Cleaner separation; better when the overlay logic is straightforward.\n\nChoose based on the service's data model and the complexity of simulating each action type.\n\nKeep in mind that the agent calling the API (or the agent writing a gadget to call it) is generally not aware that actions do not take place immediately. If the simulation is correct, the agent doesn't need to be aware. If the simulation has gaps, you may want to mention it in your API's doc comments, so that the calling agent knows to work around them — but ideally there are no gaps and the calling agent does not need to think about it.\n\nFor concrete examples, see the Google gatekeeper's Google Docs simulation/cache handling and BigQuery dry-run scope enforcement.\n\n### Observer verification\n\nThis is responsibility 7. When a Gadget is shared, each non-owner collaborator becomes an **observer** of every gatekeeper bound to the Gadget, and may see data the Gadget previously read. The gatekeeper's job is to refuse — or forward-restrict — observers who couldn't access that data themselves.\n\nThree methods implement this (full JSDoc in `gatekeeper.ts`):\n\n- `GatekeeperUser.getVerifier()` — mints a `GatekeeperUserVerifier` (a persistent service stub) representing *this* user's account. The overseer mints one per open and **only ever passes it back to a gatekeeper of the same vendor**, so the gatekeeper may trust whatever it learns from it.\n- `Gatekeeper.addObserver(id, verifier)` — must **throw** if the user represented by `verifier` is not allowed to observe everything read through this gatekeeper so far. The overseer calls it on **every open by every authorized observer** (re-verification, so revoked access is caught at the next open); cache as needed if the check is expensive. `id` is an opaque, stable per-(user,gadget) string.\n- `Gatekeeper.removeObserver(id)` — idempotent; drop a tracked observer.\n\n#### The verifier \"non-standard method\" pattern\n\n`GatekeeperUserVerifier` has no methods of its own — it's an opaque token. To actually answer \"can this observer access X?\", define a vendor-specific interface that **extends `GatekeeperUserVerifier`** with your own methods, implement it on a `WorkerEntrypoint` that queries the service using the **observer's own token**, and cast the `Fetcher` back to that interface inside `addObserver`. The overseer's same-vendor guarantee is what makes the cast safe.\n\n```typescript\n// In types/impl: a verifier interface with non-standard methods.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\ntype MyVerifierProps = { userObjectId: string };\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    // Query the service with the OBSERVER's own token (this.ctx.props.userObjectId).\n    try {\n      await myApiForObserver(this.ctx).getResource(resourceId);\n      return true;\n    } catch (error) {\n      // Distinguish \"no access\" from \"transient failure\":\n      //   - auth/permission/not-found (401/403/404) → false (they can't see it)\n      //   - anything else → rethrow, so the open fails loudly rather than silently denying\n      if (isNoAccessStatus(statusOf(error))) return false;\n      throw error;\n    }\n  }\n}\n\n// In GatekeeperUser:\nasync getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n  return this.ctx.exports.MyVerifier({ props: { userObjectId: this.ctx.props.userObjectId } });\n}\n```\n\n`MyVerifier` is a `WorkerEntrypoint`, so it needs **no migration entry**, but (like all entrypoints) it must be `export`ed from the worker's main module so `ctx.exports.MyVerifier(...)` resolves.\n\n#### Choosing a strategy (per resource type / binding)\n\nStrategy is chosen **per `Gatekeeper` DO class / binding**, not per package — one package may use several (e.g. Google: Gmail=A, Doc=B, BigQuery=C).\n\n- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox). For truly sensitive data, also mark each observation with `ObservationDescription.containsRestrictedData: true`: because nobody can open the workspace without passing `addObserver()`, and with strategy A nobody ever does, the first such observation makes the workspace effectively unshareable — and it permanently sets the workspace's `containsRestrictedData` flag, whose restricted mode blocks all actions and public web fetches, so the data cannot leak back out through other gatekeepers.\n- **B — ACL check (single unit).** The binding is one atomic resource; sub-resources inherit its ACL. `addObserver()` calls a verifier method to confirm the observer can access it and throws otherwise; `removeObserver()` is a no-op; nothing is tracked and no `excludeObservers` is ever needed. Use for repo / document / page / team / single-project bindings.\n- **C — Data-set tracking.** The binding spans sub-resources with **distinct ACLs**, and there is a **per-observer access oracle** for each. The DO logs the data sets actually observed and the current observers; `addObserver()` verifies the observer against **every** logged set (plus a coarse membership baseline) and **stores their verifier**; each later observation that first touches a **new** set re-checks all stored observers and sets `excludeObservers` for any who fail. Use for workspace / organization / dataset-spanning bindings.\n- **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier with a no-op public method such as `verify(): void {}` (an empty `WorkerEntrypoint` is not registered in `ctx.exports`). Use when any collaborator may observe (personal, low-stakes services).\n\nThe **B-vs-C decision** (the \"broad binding\" lens): use C only when **both** (1) the binding spans sub-resources with distinct ACLs *and* (2) there's a per-observer oracle to check each against. If one ACL covers everything → B. If there's no oracle → A or D.\n\n#### Implementing strategy C\n\nRoute **every data-revealing observation** through a helper that takes the set id(s) the observation reveals, instead of calling `authorizeObservation()` directly:\n\n```typescript\n// On the Gatekeeper DO. `setIds` are the data sets this observation reveals.\nasync authorizeSetObservation(\n    queue: RpcStub<ApprovalQueue>, setIds: string[], description: ObservationDescription) {\n  const check = setIds.length > 0\n      ? await this.#prepareSetObservation(setIds)\n      : { pendingSets: [], excludeObservers: undefined };\n  await queue.authorizeObservation({ ...description, excludeObservers: check.excludeObservers });\n  for (const setId of check.pendingSets) this.#markSetObserved(setId);\n}\n\nasync #prepareSetObservation(setIds: string[]) {\n  const pendingSets = [...new Set(setIds)].filter(id => !this.#isSetObserved(id));\n  if (pendingSets.length === 0) return { pendingSets, excludeObservers: undefined };\n  // This synchronous state change is visible to addObserver() before verifier RPCs can interleave.\n  for (const setId of pendingSets) this.#markSetPendingIfUnknown(setId);\n  const excluded = new Set<string>();\n  for (const [id, verifier] of this.#listObservers()) {\n    for (const setId of pendingSets) {\n      if (!(await verifier.hasSetAccess(setId))) { excluded.add(id); break; }\n    }\n  }\n  return {\n    pendingSets,\n    excludeObservers: excluded.size > 0 ? [...excluded] : undefined,\n  };\n}\n```\n\nKey points for C:\n\n- **Use two durable states: pending and observed.** Mark unknown sets pending before the first await, recheck pending sets on every retry, and promote them only after `authorizeObservation()` succeeds. A failed authorization leaves them pending. `addObserver()` must check both states and loop until no unchecked sets remain before synchronously storing the verifier; this also closes admission races in either request ordering.\n- **The session impls must route through this helper**, not `approvalQueue.authorizeObservation()`. If sessions hold the raw queue (not the DO), thread a small prepare hook/callback into each session and any sub-sessions it spawns, and expose a completion step that promotes its pending sets after authorization. For a single broad binding the hook is active; for the narrow (B) sibling binding it is absent (passthrough). See Linear/Notion for the shared-session-impl case and Supabase for the context-object case.\n- **One observation may reveal several sets** (e.g. a workspace-wide list whose rows belong to different sub-resources). Pass all of them; union the exclusions over the newly-seen ones. Reads that reveal *no* set (workspace name, member directory, a bare \"open\") pass an empty list and rely on the membership baseline.\n- **`addObserver` baseline:** verify the coarse membership (e.g. same org/workspace) that gates the set-independent reads, then verify each already-observed set, then store the verifier. Fail closed if a needed identity is unknown (e.g. an account connected before you began persisting the workspace id → force a reconnect).\n\n#### `excludeObservers` semantics (why conservative is safe)\n\nWhen `authorizeObservation()` is given `excludeObservers`, the overseer **blocks the observation** if any named observer is still authorized, and only lets it proceed (tearing down the observer) if they've already lost access. So erring toward listing an observer is never a leak — at worst it blocks an observation that could in principle have been allowed. The leak-relevant gate is always the live sharing graph, so stale observer state self-heals on the next open.\n\n## Hooks (push notifications)\n\nSome services can push events to the Gadget (inbound email, webhooks, chat messages, etc.). A gatekeeper exposes this as a **hook**: the Gadget registers a callback, and the gatekeeper later invokes it when an event arrives. Hooks are persistent — they survive across sessions and server restarts — and are subject to the same observation/action approval model as everything else.\n\n`gatekeeper-email` is the canonical reference implementation. Read it alongside the `HookController`, `HookInitiator`, and `ApprovalQueue.bindHook()` JSDoc in `gatekeeper.ts`.\n\n### The pieces\n\n- **Hook interface** (in `types.d.ts`): the methods the Gadget implements to receive events, e.g. `EmailHook.receiveEmail(email)`. It is implemented by the Gadget as an **`RpcTarget`** (or a plain function), *not* a `WorkerEntrypoint`. Reference it from `describe()` via `hookTsType`.\n- **Session method**: a method like `subscribe(callback)` that the Gadget (or, more commonly, an agent in a one-off `executeCode` call) uses to register interest. The `callback` is a **persistent stub** (created by the Gadget with `ctx.restore()`), so it can be stored and re-invoked long after the session ends.\n- **`HookController`** (a `WorkerEntrypoint` you implement): lets the overseer `enable()` / `disable()` the hook. All the state it needs must live in its `props`, so it is constructed via `this.ctx.exports.MyHookControllerImpl({props})` **at bind time**, immediately before calling `bindHook()` — see below.\n- **`HookInitiator`** (provided to you by the overseer): you call `startHook()` on it when an event arrives.\n\n### Lifecycle\n\n1. **Register.** The Gadget calls your Session method (e.g. `subscribe(callback, filter)`). Inside it, construct a `HookController` whose `props` capture the specifics of *this* registration, then call `approvalQueue.bindHook(controller, callback, description)`. The overseer stores the callback and records the hook (initially **disabled**). Do **not** store the callback yourself — it is bound to the current session and would be revoked when the session ends.\n2. **Enable.** When the user approves the hook in the Workshop UI, the overseer calls `controller.enable(initiator, target)`. Store the `initiator` Fetcher somewhere it can be reached when events arrive (e.g. an event-source DO). `target` identifies where the hook delivers (workspace, plus gadget when the hook is pinned to one); persist it alongside the initiator if you display or link to the target — the IDs are fixed when the hook is bound, so there is nothing to refresh. A gatekeeper that doesn't need it still has to declare the parameter, since RPC argument validation rejects arguments the receiver doesn't declare. Avoid storing any other state until enabled; everything else should already be in the controller's `props`.\n3. **Deliver.** When the event occurs, call `initiator.startHook()`. This returns `{callback, approvalQueue}` bound to a fresh session. Call `authorizeObservation()` (a hook event is almost always an observation; register actions too if the callback's return value triggers side effects), then invoke the `callback` to deliver the event to the Gadget.\n4. **Disable / delete.** The overseer calls `controller.disable()`. Forget the stored `initiator` and clean up all related state — `disable()` may never be called again, though the overseer may later call `enable()` afresh.\n\nBecause the callback is a persistent stub tied to a session, the gatekeeper never stores it directly; the overseer hands it back (re-bound to a new session) each time you call `startHook()`. See the SKELETON for the full code shape.\n\n### Documentation\n\nWhen defining a session interface with hooks, it's important to include comments that clearly state when a method expects to be passed a *persistent* stub created with `ctx.restore()`, as opposed to a regular RpcStub. The caller needs to do extra work to make sure the stub they provide you is persistent.\n\n## Reviewing a gatekeeper\n\nReviews tend to stop at general correctness. The most important review is of the agent-facing API, so pressure-test it:\n\n- **Shape.** Read `types.d.ts` as the agent does, as its only documentation. Does the shape make sense? What would you do differently?\n- **User stories.** List the most common things a user would ask an agent to do with this service and walk each through the API. Flag any that are impossible, need sequential round trips that promise pipelining can't remove, or need knowledge the JSDoc doesn't give. Flag dead ends too: anything the grant includes that the agent can't navigate to, such as child pages it can list but not open.\n- **Seams.** Check that resource types, bindings and sessions partition authority along the lines users actually grant:\n  - Each `getSupportedResources()` granularity is a grant a user would make, and together they cover the common ones (broad and narrow).\n  - A narrow binding's session reaches nothing outside its resource: sub-resources come from stubs on their parent, and no method or option field (`parentId`, `projectId`) takes an ID that escapes the binding.\n  - Each method sits on the object whose authority it needs. A cross-resource operation (move, copy, link) must not quietly require, or grant, a broader binding than the user picked.\n  - Each binding's types expose only what that binding can do and stay small, and its observer strategy fits how the binding is partitioned (see [Choosing a strategy](#choosing-a-strategy-per-resource-type--binding)).\n\n## Tips\n\n- `types.txt` must be a **symlink** to `types.d.ts`, never a copy. Because it is delivered verbatim, `types.d.ts` must not import from other modules — inline any shared types (see [Documenting the API](#documenting-the-api-typesdts)).\n- Call `.dup()` on `approvalQueue` stubs before storing in a session, since Cap'n Web automatically disposes all stubs in parameters to an RPC call when the call returns.\n- `suggestedBindingName` in `describe()` reflects the resource **type** (e.g. `\"GMAIL_INBOX\"`), not the specific instance.\n- For read-only or push-only gatekeepers, `applyAction()` / `rejectAction()` / `revertAction()` can simply throw (they'll never be called since the gatekeeper never submits actions).\n- For `WorkerEntrypoint` and `DurableObject` subclasses, pass credentials and resource IDs via `ctx.props`, not constructor arguments. RPC stubs pointing to these types can be stored in long-term storage and restored later, creating a new instance based on the same `props`.\n- If the gatekeeper implements multiple unrelated resource types with disjoint APIs, each may have its own `.d.ts` file, so that the `getTypeScriptTypes()` method of the specific `Gatekeeper` implementation only returns the types that matter for it. The `getTypeScriptTypes()` method on the top-level `GatekeeperVendor` should return the concatenation of all of these.\n- All DO classes must appear in `cloudflare.config.ts`'s `migrations` export under `new_sqlite_classes`.\n- Set a self-destruct alarm in `UserAccount.setCallback()` in case the OAuth flow is never completed.\n- `authorizeObservation()` may be called *after* fetching data (so the description can include details about what was fetched) but must be awaited *before* returning anything to the caller.\n- `getVerifier()` / `addObserver()` / `removeObserver()` are **mandatory** — the gatekeeper won't type-check without them. Even a read-only or push-only gatekeeper needs them (sharing is independent of whether the gatekeeper has actions). Pick a strategy per [Observers](#observer-verification): a low-stakes one can be A or D; otherwise B/C.\n\n## Reference implementations\n\n- `packages/gatekeeper-google/` — OAuth, multiple resource types (Gmail, Google Docs, BigQuery), actions, caching/simulation examples, multiple Session types. **Observers:** all three strategies in one package — Gmail=A (always throw), Doc=B (single-unit ACL via `GoogleVerifier.hasDocAccess`), BigQuery=C (dataset tracking via `hasDatasetAccess`).\n- `packages/gatekeeper-email/` — Hook-based push notifications, no actions, email address claiming. **Observers:** strategy D (low-stakes no-ops + trivial verifier).\n- `packages/gatekeeper-github/` — **Observers:** clean strategy B example — `GitHubVerifier.hasRepoAccess` plus a one-method `addObserver`.\n- `packages/gatekeeper-gitlab/` — A **mirror** of gatekeeper-github for a provider with instance configuration (self-hosted URLs, an optional Cloudflare Access service token on upstream requests) and **rotating credentials** (expiring access tokens, single-use refresh tokens refreshed under a mutex). Reads the same `plans/worktrees.md` §3 git contract through `gatekeeper-kit`'s `git-transport`/`git-objects`/`git-diff`. **Observers:** strategy B with a role-aware probe (`membershipGrantsFullRead`): Reporter membership whatever the visibility, since a public project can still hold confidential issues and internal notes, read from `members/all?user_ids[]=` because the project's `permissions` object omits group-inherited access. Its `plans/gitlab-gatekeeper.md` divergence table is a worked example of porting one gatekeeper's API to another provider's vocabulary.\n- `packages/gatekeeper-supabase/` — **Observers:** strategy C with a per-session context object (`authorizeProjectObservation`) — good when sessions already hold a shared context.\n- `packages/gatekeeper-linear/` & `packages/gatekeeper-notion/` — **Observers:** strategy C where the page/team session impls are shared between the narrow (B) and broad (C) bindings, threading an `observe` hook through sub-sessions; both also handle one observation revealing multiple sets.\n- `packages/workshop-shared/src/gatekeeper.ts` — Canonical interfaces with detailed JSDoc (`getVerifier`, `addObserver`, `removeObserver`, `GatekeeperUserVerifier`, `ObservationDescription.excludeObservers`).\n","AGENTS.md":"This project is building a platform for \"vibe coded\" personal applications and AI agents that run inside a strong sandbox.\n\nThe following files are commonly important to reference:\n\n* packages/workshop-shared/node_modules/capnweb/README.md: Explains how to use Cap'n Web RPC, which is used extensively for client-server communications.\n* packages/workshop-shared/src/api.ts: Defines the RPC API used between the frontend and backend.\n\nThe project structure is:\n\n* packages/workshop-frontend: The Gadgets Workshop UI.\n    * This is a pure single-page app, running entirely client-side.\n    * It speaks to the backend using an RPC API over a persistent WebSocket connection.\n    * Uses React, Kumo UI (https://kumo-ui.com/api/component-registry), Phosphor icons, and Vite.\n* packages/workshop-backend: The Gadgets Workshop server.\n    * Runs on Cloudflare Workers.\n    * This is the **kernel**: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read *every line* of `workshop-backend` and of API changes in `workshop-shared`, so keep diffs here small and elegant. Concretely: doc-comment **every** exported member of the `workshop-shared` public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes \"ambient\" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits so `workshop-backend`/`workshop-shared` can be reviewed apart from UI), since fewer kernel lines = easier review.\n    * Installs the **bundled blueprints** from `@gadgets/bundled-blueprints` on a deployment's first `/api` request, and offers them as the deployment's standard output formats. `scripts/build-bundled-blueprints.ts` generates the gitignored `src/generated/bundled-blueprints.ts` they are installed from, which holds each blueprint's manifest and built files (`BUNDLED_BLUEPRINTS_DIR` overrides the source directory), so `build` and `test` both run the generator first; `pnpm import:bundled-blueprint` updates or adds a blueprint from a Workshop export, of either `.gadget` version. Installing one (`src/bundled-blueprints.ts`) stores it as any published blueprint is stored: a git commit of its files, as a packfile at `<blueprintId>/<commitId>` in R2, named by `metadata.commitId` in KV (see `docs/blueprints.md`). The commit is a *snapshot release* (`buildSnapshotRelease` in `src/blueprint-release.ts`): parentless, with a fixed author, date and message, so it depends on the files alone. Every deployment therefore derives the same commit from the same files, and the installer never reads what was installed before. The cost is that a bundled blueprint's versions are not chained to one another as a published blueprint's releases are. A gadget made from one still takes its updates, merged against the release the gadget last took.\n    * Agent turns emit the Workers trace spans Cloudflare's Agents dashboard reads (`src/agent-tracing.ts`): `invoke_agent` per turn, `chat` per model request, `execute_tool` per tool call, and `tool_approval` when an agent's action waits for a manual decision and when it is approved or denied. They carry token counts, including cache reads and writes, but never prompts, responses or tool data. A span belongs to the invocation that opened it, and once a Durable Object invocation has returned, the next event the object receives ends its trace. So a turn keeps all its spans only while the request that started it (usually the browser session) stays open.\n* packages/bundled-blueprints: The blueprints the deployment ships with, `blueprints/<name>/` holds each blueprint's `blueprint.json` plus its gadget code under `files/`, which may be TypeScript.\n* packages/workshop-shared: Shared API definitions between client and server.\n    * This defines the application's RPC interface.\n    * The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details.\n* packages/configurator-ui: The authoring API (`src/index.ts`, type-only stubs) for optional gatekeeper resource configurator UI modules, the sandbox runtime that renders them (`src/runtime/`: Kumo components on `preact/compat`), and the build that bundles both into each configurator's frame (`build/`).\n    * Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.ts` as part of package builds. Each frame is one self-contained HTML document: the minified runtime, its Tailwind/Kumo CSS, and the module, under a content security policy that admits only those scripts by hash and allows no network access. Kumo and Base UI support only React, so the browser suite (`vitest.browser.config.ts`, Chromium) is what catches an upgrade that stops working on Preact; `build/frame.test.ts` drives a generated frame over the real Cap'n Web host protocol.\n    * `pnpm dev-server` rebuilds a gatekeeper's frames when its `src/configurator/` or the runtime's `src/runtime/` changes; restart it after editing `build/`, which the watcher has already loaded.\n* packages/ui: Shared runtime React UI used across the Workshop and gatekeeper management apps.\n    * Kumo remains the primitive and token foundation. Reusable Gadgets interaction patterns and composed controls belong here rather than being reimplemented in individual apps.\n* packages/gatekeeper-*: Gatekeeper workers for external service integrations.\n    * Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a `gatekeeper-*` package with **no `wrangler.jsonc` is a library, not a worker** (`gatekeeper-kit` here; `gatekeeper-shared` in the internal repo). Deployable discovery is config-gated, not name-gated — `readDeployablePackages` in `scripts/release/manifest-lib.ts` keys solely on the presence of `wrangler.jsonc`, and `run-dev-server.ts` requires it too — so adding one to a library package is what would make it deployable, at which point `workerKind` would classify it a gatekeeper by prefix and the deploy wizard would demand `CLIENT_ID`/`CLIENT_SECRET` for it. `manifest-lib.test.ts` fails first if that ever happens.\n    * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml`, which sends the popup to the Workshop's `/connect/handoff` page, and that page redeems the single-use ticket over the popup's own session together with a per-flow nonce (see `docs/connect-handoff.md`); a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone.\n    * A gatekeeper may declare `VendorDescription.autoProvisionsAccount`: it can mint a connected account with no OAuth flow (via `GatekeeperVendor.createAccount()`, which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — **disabled** / **optional** / **enabled** (default **optional**) — resolved in `provisioning-policy.ts`: `enabled` auto-provisions the account for every user (forced, and hidden from the Connectors list), `optional` lets each user opt in from the Connectors page or during onboarding (where each starts switched on and is provisioned when the user finishes), and `disabled` offers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The **account** (a `GatekeeperUser`) declares in its `AccountDescription` whether it provides an agent **singleton** (`singleton: { tsType }`) and/or a **management UI** (`providesUi`). The Workshop auto-provides the singleton to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's env as a **named chat binding** (named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in overseer.ts) that the agent reads in `executeCode` (`getSession`/`getAgentCatalog`), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it. The UI is hosted at `/gatekeepers/$appId` (the gatekeeper's vendor id, e.g. `/gatekeepers/context`) via `startAppUi({ isAdmin })`. The two are orthogonal — an account can declare either, both, or neither.\n* packages/mcp-shared: Shared implementation behind the two MCP gatekeepers — `gatekeeper-mcp` (endpoints a user pastes) and `gatekeeper-mcp-portal` (one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. See `packages/mcp-shared/README.md` and each connector's README.\n    * The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation, everything else is queued for approval, and auto-*applying* a write additionally requires a `vetted` endpoint — which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`.\n    * OAuth uses the official `@modelcontextprotocol/client`; always give SDK OAuth operations `sdkFetch(...)` so every request and redirect retains endpoint and SSRF checks.\n* packages/gatekeeper-context: The Context Library — a gatekeeper whose account provides a singleton read session + a management UI, for authoring collections of context documents that agents read as observations. Collections have one of two visibilities: **private** (created by one account and shareable with others) and **public** (created/edited only by deployment admins, readable by everyone and auto-enabled for all users; not shareable). It owns its state in three Durable Objects (`ContextCollectionDurableObject` for content and a private collection's grants, `UserLibraryDurableObject` for each account's index of collections it created or was given, `LibraryRegistryDurableObject` for the domain's public set) plus a KV namespace. All data is namespaced by a `sharingDomain` (from the binding's props, see `domain.ts`) so multiple workshops sharing one gatekeeper instance stay isolated.\n    * Sharing is whole-collection. A private collection's own grants (`viewer` / `editor` / `owner`, keyed by Context account) and its `everyoneCanView` flag are the sole authority, interpreted only by `collection-access.ts`; every listing, agent read, and observer verification re-checks them live, so a library index entry is discovery and never access. A collection always keeps an owner except when its last owner's account is disconnected while others still hold access; its editors then keep editor permissions only (nobody can delete it or grant ownership), and it is deleted once its last grant goes, even if everyone can view it. Everyone-viewable only grants read access (so sharing a workspace that used the collection is not blocked); it never adds the collection to anyone's library. Shares arrive through the Workshop user picker: the management API redeems the ticket and takes the recipient's account IDs from their Context verifiers (`getAccountId`), never from the browser, and grants all of that person's accounts together. Losing write access revokes the git mirror tokens that account minted, and tokens from before sharing count as the creator's; the collection serializes grant changes, token minting, and deletion so none can race another, and retries a failed revocation from an alarm. Disconnecting marks the account's library first, so a share racing it is refused rather than left behind. `plans/context-collection-sharing.md` is the design record.\n    * Its `GatekeeperVendor` entrypoint (bound as `GATEKEEPER_CONTEXT`) declares `autoProvisionsAccount` and mints a `ContextAccount` via `createAccount()` (no user identity is passed in; the account keys its private data by its own generated `accountId`). The account exposes the agent read session (`getSession()`), collection discovery metadata (`getAgentCatalog()`), and a management UI (`startAppUi({ isAdmin })`). The UI is a single-file React SPA in `app/` (Vite + Tailwind + Kumo) bundled by `build-app.ts` into `src/generated/app.txt`.\n* packages/gatekeeper-scheduler: Scheduled Tasks — an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks plus a read-only management UI. One account-scoped `ScheduleDriver` Durable Object stores enabled schedules and delivers them from a shared alarm; hook enablement remains in the Workshop Connections UI.\n* packages/gatekeeper-websearch: Web Search — an auto-provisioned gatekeeper whose account provides an ambient `WEB_SEARCH` singleton that searches the public web through its own Workers AI binding (`websearch()`, through the AI Gateway its `CF_AI_GATEWAY` var names, provider pinned to Ceramic). The deploy service sets that var to the instance's gateway, the backend's; anywhere else it defaults to the account's `default` gateway. Each query is recorded as an observation *before* it is sent. A workspace that has observed restricted data can still search: unlike a `webFetch` URL, the query can only go to the pinned provider. Preinstalled on every deploy like Scheduled Tasks; every search spends the account's AI Gateway credits, outside the model spend limits, unless that gateway holds a Ceramic key, which Ceramic then bills. The README is the design record.\n* packages/gatekeeper-gitlab: GitLab, for gitlab.com and self-hosted instances — a mirror of `gatekeeper-github` (project / issue / merge request resources, the same action queue and read-time simulation, the same worktree contract) in GitLab's own terms. `plans/gitlab-gatekeeper.md` is the design record, and its divergence table lists every place GitLab's API forced a different shape; the README covers instance configuration (`GITLAB_URL`, `GITLAB_API_URL`, and an optional Cloudflare Access service token).\n* packages/gatekeeper-x: X (formerly Twitter) — account / post / List / read-only profile resources over plain `fetch` (the `twitter-api-v2` package breaks on Workers). `plans/x-gatekeeper.md` is the design record. X's API is pay-per-use and bills every resource a read returns, so cost is part of the design and easy to undo by accident:\n    * Every read goes through the gatekeeper's `#read`, which reserves against the connection's daily read limit (`X_DAILY_READ_LIMIT`, counted per UTC day in the `UserAccount` DO across all of the connection's bindings) and settles with what X returned. Listings use `XCursor`, one X request per `next()`, never the kit's `TokenCursor`, which refills a short page with up to ten more billed requests.\n    * Only bookmark, mute and hide are `autoApprovable`: X's developer policy forbids automating likes, follows, reposts and posts. Do not widen that list.\n    * A connection is pinned to the X user it was made as (a reconnect as anyone else is refused and its new grant revoked), and actions are fenced on that X user's ID rather than the connection generation, so a same-user reconnect to widen scopes does not strand pending actions.\n    * Publishing is `claimBeforeApply`, and a send whose outcome X never reported is reconciled inline on the next apply (an attempt marker, then a look at the account's posts from around the attempt, binding only a unique match on text, links, media and reply parent), because the kit's `ActionOutcomeUnknownError` is terminal. Creating a List is reconciled the same way. Rejecting a publish or List creation that may already be on X is refused.\n    * X is renaming \"tweet\" to \"post\" on the wire and both spellings occur; `x-normalize.ts` reads both. The configurators copy URL grammar from `x-urls.ts`, and `__tests__/configurator-url.test.ts` keeps the copies in step.\n* packages/gatekeeper-cloudflare: Cloudflare OAuth, serving three unrelated purposes from one connected account — sign-in (`AUTH_GATEKEEPERS`), AI Gateway billing, and **Workers Observability** read-only telemetry resources. Two resource granularities (whole account, or one Worker) both map to the single indivisible `workers-observability.read` scope, so the capability boundary is the *binding*, not the grant.\n    * Layering: `observability-api.ts` is the only place that talks HTTP; `observability-parse.ts` validates every response (no blind casts); `observability-session.ts` is the agent-facing session, where every read funnels through one `#observe` so no path returns data unaudited; `observability-discovery.ts` derives field names/values from a sampled events query.\n    * Scopes fail closed to `BILLING_SCOPES`, and an omitted `resourceUrlPatterns` (\"all resource types\") is distinct from `[]` (\"none\"): a billing-only connection must not silently acquire telemetry access, and recording a wider grant than was made would make `ensureResources` short-circuit into a binding that 403s with no way to re-prompt.\n    * Three provider behaviours return wrong-but-plausible data with no error, so none is trusted (all verified against a live account): `telemetry/keys`/`values` **ignore** the `filters` they accept, so a constrained discovery call is answered from a filtered `telemetry/query` sample or it would disclose the whole account; an unknown filter key matches nothing, which bites because a log's own fields are returned nested under `source` but indexed under their *bare* name (`{event: \"x\"}` → `source.event`, queried as `event`, hence `observabilityFieldKey` accepting the `source.` alias wherever a caller names a field); and `/accounts` pages at 20 by default, so it must be walked to the end.\n    * Worker-scoped bindings prepend an immutable `$metadata.service` filter *and* re-filter the response, since a filter the provider silently ignored would leak another Worker's telemetry. A dropped event proves the filter was not applied, so the provider's own `count` is then withheld rather than reported (it would count the whole account) and the drop is logged at `error`; `statistics` is kept, since it describes what the query cost rather than how much matched. Pagination cursors come from the provider's raw events rather than the surviving ones, so a fully-foreign page can't stall pagination and hide the caller's own older data. Trace *summaries* are account-only (their shape describes the whole cross-service trace); a Worker binding can still fetch its own events for a known trace id. `calculate()` is the one read with no second line of defence — an aggregate can't be un-mixed — so it rests solely on the injected filter; that is accepted and documented on the method, with the group-by fix left as a follow-up.\n    * A provider error message can quote a caller-supplied filter value back, so only its numeric `codes` are logged — filter *values* stay out of the audit trail (`summarizeFilter`), and the message travels to the caller who caused it.\n    * Tests are two vitest projects: `vitest.config.ts` (Node, pure logic) and `vitest.worker.config.ts` (workerd, for `RpcTarget`/`RpcStub`/Durable Objects). The workerd suite reaches the gatekeeper through a `TestHooks` Durable Object because a `DurableObjectClass` carrying `ctx.props` is only reachable via `ctx.facets` — the way the overseer instantiates it.\n    * `src/configurator/*.tsx` duplicate the resource-URL grammar from `resources.ts` and **must**: `build-gatekeeper-configurator.ts` transpiles each per-file, stripping only `@gadgets/configurator-ui` and type-only imports, so they cannot import runtime helpers. `__tests__/configurator-url.test.ts` keeps the copies in step, and `configurator-fields.test.ts` drives `render` against a mocked runtime — the runtime's `clearFields` only drops an autocomplete's typed query, so a dependent field must *also* be nulled through `setValues` or the stale value silently survives into the resource URL.\n* packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix: `/api/*` and `/blueprint-screenshot/*` to the workshop backend, `/gatekeeper/<name>/*` to whichever gatekeepers are bound (discovered by scanning its own `GATEKEEPER_*` service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (`pnpm dev-server`): with no `ASSETS` binding it proxies frontend requests to the Vite dev server instead.\n\nFrontend conventions (Workshop, gatekeeper management apps, and shared UI):\n\n* IMPORTANT: Load the `frontend-conventions` skill before creating, materially changing, moving, or reviewing React frontend code anywhere under `packages/`. The bullets below are the mandatory summary; the skill contains the complete conventions and examples.\n\n* Organize code by product ownership before implementation type. Keep feature-owned components, hooks, tests, and utilities together; start directories flat and introduce responsibility-named subdirectories only when a subsystem grows.\n* Use PascalCase filenames for components and camelCase filenames for hooks and non-component modules. Colocate `*.test.ts(x)` files with their subject.\n* Keep small stateless render helpers private. Extract a component or hook when it owns meaningful state, effects, interactions, accessibility behavior, or a reusable responsibility.\n* Cross-application UI belongs in `@gadgets/ui`. An app's local `components/` directory is shared only within that app. Product-specific compositions stay with the product even when they use shared primitives.\n* Use Kumo components and semantic Kumo tokens by default. Check Kumo and `@gadgets/ui` before creating a control or interaction pattern. Do not add custom color literals, feature-local token systems, or wrappers that only restyle Kumo.\n* Consult the installed Kumo API before implementing or changing Kumo UI: run `npx @cloudflare/kumo docs <Component>` (for example, `npx @cloudflare/kumo docs Select`).\n* Tailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and typography. Custom CSS is for technical behavior Kumo and utilities cannot express.\n* Represent props that are valid only together as an object or discriminated union. Controlled values require a change callback. Name callbacks `on<Action>` and pass domain values rather than React setters or browser events.\n* Add slots, variants, DOM passthrough, imperative refs, and other customization surface only for current callers. Do not generalize based on speculative reuse.\n* Treat Effects as synchronization with external systems, not as derived-state machinery. Keep state close to its owner, calculate render data during render, clean up subscriptions, and avoid chains of Effects.\n* Prefer named arrow-function components and hooks. Do not add `useMemo` or `useCallback` without a concrete identity or performance need.\n* Preserve keyboard behavior, focus management, accessible names, and announcements when building or extracting interactions.\n* Tests should protect observable behavior, product rules, accessibility, state transitions, races, and failure paths. Do not test framework behavior or implementation details merely for coverage.\n* Prefer code that communicates intent through names and types. Comments should explain non-obvious constraints and reasons, not narrate the next line.\n\nDeployment admin settings (the `/admin` panel) follow a few conventions worth knowing when extending them:\n\n* `packages/workshop-backend/src/storage-schema/admin-settings-storage.ts` defines `AdminConfig` (its helpers live in `src/admin-config.ts`) — the deployment's \"soft\" customizations: agent instructions, banners/theme, and which gatekeeper connectors/resources are offered (plus the three-state mode for auto-provisioning gatekeepers, see `provisioning-policy.ts`). Connectors/resources default to enabled and the admin UI opts them *out*; auto-provisioning gatekeepers default to *optional*. **Authentication/authorization config (sign-in providers via `AUTH_GATEKEEPERS`, password login via `DISABLE_PASSWORD_AUTH`) is deliberately NOT here** — it stays env-var driven (`auth/config.ts`) so it can't be changed by a compromised admin session.\n* The `AdminSettings` durable object owns the authoritative `AdminConfig` and mirrors it to a single reserved KV key (`.adminConfig`, see `isReservedBlueprintKey()`), so hot-path code (connect/agent) reads it with one cheap KV get via `readAdminConfig(env)`. The DO is the only writer (`updateAdminConfig(patch)`).\n* Admin operations are exposed as an `AdminApi` capability obtained via `AuthenticatedApi.getAdminApi()` (returns null for non-admins). The `#isAdmin()` check happens once when the capability is minted, so the individual methods don't re-check.\n* `user.ts:#assertResourceEnabled()` is the single core chokepoint where disabled gatekeepers/resources are enforced before a capability is minted. Both mints call it: `getGatekeeperClassFor()` (gadget/agent code can't reach it directly) and `createResourceGatekeeper()` (reached only through the agent's `createExternalResource` tool).\n* In AI Gateway mode the deployment's models are admin settings too (`AdminConfig.modelModes`, `addedModels`, `modelSettings`, `addedProviders`, `userModelsEnabled`). `GatewayModels` in `ai-gateway.ts` is the only place that merges them with `SUGGESTED_MODELS`, and *disabled* is enforced where a model is about to run: `user.ts:getChatContext()` and `LanguageModelGatekeeper.startSession()`.\n\nWorker configs: each Worker's `cloudflare.config.ts` (`@cloudflare/config` format, built from the shared factory in `scripts/worker-config.ts`) is the source of truth. The `wrangler.jsonc` beside it is committed but generated by `pnpm configs:generate` (`scripts/generate-worker-configs.ts`) and never edited by hand, because Wrangler's new-config support cannot drive the multi-worker dev server, `wrangler types`, the vitest pools or the integration harness. Settings `@cloudflare/config` has no field for (`build`, module `rules`, KV `preview_id`, `assets.directory`) go in the config's `wrangler` export, and Durable Object migrations stay tagged in its `migrations` export, because the release manifest ships them to the deploy service verbatim. `pnpm configs:check` (part of `pnpm lint` and CI) fails on a stale or hand-written file; `pnpm dev-server` regenerates before starting.\n\nRelease pipeline (`scripts/release/`) — how customer instances get deployed:\n\n* `build-release.ts` bundles every deployable worker byte-identically (wrangler dry-run with the pinned wrangler), builds the Access-mode frontend asset build, and generates the release manifest — the contract between this repo's CI and the deploy service, produced by `manifest-lib.ts` from each package's wrangler.jsonc with account-specific values replaced by placeholders (`$ACCOUNT_ID`, `$WORKER_NAME(...)`, `$SECRET(...)`, `$PUBLIC_BASE_URL`, ...).\n* `upload-release.ts` mirrors the release to R2 content-addressed, manifest last; with `--candidate` the manifest lands under `candidates/<id>/` (invisible to the deploy service) so e2e can verify it, and `promote-release.ts` then copies it to `releases/<id>/` — publishing is that single all-or-nothing manifest copy. The copy is not isolated against concurrent promotions, so CI serializes promote runs (a GitLab resource group) and the script's newer-release guard skips candidates that a later release has already superseded.\n* The manifest is covered by a golden-file test; after an intentional manifest change, regenerate with `UPDATE_GOLDEN=1 node --test scripts/release/manifest-lib.test.ts` and review the golden diff.\n* Running the flow by hand (upload and promote need `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`):\n    * `node scripts/release/build-release.ts --out release-out` — build everything into `release-out/` (id defaults to `r<CI_PIPELINE_IID>-<sha7>` in CI, `dev-<timestamp>` locally; override with `--release-id <id>`).\n    * `node scripts/release/upload-release.ts --release release-out --candidate` — mirror to R2; omit `--candidate` to publish directly (bypasses the gate — CI never does this).\n    * `node scripts/release/promote-release.ts --release-id <id>` — copy the verified candidate's manifest into `releases/<id>/`.\n* Deploy-wizard configuration: an installable gatekeeper's user-supplied inputs default to OAuth `CLIENT_ID`/`CLIENT_SECRET` secrets; a per-package `deploy-inputs.json` overrides them, and `NO_DEFAULT_CRED_INPUTS` in `manifest-lib.ts` opts out gatekeepers that take no third-party OAuth app credentials (the wizard blocks Install on unfilled secret inputs, so a spurious default makes a gatekeeper uninstallable). Backend instance-state vars (`ADMINS`, `CLOUDFLARE_OS_DEPLOYMENT`, ...) are injected by the deploy service at PUT time, never manifest-templated.\n\nDevelopment checks:\n\n- Run `pnpm lint` before pushing; it includes script types, generated-config checks, and the workspace build.\n- `pnpm build` runs type-checks and codegen. For one package use `pnpm exec vp run -F <package> build`; most builds are tasks, not package.json scripts.\n- Use `pnpm --filter <package> test:run` while iterating and `pnpm test` for workspace verification. Cached tasks may replay results; cold-checkout tests must generate their own prerequisites on both entrypoints.\n- Cached Vite+ tasks strip undeclared environment variables. Declare output-affecting variables in `cache.env`; a path value does not fingerprint the external files it names.\n- For slow CI/container runs, keep the local watchdog defaults and explicitly choose longer limits. See [testing guidance](docs/testing.md) for commands and timeout policy.\n- Read [build-system guidance](docs/build-system.md) when changing task graphs, codegen, caching, or dev-server startup; [integration testing](docs/integration-testing.md) covers the workerd harness.\n\nIMPORTANT: This repository uses pnpm, not npm. Always use pnpm.\n\nIMPORTANT: Remember when using RPC to use promise pipelining whenever possible. Cap'n Web implements promise pipelining (similar to Cap'n Proto). This means that if an RPC returns a stub, it's not necessary to await the RPC -- the promise itself can be used in place of the stub. Also, Cap'n Web lets you use the promise for a future result (even if it isn't a stub) in the arguments for another call; the promise will be replaced with its resolution on the server side before delivering the arguments. See the Cap'n Web README.md for more details.\n\nIMPORTANT: When using React's useState(), the state value cannot be an RPC stub. At runtime, all stubs appear to be callable (because the system doesn't actually know if the stub points to a function on the server side or not). But the setter returned by useState() has different behavior if passed a function (including any callable object): it calls the function in order to get the state. In order to avoid this problem, whenever a useState() state will contain an RpcStub, it's important to wrap the stub in an object, and set the state to that object instead.\n\nIMPORTANT: RPC stubs must be disposed to prevent resource leaks on the server side. Call `stub[Symbol.dispose]()` when the stub is no longer needed (or use a `using` declaration where possible). In particular, when a React component obtains a stub in a useEffect, the cleanup function should dispose the stub.\n\nIMPORTANT: All RPC interface implementations should use the annotation `@validateRpc()` to apply capnweb-validate, which installs auto-generated runtime type validation matching the interface's TypeScript signatures. Do not write redundant validation code that duplicates the checks capnweb-validate already covers.\n\nIMPORTANT: Server-side logging uses `@gadgets/observability/logger` (frontend browser `console.*` is out of scope):\n- Define a package-owned field type and module-scoped logger with a stable dot-separated `component`\n  and, for gatekeepers, `vendorId`:\n  `const logger = createLogger<GitHubLogFields>({ component: \"gatekeeper.github\", vendorId: VENDOR_ID });`.\n- Emit concrete event names and relevant typed fields, for example:\n  `logger.warn(\"failed to notify credential expiry\", { event: \"credentials.expiry.notify.failed\", error: err });`.\n  Each call emits one indexed object; module/child fields such as `vendorId` are inherited.\n- Use immutable `logger.with(fields)` for object-owned or nearby context. Prefer module/object loggers\n  over logger parameters, and do not replace a shallow child logger with ambient context just to\n  remove a local variable.\n- For bounded operation context needed by deep helpers, independent loggers, or other observability\n  consumers, use `createObservabilityContext` from `@gadgets/observability/observability-context`.\n  Re-establish it per operation;\n  it does not cross RPC, hibernation, or restart, and requires `nodejs_als` or `nodejs_compat`.\n- Pass caught values as `error`. The helper stringifies `Error` instances and primitives, uses an\n  own string `message` for plain objects, omits `undefined`, and adds stacks to all `Error` logs.\n  Keep this normalization deliberately small; do not traverse causes or copy arbitrary properties.\n- Extend field vocabularies locally. Levels: `error` needs attention, `warn` continues best-effort,\n  `info` is notable lifecycle, and `debug` is noisy breadcrumbs. Never log secrets, prompts, headers,\n  tokens, or request/response bodies.\n- To also dispatch a failure to the optional external issue Reporter (in addition to logging it),\n  call `reportIssue(failureSite, caught, options?)` from\n  `@gadgets/observability/error-reporting`. Attach ambient fields from the package's observability\n  context and augment them with capture-site fields:\n  `reportIssue(\"overseer.catalog-fallback\", err, { handled: true, attributes: { ...obsContext.get(), gatekeeperId } });`.\n  It is a no-op when the `ERROR_REPORTER` binding is absent (local dev / deployments without an issue\n  destination). Only bounded scalars are retained as attributes; reported context obeys the same\n  no-secrets rules as log fields.\n- Usage metrics go to the optional `METRICS` Analytics Engine dataset through\n  `@gadgets/observability/metrics`, whose column layout (`metrics-schema.ts`) is also what\n  dashboards query; Workshop product events reach it through `recordAnalytics`.\n\nIMPORTANT: Frontend error reporting is a separate, opt-in path:\n- `@gadgets/error-reporting` owns the vendor-neutral browser/Worker event contract and tolerant,\n  bounded normalization. `VITE_FRONTEND_ERROR_REPORTING=true` enables trusted frontend producers\n  and their hidden source maps at build time; deployments without reporting should leave it unset.\n- The Workshop browser sends best-effort reports to the same-origin `POST /api/client-errors`\n  endpoint. The backend dispatches only when both `FRONTEND_ERROR_REPORTER` and\n  `FRONTEND_ERROR_RATE_LIMITER` are bound; otherwise the endpoint is an intentional no-op.\n- Gatekeeper management/configurator UIs run as Workshop-owned opaque-origin `srcDoc` frames. They\n  send bounded reports with `postMessage`; the host accepts them only from the known frame window\n  with origin `null`, adds host-owned surface/vendor context, and performs the same-origin POST.\n  Do not add direct cross-origin reporting from a gatekeeper Worker domain.\n- Frontend reports never convey authority. `reportedUserId` is supplied by the client and unverified\n  — the name records that it is a report, not a finding — so it is a diagnostic label only and must\n  never be read to make a decision. `pageLocation` is origin and pathname only, rebuilt by\n  `normalizePageLocation` rather than trusted from producers, because a share link's fragment is a\n  bearer capability and an `href` also retains credentials; non-`http(s)` URLs are dropped entirely.\n- Install automatic capture only in trusted first-party surfaces, never gadget/user-authored code.\n  Exception messages and stacks reach the external Reporter, so never intentionally put secrets,\n  prompts, tokens, headers, or request/response bodies in thrown errors or report metadata.\n","packages/bundled-blueprints/README.md":"# Bundled blueprints\n\nThis package holds the blueprints that ship with this repo, the gadget libraries they import, and\nthe build that turns a blueprint directory into the files the Workshop backend installs. A fresh\ndeployment installs the blueprints into BLUEPRINTS KV and BLUEPRINT_CONTENT R2 on its first `/api`\nrequest, after which they are ordinary blueprints, and promotes them as its standard output formats.\n\"Bundled\" is what this package holds; \"format\" is a curation state an admin controls at runtime --\na bundled blueprint can be taken out of the formats menu, and an unbundled one promoted into it.\n\nNothing here is deployed on its own. The Workshop backend's `scripts/build-bundled-blueprints.ts`\nimports `src/` to generate the gitignored `src/generated/bundled-blueprints.ts` it compiles the\nblueprints into, and `pnpm import:bundled-blueprint` (the same package) writes into `blueprints/`.\n\n## Layout\n\n```\nblueprints/<name>/     one bundled blueprint, committed as reviewable source\n  blueprint.json       install ID, presentation, provenance, bindings, version, revision\n  files/               the gadget's code; may contain nested directories\n    README.md\n    client.ts          import { el } from \"@gadgets/bundled-blueprints/libraries/ui/client\"\n    server.ts          import { MutationQueue } from \".../libraries/sync/server\"\n    lib/protocol.ts    the document, operation and RPC types both sides share\n  __tests__/           vitest, repo-only; never part of the blueprint\nlibraries/<name>/      a gadget library: client.ts, server.ts, src/**, __tests__/** (see libraries/README.md)\nsrc/                   the build: source and archive reader (files.ts), manifest parser\n                       (manifest.ts), module generator (generate.ts); index.ts is what the backend imports\n__tests__/             the build's own tests\n```\n\n`files/` is the gadget's code. `blueprint.json` contains its install ID, presentation, provenance,\nbindings, blueprint `version`, and bundled `revision`. The build embeds the manifest and the files,\nas built, in the generated Worker module. No binary archive is committed, and none is built.\n\nInstalling a blueprint makes a git commit of its files, as publishing one from a Workshop does, and\nstores it as a packfile. The commit has no parent and a fixed author, date and message, so it\ndepends on the files and nothing else: every deployment that installs the same files installs the\nsame commit, and a blueprint whose files have not changed is the same commit however often it is\ninstalled. A bundled blueprint's versions are not chained to one another the way a published\nblueprint's releases are: each is a commit on its own.\n\n### Libraries\n\nA blueprint may import the shared **gadget libraries** in `libraries/` (see [its\nREADME](libraries/README.md)) by this package's name and the subpath its `package.json` exports:\n`@gadgets/bundled-blueprints/libraries/<name>/client` from its client, `.../<name>/server` from its\nserver. The build resolves each to the library's entry and inlines what the entry uses into the\nshipped `client.js` / `server.js`, as it does a `lib/` module, so the blueprint stays\nself-contained and a gadget created from it carries its own copy of the library as of its\ninstantiation. A client may not import a library's server side (it would drag a Durable Object into\nthe iframe), and a library, reached by that subpath, is the one thing an import may reach outside\n`files/` for. The importer has to be TypeScript: a JavaScript module ships as written, with nothing\nat runtime to resolve the package name against, so it cannot import a library, and the build rejects\nthe attempt. The Docs, Sheets and Slides blueprints are built on the `ui` and `sync` libraries, with\ntheir own domain code in `files/`.\n\n### TypeScript sources\n\n`files/` may be written in TypeScript: `client.ts` and `server.ts` are the entry points, and\n`lib/**/*.ts` holds the modules they import (by on-disk name, `./lib/protocol.ts`). The build bundles\neach entry with its imports into the `client.js` / `server.js` the blueprint ships -- readable\nrather than minified -- so the installed gadget, and the agent that later edits it, see exactly one\nJavaScript file per side, the same as for a blueprint written in plain JavaScript. Those `lib/`\nmodules are build input only and are not stored, and `.d.ts` files are dropped. The bundled Docs,\nSheets and Slides blueprints are written this way, each with a `lib/protocol.ts` holding the types\nits client and server share (imported type-only, so nothing of it ships).\n\nEverything else under `files/` (the README, assets) passes through unchanged, and may be imported\nfor its contents where the bundler can inline it: JSON is; a stylesheet is not, and\n`import \"./styles.css\"` fails the build (a gadget carries its CSS in the module that injects it). A file a bundle inlined is still shipped, because\nonly TypeScript is build input. A blueprint is written in TypeScript or in JavaScript, not both: a\n`.js` module in a tree that holds `.ts` is rejected, since it would ship as written beside bundles\nit cannot share code with. Non-module files pass through either way.\n\n`cloudflare:workers` is the only import left for the runtime to resolve, and only on the server:\nthe client is loaded as an ES module in a sandboxed iframe with nothing to resolve a bare import\nagainst, and the server as a Durable Object whose module map holds the gadget's own files and whose\nloader supplies no other `cloudflare:` module. Everything else a blueprint imports must be a file it\nowns or a library, so `import \"yjs\"` is a build error rather than a module that goes missing inside\nthe sandbox, and tree-shaking annotations are ignored, so a `\"sideEffects\": false` in an enclosing\npackage.json cannot drop a side-effect-only import from the bundle.\n\nThe build rejects a tree that would otherwise ship something other than what was written: a\nJavaScript module in a TypeScript blueprint, a `package.json` in a TypeScript blueprint (its `browser`\nfield or `imports` map would steer the bundler's resolution), an import of one of the blueprint's\nmodules that the bundler resolved to something other than the module TypeScript resolves it to (a\n`package.json` above the blueprint steering it, or an extensionless file the bundler takes first), a\n`.ts` file outside the entry/`lib/`\nlayout, TypeScript spelled `.tsx`/`.mts`/`.cts`, declarations included (neither runtime has a loader\nfor it), a `lib/` module\nno entry imports, an import that reaches outside `files/` by any path other than a library's exported\nsubpath (a relative or absolute path into `libraries/`, a `src/` module, the package root, a bare\nspecifier some `node_modules` resolves), a library import of the wrong side or of a library that does\nnot exist, a\ndynamic `import()` or `require()` of anything but a string literal, refused before the bundler runs\n(it would leave a computed path unchecked, and expand a template literal or concatenation into every\nfile the pattern matches, wherever that reaches), a module that binds the name `require` (the scan\nreads a bare `require(...)` as the module loader, as the bundler does only while the name is\nunbound), a generated `client.js`/`server.js` that collides\nwith a file or directory already in the tree, or a shipped JavaScript module that imports a library,\nwhich only the bundle can inline. Imports are read from each module's syntax tree with the\nTypeScript compiler, so a comment or a string can neither masquerade as one nor hide one.\n\n### Type checks and tests\n\n```\npnpm exec vp run -F @gadgets/bundled-blueprints build   # the five type-check programs\npnpm --filter @gadgets/bundled-blueprints test:run      # the blueprints', libraries' and build's tests\npnpm --filter @gadgets/bundled-blueprints test:watch\n```\n\n`pnpm build` type-checks all of it, through one config per set of globals -- the sets must not see\neach other, since a Durable Object has no `document`, iframe code cannot import\n`cloudflare:workers`, and neither has Node's `fs`:\n\n| config | covers | globals |\n| --- | --- | --- |\n| `tsconfig.client.json` | every `client.ts` (blueprints and libraries) and what it imports | DOM |\n| `tsconfig.server.json` | every `server.ts` and what it imports | Workers |\n| `tsconfig.tests.json` | the blueprints' and libraries' `__tests__/` and what they import | DOM + Node |\n| `tsconfig.server-tests.json` | `__tests__/server.test.ts` and `__tests__/*.server.test.ts`, the tests of a server side | Workers + Node |\n| `tsconfig.node.json` | `src/` and `__tests__/`, the build and its tests | Node |\n\nEach follows its entries' imports, so a `lib/` or `src/` module is checked under the globals of\nwhichever side imports it, and a module both sides import under both -- which is what keeps a\nshared module honest without forcing a server-only one to compile against the DOM. A library import\nis this package referring to itself by name, which tsc resolves through the `exports` in\n`package.json` with no `paths` block, so a blueprint is checked against the real signatures it\nimports.\n\n### What the build does not check\n\nThe bundler and the type check are kept in step by refusing what would let them diverge, not by\nsupporting every way TypeScript can be written. Write a blueprint the plain way -- `.ts` modules\nunder `files/`, relative imports spelled `./name.ts` or `./lib/name.ts`, libraries by the package\nsubpath -- and stay inside these limits, which the build does not enforce:\n\n- A `/// <reference types=\"…\" />`, `lib=\"…\"` or `path=\"…\"` directive loads types the side's tsconfig\n  deliberately leaves out (`types: []`, one `lib` per side), so `Buffer` type-checks in a client and\n  ships as a free global that throws in the iframe. Do not write one: a gadget module sees exactly\n  the globals its runtime supplies, and the type check is only as honest as that isolation.\n- A tree under `BUNDLED_BLUEPRINTS_DIR` is bundled but not type-checked; the programs above cover\n  this package's `blueprints/` and `libraries/` only. Type-check such a tree in its own workspace.\n- The comment naming each inlined library module is rewritten line by line, so the bundle is the\n  same wherever it is built; a template literal whose own line spells exactly such a path would be\n  rewritten with it.\n\nUnit tests of a blueprint's `lib/` modules, or of its server, live in the blueprint's `__tests__/`\nand run under `pnpm test` (jsdom by default; a pure module's or a server's test declares\n`// @vitest-environment node`). They import the module under test by on-disk name, e.g.\n`../files/lib/protocol.ts`, and load a fixture with `readFileSync`. A test of the server side is\nnamed `server.test.ts` or `<topic>.server.test.ts`, which is what puts it under the Workers types\nrather than the DOM's; at run time it gets `cloudflare:workers` as a stub of its base classes\n(`__tests__/stubs/cloudflare-workers.ts`), so a Durable Object can be constructed over in-memory\nstorage. The blueprints the build produces are still installed and inspected inside workerd by the\nWorkshop backend's suite. Only `blueprint.json` and `files/` are read by the build, so `__tests__/`\n(and anything else beside them) is repo-only and never part of the blueprint.\n\n`blueprintId` is the install key. Never change it after deployment: the new ID would install a\nsecond blueprint while the old one remained. (TODO: the bundled IDs keep the historical `format.`\nprefix -- `format.document`, `format.spreadsheet`, `format.slides` -- for that reason; renaming them\nneeds an install-time migration keyed on the old id.) `version` is the blueprint's published content\nversion, a counter for display: the content itself is stored under the commit of its files. The\nbuild fingerprints the files and the rest of the manifest, so direct edits under `files/` reinstall\nautomatically. `revision` remains an explicit reinstall trigger and is bumped by the importer.\n\n## Editing presentation\n\nEdit `blueprint.json` and rebuild. Changes to title, description, output, or author are included in\nthe install fingerprint and do not need a `revision` bump.\n\n## Updating code\n\nBuild the blueprint in a Workshop, export it, then import the export, from `packages/workshop-backend`:\n\n```\npnpm import:bundled-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document\n```\n\nThe importer replaces `files/` and leaves anything beside `blueprint.json` and `files/` alone,\nupdates archive-owned metadata (`created`, `version`, `lastUpdated`, and `bindings`), bumps\n`revision`, rebuilds the backend's `src/generated/bundled-blueprints.ts`, and reports changed files\nand bindings. An export the build rejects is refused before it replaces\nanything. Review the resulting source diff normally.\n\nAn export is a `.gadget` archive in one of two versions, and the importer reads both. Version 1\nholds a snapshot of the blueprint's files. Version 2, which a Workshop writes for anything published\nsince blueprints became git commits, holds a git packfile, and the importer has `git` unpack it.\nOnly the files are taken from either: the bundled blueprint's commit is made from them at install,\nnot copied from the export.\n\nAn export contains the bundled JavaScript, so importing one over a TypeScript blueprint replaces its\nsources with the built `client.js` / `server.js`. Edit a TypeScript blueprint in the repo instead,\nand use the Workshop only to try the result.\n\n## Adding a format\n\n```\npnpm import:bundled-blueprint ~/Downloads/Brief.gadget --new acme-brief\n```\n\nThis extracts the files and writes a valid scaffolded `blueprint.json`. Before deploying, replace\nthe scaffold description and review `output`. Prefer a generic `output.id` such as `document`; the\nOutputs page uses it to group related formats.\n\n## Shipping your own formats\n\n`BUNDLED_BLUEPRINTS_DIR` points the build at another directory in this same extracted layout. It is\nresolved against `packages/workshop-backend`, where the build runs (the variable used to be called\n`FORMAT_BLUEPRINTS_DIR`; that name is no longer read, so a deployment setting it builds the bundled\nset instead):\n\n```\nBUNDLED_BLUEPRINTS_DIR=../../acme-formats pnpm exec vp run build\n```\n\nThe named directory replaces this set rather than extending it. It can be empty to ship no bundled\nformats. The import command honors the same variable. Keeping deployment-owned formats outside this\nrepo avoids modifying it when it is consumed as a submodule.\n\nA `BUNDLED_BLUEPRINTS_DIR` tree may be written in TypeScript like the blueprints here, and the build\nbundles it the same way: a syntax error or an import that does not resolve fails the build, and it\nimports the gadget libraries by the same package name, which the build resolves to this package's\n`libraries/` without the tree having installed anything. It is not type-checked, though. This\npackage's `tsconfig.*.json` programs are static and cover only `blueprints/` and `libraries/`, so a\ntree elsewhere needs its own `tsc` run in the repository that owns it, with\n`@gadgets/bundled-blueprints` linked into that workspace the way `@gadgets/scripts` already must\nbe, so its editor and `tsc` resolve the same `exports`; or it can stay JavaScript.\n\nDirectories using the previous `<name>.gadget` plus `<name>.json` layout remain supported, so an\nexisting deployment can update this repo without coordinating a format conversion. Importing a new\nexport into one of those entries migrates that pair to the extracted layout automatically. That is\nalso the only way to update one: the layout holds version 1 archives, and the build refuses a\nversion 2 export dropped in a `<name>.gadget`'s place.\n\nAdministrators can also publish and promote ordinary blueprints at runtime instead of rebuilding a\ndeployment.\n","packages/bundled-blueprints/blueprints/workspace-docs/files/README.md":"# Docs — real-time collaborative rich-text Gadget\n\nA single-document, Google-Docs-style rich-text editor with Durable Object persistence, live block updates, collaborator presence, and conflict-safe concurrent editing.\n\n## Shared libraries\n\nThis blueprint is built on the shared gadget libraries in `packages/bundled-blueprints/libraries`\n(see that directory's README). The build inlines what it imports into the `client.js` and `server.js` it ships,\nso a gadget created from it carries its own copy of the libraries as of its creation.\n\n- `@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the `el` element builder, the shared icon table, the\n  toolbar's buttons, groups, colour pickers and dropdown, the in-page prompt that stands in for the\n  sandbox's blocked `window.prompt`, the save-status dot, and the reading and downscaling of a\n  picked, dropped or pasted image.\n- `@gadgets/bundled-blueprints/libraries/sync/client` runs the browser's half of the collaboration loop: the debounced,\n  serialized, retrying save scheduler, the presence roster and its throttled reporter, and the\n  `RpcTarget` the server calls back.\n- `@gadgets/bundled-blueprints/libraries/sync/server` runs the object's half: the mutation queue, the subscriber registry with its\n  presence announcements, and the per-block optimistic concurrency.\n\nEverything on top of them is this gadget's own: the editing commands, the paste sanitizer, the block\nmodel and its ordering rule, where a remote caret is drawn, and the export formats.\n\nIn the repository the source is TypeScript under `blueprints/workspace-docs/files/` (`client.ts`,\n`server.ts`, the shared `lib/protocol.ts` contract and the `lib/markdown.ts` export), which the build\nbundles into the `client.js` and `server.js` this gadget runs.\n\n## Architecture\n\n- **server.js** is the authoritative collaboration coordinator. It stores one atomic `document:v2` snapshot containing the title, global revision, ordered blocks, per-block versions, and modification time.\n- **client.js** builds the entire UI around a `contenteditable` surface. Every top-level document element has a stable `data-block-id`. Local typing is optimistic and changed blocks are sent after a short ~220 ms debounce.\n- Clients subscribe with a Cap'n Web `RpcTarget`. The server broadcasts accepted block operations and ephemeral presence events to every connected client.\n\n## Real-time data model\n\nThe persisted document resembles:\n\n```js\n{\n  revision: 42,\n  title: \"Project plan\",\n  blocks: [\n    { id: \"b_…\", html: \"<h1 data-block-id=\\\"b_…\\\">Project plan</h1>\", version: 3 },\n    { id: \"b_…\", html: \"<p data-block-id=\\\"b_…\\\">Draft…</p>\", version: 8 }\n  ],\n  lastModified: 1700000000000\n}\n```\n\nClients send compact operation batches containing only changed/upserted blocks, version-checked deletions, the current block order, and title. The Durable Object:\n\n1. checks each changed block's `baseVersion`;\n2. accepts non-conflicting changes;\n3. increments accepted block versions and the document revision;\n4. persists the resulting authoritative snapshot; and\n5. broadcasts only the accepted operation.\n\nA remote operation patches only affected DOM blocks. The whole editor is not replaced, so typing and selection in unrelated paragraphs remain stable. Ordering is last-writer-wins, while content and deletion changes use per-block optimistic concurrency.\n\n### Same-block concurrency\n\nA remote update to the block currently being edited is queued rather than replacing the user's caret and draft. If the local block is clean on blur, the remote version is applied. If it is locally dirty, the draft is rebased onto the latest server block version and saved as the next version. Server conflicts return the authoritative block/version, and the client automatically retries its visible local draft—no local text is silently discarded.\n\nThis is block-granularity collaboration rather than a character-level CRDT. It provides smooth simultaneous editing across paragraphs with much less complexity. A CRDT/OT layer would still be required for merged character-by-character edits by multiple people inside the exact same paragraph.\n\n## Presence\n\nSelection changes are throttled to ~70 ms and sent as ephemeral presence messages containing collaborator ID, name, color, and anchor/focus block-and-text offsets. Presence is never persisted. A collaborator appears as a slim named caret; selected text is shown with a subtle translucent highlight, including selections spanning multiple blocks. Both are rendered in a fixed overlay without modifying document HTML. There are no header avatars or active-block outlines. Page close uses a best-effort leave message, RPC disconnects broadcast leave events, and client heartbeats expire stale cursors when browsers do not complete unload networking.\n\nRemote carets and selection highlights live in a separate overlay, so presence decoration never enters the editable DOM or persisted document HTML. Serialization also strips transient local image-selection decoration.\n\n## Programmatic population and backward compatibility\n\nAgents and importers should call `setDocument({ title, blocks, senderId })` to populate or replace the document atomically. It works whether the editor has already been opened or not, persists the full snapshot, increments the revision, and broadcasts the result to connected clients. This avoids requiring callers to choose between initialization and incremental operations.\n\n`initializeBlocks()` remains the idempotent bootstrap/migration API. It also handles the startup race where a newly opened browser creates an empty revision-1 shell just before generated content arrives: that exact untouched shell can be replaced by a non-empty initialization payload. It will not overwrite later empty documents, which may represent an intentional user edit.\n\nIf only the former `content`, `title`, and `lastModified` KV keys exist, the first v2 client loads that HTML, normalizes its top-level nodes, assigns stable block IDs, and calls `initializeBlocks()`. Existing documents therefore migrate without data loss.\n\n## Formatting and media\n\nThe toolbar supports paragraph styles, font family/size, bold, italic, underline, strikethrough, text/highlight color, alignment, lists, indentation, links, images, horizontal rules, clear formatting, and undo/redo. Font sizes are in points, as in Google Docs and Word: body text is 12 pt, in the editor and in the HTML and PDF exports alike, so a sized run keeps its proportion to the text around it.\n\nPasted Google Docs/Word HTML is rebuilt into clean semantic markup. Images from file selection, drag/drop, and clipboard are downscaled and embedded as data URLs. Because collaboration transmits changed blocks, an embedded image is no longer resent when unrelated paragraphs change. A future asset store could replace data URLs with image IDs if very large media-heavy documents are needed.\n\n## Implementation notes\n\n- Durable Object storage is the source of truth; in-memory subscriber and presence maps are intentionally ephemeral.\n- Structural DOM normalization handles root text and `<br>` nodes and repairs duplicate/missing block IDs.\n- Remote ordering moves only blocks that are out of place, avoiding unnecessary selection disruption.\n- `execCommand` is deprecated but remains the compact, broadly compatible formatting mechanism for this sandboxed editor.\n\n## Export formats\n\n- **Markdown** exports the persisted rich-text blocks as Markdown, preserving headings, bold,\n  italic, strikethrough (whether written as tags or as the inline styles the toolbar applies, with\n  an inline style overriding its tag and its ancestors, as on the page), links, images, lists,\n  block quotes, code, horizontal rules and line breaks. A paragraph whose emphasis changes inside\n  a word, where Markdown's asterisks could pair differently than written, writes its emphasis as\n  inline `<strong>`, `<em>` and `<del>` tags instead. Markdown has no syntax for underline,\n  colour, size, font or alignment, so those runs export as plain text. An image exports with its\n  `src`, which for an embedded image is its whole data URL.\n- **HTML** and **PDF** use the editor's print layout and omit editing chrome and presence UI.\n","packages/bundled-blueprints/blueprints/workspace-sheets/files/README.md":"# Sheets\n\nA lightweight, persistent spreadsheet Gadget with a familiar grid interface, formulas, formatting, multiple sheets, and live synchronization.\n\n## Features\n\n- Editable spreadsheet title and multiple sheet tabs\n- Add, rename, duplicate, and delete sheets\n- Cell and range selection with keyboard navigation\n- Formula bar and A1-style references\n- Cross-sheet references such as `Sheet2!A1` and `'Sales Data'!B4`\n- 100+ spreadsheet functions across math, statistics, logic, text, lookup, date/time, and information categories\n- Number, currency, percent, scientific, date, and time formatting\n- Bold, italic, underline, strikethrough, text color, fill color, alignment, and wrapping\n- Row and column insertion, deletion, and resizing\n- Range sorting, AutoSum, copy/paste via TSV, and local undo/redo for cell edits\n- Automatic persistent saving with optimistic per-cell conflict detection\n- Real-time operation and presence synchronization in the server architecture\n- Excel workbook and per-sheet CSV export\n\n## Using the spreadsheet\n\n- Click a cell to select it.\n- Double-click a cell, press **F2**, or begin typing to edit it.\n- Start formulas with `=`, for example:\n  - `=SUM(A1:A10)`\n  - `=IF(B2>100,\"High\",\"Low\")`\n  - `=VLOOKUP(E2,A2:C20,3,FALSE)`\n  - `=Sheet2!A1*2`\n- Use the name box to jump to a cell or range such as `D12` or `A1:C8`.\n- Right-click the grid for cut/copy/paste and row or column actions.\n- Double-click a sheet tab to rename it; right-click it to duplicate or delete it.\n\n## Keyboard shortcuts\n\n| Shortcut | Action |\n| --- | --- |\n| Arrow keys | Move the active cell |\n| Shift + Arrow | Extend the selection |\n| Enter / F2 | Edit the active cell |\n| Tab / Shift + Tab | Move right / left |\n| Delete / Backspace | Clear selected cells |\n| Ctrl/Cmd + C, X, V | Copy, cut, paste |\n| Ctrl/Cmd + Z | Undo |\n| Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z | Redo |\n| Ctrl/Cmd + B | Bold |\n| Ctrl/Cmd + I | Italic |\n| Ctrl/Cmd + U | Underline |\n| Ctrl/Cmd + A | Select the whole sheet |\n| Ctrl/Cmd + Arrow | Jump across populated or empty regions |\n| Alt + Enter | Insert a line break while editing |\n\n## Programmatic population\n\nThe spreadsheet can be read and populated through the Gadget's server RPC methods. This is useful for scripts, agents, migrations, or seeding a workbook without using the grid UI.\n\n### Read the workbook\n\nCall `getDocument()` to retrieve the complete persisted workbook:\n\n```js\nconst doc = await gadget.getDocument();\n```\n\nThe returned object contains `revision`, `title`, `sheetOrder`, `sheets`, `cells`, and `lastModified`. Cells are grouped by sheet ID and keyed by A1 reference:\n\n```js\nconst firstSheetId = doc.sheetOrder[0];\nconst a1 = doc.cells[firstSheetId].A1;\n// { value: \"Revenue\", fmt: { b: true }, version: 1 }\n```\n\n### Write cells\n\nUse `applyOperation()` with per-cell operations. For a new cell, use `baseVersion: 0`; when replacing or deleting an existing cell, use its current `version` from `getDocument()`.\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\nconst cells = doc.cells[sheetId];\n\nconst result = await gadget.applyOperation({\n  senderId: \"seed-script\",\n  cellOps: [\n    {\n      sheetId,\n      ref: \"A1\",\n      value: \"Revenue\",\n      fmt: { b: true, bg: \"#fff3a3\" },\n      baseVersion: cells.A1?.version ?? 0,\n    },\n    {\n      sheetId,\n      ref: \"B1\",\n      value: \"=SUM(B2:B10)\",\n      fmt: { nf: \"currency\", d: 2 },\n      baseVersion: cells.B1?.version ?? 0,\n    },\n  ],\n});\n```\n\nA cell's `value` is always stored as text. Begin a value with `=` to create a formula. To delete a cell completely, send both `value: null` and `fmt: null` with the current `baseVersion`.\n\nCheck `result.status` after writing. It is `applied`, `unchanged`, or `conflict`. On a conflict, inspect `result.conflicts`, re-read the document, and retry against the latest cell versions rather than overwriting blindly.\n\n### Replace a whole sheet\n\nFor bulk imports, `sheetReplacements` is more efficient than sending many individual cell operations:\n\n```js\nawait gadget.applyOperation({\n  senderId: \"csv-import\",\n  sheetReplacements: [{\n    sheetId,\n    cells: {\n      A1: { value: \"Name\", fmt: { b: true }, version: 1 },\n      B1: { value: \"Total\", fmt: { b: true }, version: 1 },\n      A2: { value: \"Example\", fmt: null, version: 1 },\n      B2: { value: \"1250\", fmt: { nf: \"currency\", d: 2 }, version: 1 },\n    },\n  }],\n});\n```\n\nA whole-sheet replacement overwrites that sheet's complete cell map, so merge with existing data first if it must be preserved. It does not use per-cell conflict checks.\n\n### Change workbook structure\n\nPass a complete structure snapshot to change the title, sheet order, names, dimensions, or sizing metadata. Because structure uses last-writer-wins semantics, start with the latest document and preserve every sheet that should remain:\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\n\nawait gadget.applyOperation({\n  senderId: \"setup-script\",\n  structure: {\n    title: \"Quarterly plan\",\n    sheetOrder: doc.sheetOrder,\n    sheets: {\n      ...doc.sheets,\n      [sheetId]: {\n        ...doc.sheets[sheetId],\n        name: \"Summary\",\n        colWidths: { ...doc.sheets[sheetId].colWidths, 0: 180 },\n      },\n    },\n  },\n});\n```\n\nOmitting an existing sheet from `sheetOrder` deletes it and its stored cells. Adding a new sheet ID creates empty storage for it. When adding a sheet and its initial data together, include both `structure` and a matching `sheetReplacements` entry in the same operation.\n\nFormatting keys accepted by the server are `b` (bold), `i` (italic), `u` (underline), `s` (strikethrough), `c` (text color), `bg` (fill color), `a` (`l`, `c`, or `r` alignment), `nf` (number format), `d` (decimal places), `fs` (font size), and `wrap`. Colors must be hexadecimal strings such as `#1d1d20`.\n\n## Architecture\n\nBoth sides are built on the shared gadget libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the `client.js` and `server.js` it ships.\n\n`@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the element builder, the shared\ntoolbar icons and controls (icon and segment buttons, groups, colour pickers, the dropdown behind the\nnumber-format menu), the in-page prompt that stands in for the `window.prompt` the sandbox blocks,\nand the save-status dot. `@gadgets/bundled-blueprints/libraries/sync/client` and `.../sync/server` are the\ncollaboration loop: the debounced, serialized, retrying save scheduler, the presence roster and\nheartbeat, the subscriber object the server calls back on, and in the Durable Object the mutation\nqueue and the subscriber registry with its presence announcements (whose broadcasts are never\nawaited, so a callback may re-enter the queue). `@gadgets/bundled-blueprints/libraries/zip/server`\nprovides the streaming ZIP writer used by XLSX export. The cell model, the formula engine, the grid,\nsheet tabs and the export-specific code are this gadget's own.\n\nIn the repository the source is TypeScript under `blueprints/workspace-sheets/files/`\n(`client.ts`, `server.ts`, `lib/protocol.ts`, `lib/formula.ts`, `lib/xlsx.ts`), which the build bundles\ninto the `client.js` and `server.js` shipped here.\n\n### `client.js`\n\nBuilds the entire browser interface in JavaScript. It contains:\n\n- Grid rendering and selection behavior\n- Cell editing, formatting, sorting, and structural operations\n- Formula tokenization, parsing, evaluation, and display formatting\n- Clipboard and keyboard support\n- A local model whose saves are queued per cell and flushed by the library's scheduler\n- RPC callbacks for live server operations and presence events\n\nFormula evaluation happens in the browser. The engine caches computed cells, detects circular references, supports ranges and cross-sheet references, and displays standard errors including `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#N/A`, `#NUM!`, and `#CYCLE!`.\n\n### `server.js`\n\nExports the Durable Object class `Gadget`, which is the authoritative persistence and synchronization layer. It:\n\n- Stores spreadsheet metadata and each sheet's cells in Durable Object storage\n- Serializes mutations and document snapshots through the library's mutation queue\n- Applies per-cell optimistic concurrency using cell versions\n- Uses last-writer-wins semantics for document structure\n- Broadcasts operations and presence events through the library's subscriber registry after the queue releases, best-effort and without awaiting them, so a callback may itself read or write the document and a hung subscriber holds up only its own client\n- Sanitizes titles, dimensions, cell contents, references, and formatting\n- Advertises and produces the server-side workbook and CSV exports\n\n### XLSX and ZIP modules (bundled into `server.js`)\n\nThe XLSX module converts a complete document snapshot into a streaming XLSX workbook: sparse\nworksheet XML with inline strings, deduplicated styles, and frozen panes. The ZIP module is a\ndependency-free streaming ZIP writer (raw DEFLATE via `CompressionStream`, incremental CRC32, data\ndescriptors). Both are bundled into `server.js`.\n\n## Storage model\n\nThe server stores:\n\n- `meta`: revision, title, sheet order, sheet metadata, and modification time\n- `cells:<sheetId>`: a map of A1 references to `{ value, fmt, version }`\n\nDefault sheets have 100 rows and 26 columns. Server validation permits up to 50,000 rows and 702 columns per sheet. Cell values are limited to 8,192 characters, and titles are limited to 200 characters.\n\n## Collaboration notes\n\nThe server and client synchronization code support multiple connected clients and live updates. Each\ntab introduces itself with the guest name and colour the sync library derives from its client ID, and\nreports its selected range on the library's throttle and heartbeat. Remote collaborator badges and\nselection overlays are currently disabled in the UI, so the app presents as a single-user spreadsheet\neven though remote operations still synchronize.\n\n## Current limitations\n\n- There is no file import workflow; clipboard operations use tab-separated text.\n- Structural edits and sorting clear local undo/redo history.\n- Formula reference adjustment during row/column changes is limited to references on the current sheet.\n- Formula support is broad but is not intended to be fully compatible with Excel or Google Sheets.\n- Frozen row/column metadata exists in the model, but the current UI does not expose controls for it.\n\n## CSV export\n\nUp to 31 worksheets are exposed as individual **CSV** export options, leaving one of the platform's\n32 format slots for XLSX. CSV files contain the stored cell values through the worksheet's used\nrange. Formula cells are exported as their raw formulas (for example, `=SUM(A1:A10)`), not as\nbrowser-computed display values. Fields use standard CSV quoting and CRLF line endings.\n\n## XLSX export\n\n**Excel Workbook** exports one XLSX file with the worksheets in workbook order. Cells keep their\nfont, color, fill, alignment, number format, decimal places and wrapping; sheets keep column widths,\nrow heights and frozen panes. Pixel sizes are converted to points (rows, fonts) or approximate\ncharacter widths (columns), so layout is close but not pixel-identical.\n\nCell values follow the grid's own literal rules: a leading apostrophe forces text, a leading `=` is\na formula (whatever the number format), `TRUE`/`FALSE` are booleans, and numbers may carry a sign,\ncommas, a leading `$` or a trailing `%`. Everything else is text; date-looking text is not parsed.\n\nFormulas are written without cached results and the workbook requests a full recalculation on open,\nso Excel evaluates them itself. To keep them valid there, the exporter rewrites cross-sheet\nreferences to the exported worksheet names, prefixes OOXML \"future functions\" (`IFS`, `CONCAT`, ...)\nwith `_xlfn.`, renames `ERRORTYPE()` to `ERROR.TYPE()`, and drops whitespace between a function\nname and its `(`. A formula that is empty, structurally unbalanced (unterminated string or quoted\nname, mismatched parentheses or brackets — the grid's parser tolerates these) or that would exceed\nExcel's 8,192-character limit after rewriting is exported as text, since one such formula makes\nExcel report the whole workbook as damaged. Formula semantics are otherwise not translated (for\nexample `^` associativity differs, and a reference outside the grid such as `XFE1` is empty here\nbut `#NAME?` in Excel), and compatibility with Excel is not claimed beyond this.\n\nWorksheet names are made Excel-safe: invalid characters become `_`, blank names become `Sheet`,\nnames are cut to 31 characters, and case-insensitive collisions get ` (2)`, ` (3)`, ... suffixes.\nReferences resolve to the first worksheet with a matching source name, as in the grid.\n\nFilters, charts, comments and pivot tables are not exported; cells already materialized from them\nexport as ordinary values.\n","packages/bundled-blueprints/blueprints/workspace-slides/files/README.md":"# Workspace Slides\n\nA composable, multiplayer slide deck builder. The deck itself is data —\nslides are arrays of typed blocks — and the editor lets anyone add, remove,\nreorder, and edit slides in real time. The visual language (tokens,\ncomponents, typography) is shared across every block. Edit mode uses light,\nneutral sidebars so the slide canvas remains the visual focus.\n\n## Files\n\n- `server.js` — Durable Object that owns the deck document and broadcasts\n  every mutation to connected viewers. The server is a generic store; it\n  doesn't know what any component does.\n- `client.js` — Design tokens, the component registry, slide renderer,\n  block interactions (drag, resize, inline text edit), and the builder\n  shell (slide list, inspector, palette, control bar, present mode).\n\nIn the repository these two modules are TypeScript (`client.ts`, `server.ts`\nand the shared `lib/protocol.ts` types under\n`blueprints/workspace-slides/files/`), which the build bundles into the\n`client.js` and `server.js` installed here.\n\n## Gadget libraries\n\nThree pieces of this deck are not its own: the element builder, the two\nsteps that read an uploaded image, and the object the Durable Object calls\nback. They are shared with the other document-style gadgets and are\nimported from the libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the two files installed here:\n\n```ts\nimport { el, loadImage, readFileAsDataURL } from \"@gadgets/bundled-blueprints/libraries/ui/client\";\nimport { createSubscriber } from \"@gadgets/bundled-blueprints/libraries/sync/client\"; // client.ts\nimport { SubscriberRegistry } from \"@gadgets/bundled-blueprints/libraries/sync/server\"; // server.ts\n```\n\n- **`ui`** — `el(tag, props, children)` builds every element in\n  `client.js`; `readFileAsDataURL` and `loadImage` are the two halves of\n  `fileToImageDataURL`, whose encoding rule (SVG verbatim, PNG kept as PNG,\n  the original on any canvas failure) stays here because it is the deck's.\n  The library's own icons, toolbar controls and prompt are for the gadgets\n  with a formatting bar; this deck draws its own `ICONS` as complete SVGs\n  and has no toolbar of that shape, so it imports none of them.\n- **`sync`** — `createSubscriber(RpcTarget, { deckChanged })` builds the\n  callback target (the RPC layer only calls prototype methods, which is\n  what the library gets right), and `SubscriberRegistry` holds the\n  connected browsers in the Durable Object. A deck has no presence — every\n  viewer sees the same slides and no cursors are shared — so the registry\n  is built with no presence hooks and is a plain fan-out.\n\nEverything else here — the tokens, the `COMPONENTS` registry, the layout and\nsnapping code, the deck document and its undo history — is this blueprint's.\n\n## Slide formats\n\nThe deck is built in a clean, **branded corporate style** (an orange accent\npalette on white slides) and supports two ready-made slide formats. Both live on the fixed\n`1200 × 675` reference canvas and are made entirely of normal editable\nblocks, so anything a template drops in can be moved, restyled, or deleted\nafterward.\n\n### 1. Title / cover slide (`makeTitleSlide()` in `client.js`)\n\nThe orange opening slide.\n\n- Full-bleed orange cover artwork (`background.coverOrange`).\n- White brand **logo** (a clean editable wordmark with an optional accent\n  dot) at `x 36, y 56`, width `267`.\n- **Title** — Inter, white, ~58px, weight 700, at `x 33, y 197`, width\n  `687`. Kept in the left portion of the slide, max three lines.\n- **Subtitle** — Inter, white, ~17px, weight 600, at `x 36, y 533`, width\n  `553`, max two lines. Delete the block if no subtitle is needed.\n\nThis is also what the seed deck's first slide and the server's\n`newBlankSlide()` produce.\n\n### 2. Basic content slide (`makeContentSlide()` in `client.js`)\n\nThe standard white content shell for every non-cover idea.\n\n- Pure white background, no inset surface, no shadow, no border.\n- **Eyebrow** (`sectionLabel`) — Ruby `#FF6633`, uppercase, SemiBold, at\n  `x 36, y 35`. Optional; delete when not useful. Eyebrows are always Ruby.\n- **Headline** (`title`) — black `#000000`, ~28px, weight 600, at\n  `x 35, y 76`, width `984`, max two lines, written as an assertion.\n- **Logo** — the brand wordmark (the `logo` component in its `dark`\n  variant: black wordmark + orange accent dot), at `x 1013, y 40`, sized\n  via `scale: 0.62`.\n- **Content region** — body starts at `x 36, y 204`. The default narrative\n  uses a constrained `760px` reading measure at 19px, weight 400,\n  line-height 1.6. The add-slide controls also provide exact-grid\n  **2-column** (`x 36/609`, `w 553`) and **4-column**\n  (`x 34/328/622/916`, `w 260`) compliant structures.\n- **Bottom brand bar** — full-width 12px Tangerine `shape` at `y 663`, touching the bottom edge.\n\n### Creating slides in either format\n\n- **In the editor:** enter edit mode (**E**). The left panel footer has\n  two buttons — **Title** and **Content** — that insert the matching\n  template after the current slide. Each calls\n  `addSlide(makeTitleSlide())` / `addSlide(makeContentSlide())`.\n- **Programmatically (over the `GADGET` binding):** call\n  `addSlide(atIndex, slideObject)` with a `{ background, blocks }` object.\n  The block/prop shapes in `makeTitleSlide` / `makeContentSlide` are the\n  canonical reference for the two styles; the server mints block ids on\n  insert.\n\nBrand style tokens worth reusing: Ruby `#FF6633`, Tangerine `#F6821F`,\nMango `#FBAD41`, black `#000000`, muted gray `#747474`, Inter throughout.\nSlide-facing component controls are intentionally limited to this palette\nand the approved 400/500/600/700 weights. Cards and diagram boxes default\nto flat white surfaces, thin `#E5E5E5` rules, minimal corner radii, and no\nshadows. Generic colored tones and the pill component are not offered in\nthe component library. The `bulletList` component implements the corporate\nhanging-indent treatment: one item per source line, 6px Tangerine dots,\n12px dot-to-copy gap, 19px primary or 17px compact Inter Regular text, and\ncontrolled 10px/8px item spacing.\n\n## Initial blueprint\n\nNew Gadget instances start from the current four-slide overview of the\nbuilder: the orange “Compose your deck or build with agent” cover, a six-part\nfeature overview, a connected-charts example showing how an agent can use an\napproved internal system of record as a data source, and a two-path get-started\nslide for editing directly or asking the agent. The complete blueprint\nis defined by `INITIAL_DECK` plus `KEY_TAKEAWAYS_SLIDE` in `server.js`; both\nfirst-time initialization and `resetAll()` clone that blueprint. The immutable default\nobjects in `server.js` are cloned whenever storage is first seeded and by\n`resetAll()`, so later edits cannot mutate the blueprint.\n\n## Data model\n\nThe deck is one JSON document:\n\n```js\n{\n  slides: [\n    {\n      id: \"abcd1234\",\n      background: { color: \"#f5f1eb\", inset: true, dotGrid: 0.45 },\n      blocks: [\n        { id: \"...\", type: \"title\", x: 48, y: 78, w: 1040,\n          props: { text: \"Hello\", fontSize: 46, highlight: \"Hello\" } },\n        ...\n      ],\n    },\n    ...\n  ],\n}\n```\n\nEvery block is `{ id, type, x, y, w?, h?, props }`. Position is in the\nfixed `1200 × 675` slide coordinate system; the stage scales uniformly to\nfit the viewport.\n\n## Component registry\n\n`COMPONENTS` in `client.js` is the catalog. Each entry declares:\n\n| field            | purpose                                                 |\n| ---------------- | ------------------------------------------------------- |\n| `name`           | Display name (palette + inspector header).              |\n| `defaultBlock()` | Returns `{ x, y, w?, h?, props }` for a freshly added block. |\n| `fields`         | Inspector schema (`text`, `multiline`, `number`, `select`, `checkbox`, `color`, `image`, `svg`). Set `advanced: true` on a field to tuck it under the inspector's \"Advanced\" disclosure (along with position/size). |\n| `resizableW/H`   | Whether the corner handle adjusts that dimension.       |\n| `fullBleed`      | If true (arrow), the block ignores wrapper position and renders an absolutely-positioned overlay. |\n| `render(props, ctx)` | Returns the DOM. `ctx.inlineText(elem, propKey, transform?)` wires up contentEditable in edit mode. |\n\nComponents exposed in the palette (generic, reusable):\n\n`title`, `subtitle`, `text`, `sectionLabel`, `card`, `box`, `tonePill`,\n`image`, `svg`, `divider`, `shape`, `arrow`.\n\nComponents defined in `COMPONENTS` but intentionally **not** in the\npalette — they're brand marks used by the seed deck only:\n\n`gadgetsMark`, `logo`. The `logo` component renders a clean editable\nwordmark (`text` prop, default `Workspace`) with an optional orange accent\ndot (`accentDot` prop); change the wordmark to rebrand the deck. They still\nrender anywhere a block of that type appears; they just aren't offered as\nbuilding blocks.\n\n### Adding a new component\n\n1. Add a new entry to `COMPONENTS` with a `render`, `defaultBlock`, and\n   `fields`. Use `ctx.inlineText(elem, \"propKey\")` for any text the user\n   should be able to edit directly in the slide.\n2. Add the type to `PALETTE_ORDER` so it shows up in the library.\n3. Add the type to one of the `COMPONENT_CATEGORIES` groups, and add a\n   short description to `COMP_DESC` plus a 14×14 currentColor SVG icon\n   to `COMP_ICON`. (The library renders the icon + name + description as\n   one row per component.)\n\nThat's it — no other code needs to change. The inspector is auto-generated\nfrom `fields`, drag/resize/selection all work automatically.\n\n## Editing\n\nPress **E** (or click the pencil) to enter edit mode. Edit mode reveals:\n\n- **Left panel** — a light, neutral sidebar with a **slide search** (matches\n  any string value in any block's props), a scrollable thumbnail list,\n  and a split **Add slide** control. The main action inserts the standard\n  narrative slide; its caret menu offers title, narrative, 2-column, and\n  4-column layouts (see \"Slide formats\" above). Each row\n  shows its slide\n  number, the thumbnail, and a one-line label derived from the slide's\n  first title (or subtitle / text). Click to jump, drag to reorder\n  (disabled while filtering), hover to reveal duplicate/delete. Press\n  Enter in the search box to jump to the first match; Escape clears.\n- **Right panel** — three stacked sections, in order:\n  1. **Component library** — a contained, filterable card. The search\n     input doubles as the section title; type to filter (matches on\n     name, description, and type id), Enter inserts the first match,\n     Escape clears. Without a query, components are grouped by category\n     (Text / Containers / Decoration / Diagram); with a query, results\n     are flat. The list has its own internal scroll so the library\n     never grows past ~280px tall.\n  2. **Slide** — compact background settings for the current slide\n     (page color, dot grid opacity, inset toggle).\n  3. **Selected block inspector** — icon + name header, schema fields\n     (label-on-the-left rows, multiline + color stack vertically),\n     a 4-button icon row (duplicate, bring forward, send backward,\n     delete), and an \"Advanced\" disclosure that holds position/size\n     and any fields tagged `advanced: true`. When nothing is selected\n     this is replaced by a quiet \"Nothing selected\" hint.\n- **Stage** — every block is selectable (hover dashes, click to select);\n  drag the body to move, the bottom-right handle to resize. Text inside a\n  block is contentEditable; press Enter or click away to save. While\n  dragging, the block softly snaps when its left / right / center edges\n  line up with another block's edges or center, or with the slide bounds\n  / center; a thin orange guide line shows which alignment is locked.\n  Hold **Alt / Option** while dragging to disable snapping entirely.\n\nPress **F** to enter presentation mode (chrome hidden, stage fills the\nviewport, no rounded corners). **Esc** or **F** exits.\n\n## Realtime\n\nEvery mutation is sent through RPC to the Durable Object and re-broadcast\nas a full deck snapshot via the subscriber callback. The callback signature\nis `deckChanged(deck, meta)` where `meta = { canUndo, canRedo }` reflects\nthe current server-side history state. The client preserves the currently-\nselected slide & block across updates when possible.\n\nThe registry behind `#broadcast` isolates subscribers from each other: one\nwhose call fails is dropped and its stub released rather than failing the\nmutation being broadcast, and so is one whose connection breaks.\n\n## Undo / redo\n\nThe undo stack lives on the server (in `Gadget`, in memory only — lost on\nDO restart, capped at `MAX_UNDO` entries). It's **global**: there's one\nshared history for the deck, and pressing undo rolls back the most recent\nmutation made by any connected client. Every call to `#save` snapshots\nthe previous deck onto the undo stack and clears the redo stack;\n`undo()` / `redo()` bypass `#save` (so they don't recurse) and broadcast\nthe restored deck through `#broadcast`. Clients learn whether undo/redo\nare currently available from the `meta` arg piggy-backed on every\nbroadcast, plus an initial `getUndoState()` call at boot.\n\n## Keyboard\n\n- `←` / `→` / `Space` / `PageUp` / `PageDown` — navigate slides\n- `Home` / `End` — first / last slide\n- `E` — toggle edit mode\n- `F` — toggle present mode\n- `Delete` / `Backspace` (edit mode) — delete the selected block\n- `Ctrl/Cmd + C` / `X` / `V` (edit mode) — copy / cut / paste the selected\n  block. The clipboard is in-memory (per tab); paste inserts onto the\n  *current* slide with a small position offset so the new block doesn't\n  sit perfectly on top of its source, and the new block becomes selected.\n- `Ctrl/Cmd + D` (edit mode) — duplicate the selected block in place\n  (same as the inspector's duplicate icon, doesn't touch the clipboard).\n- `Ctrl/Cmd + Z` — undo; `Ctrl/Cmd + Shift + Z` (or `Ctrl + Y`) — redo.\n  Also available as two icon buttons in the floating control bar (only\n  shown while in edit mode; the keyboard shortcuts work anywhere).\n- `Esc` — close jump menu, then deselect, then exit present mode\n\n## Motion\n\nAll UI motion uses a single `--ease` token\n(`cubic-bezier(0.23, 1, 0.32, 1)` — a strong ease-out) and a small\n`--ease-in-out`. Press feedback on buttons is handled with two CSS\nselectors registered in `mountShell`:\n\n- `[data-press]:active { transform: scale(0.97); }` — generic buttons.\n- `[data-icon-btn]:active { transform: scale(0.92); }` — pill icon\n  buttons in the control bar.\n\nAdd either attribute to a button to get the right press feel for free.\nAvoid `transition: all`; always specify the exact properties so unrelated\nproperty changes don't accidentally animate.\n\n## Inspector form controls\n\nAll inspector inputs (text, number, select, textarea, checkbox, color)\nshare a small set of CSS classes defined in `mountShell`:\n\n- `.field-input` — the base look (subtle dark fill, transparent border,\n  orange ring on focus). Applied to `<input>`, `<select>` and\n  `<textarea>`.\n- `.field-textarea` — adds the multiline sizing on top of `.field-input`.\n- `.field-select` — adds an inline SVG chevron and resets the native\n  `appearance` so the select matches text inputs.\n- `.field-check` — a custom-painted checkbox (orange when checked).\n\n`fieldRow(label, control, { stacked })` lays out a label + control as a\ntwo-column grid by default; pass `{ stacked: true }` for full-width\ncontrols (multiline only). All field factories (`textField`,\n`numberField`, etc.) use this and the classes above, so you almost never\nneed to touch styles when adding a new field type.\n\n## Notes\n\n- The brand logo and gadgets mark are inlined as SVG; they live in\n  the `logo` / `gadgetsMark` components and are used only by the seed\n  deck. The palette doesn't expose them as reusable blocks. To rebrand,\n  edit the `logo` block's `Wordmark` field (and toggle its accent dot).\n- **Image** blocks store their data inside `props.src` as either an\n  external URL (loaded normally by `<img>` — note this is one of the few\n  network paths the sandbox still allows) or as an inlined `data:` URI.\n  Uploads are read with the `ui` library's `readFileAsDataURL`, and raster\n  images larger than `MAX_IMAGE_DIM` (1600px on the longest side) are\n  downscaled on canvas before being inlined, so the deck JSON doesn't\n  balloon. SVG files are passed through verbatim. The custom `image` field\n  type in `renderField` renders the preview + upload/clear buttons + URL\n  input.\n- **SVG** blocks store raw markup in `props.markup`. `render()` parses\n  it with the DOM parser, strips `<script>` elements and `on*` attributes,\n  then forces `width=\"100%\" height=\"100%\"` and a `preserveAspectRatio`\n  derived from the `fit` prop so the SVG scales to the block bounds.\n  Agents can author / replace SVG blocks programmatically over the\n  `GADGET` binding: call `addBlock(slideId, { type: \"svg\", x, y, w, h,\n  props: { markup: \"<svg ...>...</svg>\" } })` or `updateBlock(...,\n  { props: { markup: \"...\" } })`.\n- Arrows are special-cased: they render a full 1200×675 SVG overlay and\n  store endpoints in `props.x1/y1/x2/y2`. Endpoint handles appear when an\n  arrow is selected. **Important**: full-bleed blocks must NOT make their\n  root element pointer-clickable, or they'll eat clicks for everything\n  rendered before them on the slide. `renderBlock` keeps the SVG itself\n  `pointer-events: none` and adds an invisible thick hit-line on top of\n  the visible arrow stroke so clicking near the line still selects it.\n- Alignment snaps while dragging are implemented in `attachBlockInteractions`\n  via three helpers: `computeSnapTargets` (gathers x/y edges + centers from\n  every other non-arrow block on the slide, plus slide bounds + center),\n  `applySnap` (independently snaps each axis to the nearest target within\n  `SNAP_THRESHOLD` slide-units), and `drawSnapGuides` (renders thin orange\n  guide lines into a `[data-snap-layer]` div inside the slide frame). The\n  snap layer is torn down on `pointerup`. Blocks without explicit `w`/`h`\n  (titles, pills, etc.) are measured via `offsetWidth/Height` so their\n  rendered bounding box still participates correctly.\n- Slide thumbnails in the left panel are produced by `renderSlideThumbnail`,\n  which calls the normal `renderSlide` with `editMode` temporarily forced\n  off and wraps the resulting 1200×675 frame in an `overflow:hidden`\n  container scaled with `transform: scale(width / 1200)`.\n- The slide search at the top of the left panel is built once at shell\n  mount (`buildSlideSearchHeader`) and persists across re-renders, so\n  typing into it never steals focus. `renderSlideList` reads\n  `slideSearch` and walks `filteredSlideIndices()` to decide what to\n  show. Drag-to-reorder is disabled when a filter is active since the\n  visible positions don't map 1:1 to absolute deck indices.\n- Reordering uses native HTML5 drag-and-drop (`draggable=\"true\"` +\n  `dragstart`/`dragover`/`drop`). A floating 2px orange line shows the\n  drop position; the actual move is done optimistically on the client and\n  then sent to the server via `gadget.moveSlide`.\n- The sandbox prohibits modal dialogs and external fetches; all asset\n  data must be inlined.\n- The server still has a `resetAll` RPC method — it's no longer exposed\n  in the UI but remains for use from the `GADGET` binding if you ever\n  need to nuke the deck.\n\n## Export formats\n\nThe deck supports **HTML**, **PDF**, and **PowerPoint (`.pptx`)** exports. HTML and PDF use the\nbrowser print renderer. PowerPoint export runs on the server and creates a conventional OPC /\nPresentationML package through `@gadgets/bundled-blueprints/libraries/pptx/server`. The build inlines\nthat library into `server.js`; there is no separately shipped `pptx.js` or runtime package dependency.\n\nText, cards, boxes, pills, basic shapes, dividers, arrows, and the two brand marks become editable\nnative PowerPoint objects rather than a screenshot. `ExportHandler` passes the original deck to\n`deckToPptx(deck, adaptBlock)`, with an adapter that expands each `logo` into a text wordmark and\nellipse accent dot. Other repository-authored TypeScript blueprints can import the same renderer\nand supply their own adapters. Source block order remains the shape z-order.\n\nAuthored quotas are checked before adaptation: 500 slides, 1,000 blocks per slide, and 10,000 blocks\nper deck. Adapted output has separate bounds of 2,000 blocks per slide and 20,000 per deck, so a\nlogo's accent dot does not consume another authored block. Text and image budgets still apply.\n\nThe starter chart, Edit/agent icons, and bottom brand bars use native blocks and survive export.\nThe orange cover treatment exports as native objects; SVG blocks and dot-grid backgrounds are omitted.\n\nThe 1200 x 675 canvas maps to standard widescreen PowerPoint at 12,192,000 x 6,858,000 EMU. The\nrenderer asks consumers to grow auto-height blocks. Cards budget text boxes and gaps within their\nauthored padded height, then ask consumers to shrink the text to fit rather than match browser\nclipping. PNG and JPEG data URLs are embedded after signature, dimension, and resource limit checks.\nImage corner radii apply when the picture fills its block, including matching-aspect `contain`\nimages; letterboxed pictures remain rectangular. Remote raster images become visible placeholders.\nSVG image data URLs and `svg` blocks are omitted entirely.\n\n","packages/bundled-blueprints/libraries/README.md":"# Gadget libraries\n\nShared code the bundled blueprints import instead of carrying their own copies. A blueprint's\n`client.ts` and `server.ts` import a library by this package's name and the subpath its\n`package.json` exports, `@gadgets/bundled-blueprints/libraries/<name>/client` and\n`.../<name>/server`; the blueprint build (`../src/files.ts`) resolves each to the library's entry\nhere and inlines what the entry uses into the `client.js` / `server.js` the blueprint ships, the way\nit inlines a blueprint's own `lib/` modules. The blueprint stays self-contained, and a gadget created from\nit carries its own copy of the library as of its creation; nothing resolves the package name\nat runtime, and a gadget the agent writes cannot import one.\n\nNothing here is deployed on its own. The package name is how tsc, vitest and an editor resolve the\nimport, through `exports`, with no alias or `paths` block; the build resolves the same name to this\ndirectory itself, so a blueprint tree with no `node_modules` above it builds too.\n\n## Layout\n\n```\n<name>/\n  client.ts        entry of .../libraries/<name>/client, inlined into a blueprint's client.js\n  server.ts        entry of .../libraries/<name>/server, inlined into a blueprint's server.js\n  src/**           the modules the two entries import; never shipped on their own\n  __tests__/**     vitest, jsdom by default, `// @vitest-environment node` for a pure module;\n                   `server.test.ts` / `<topic>.server.test.ts` for a test of the server side\n  README.md        what the library does and how it is put together\n```\n\nA library may import another (`@gadgets/bundled-blueprints/libraries/ui/client` from a library built\non it, say); the blueprint build resolves that import the same way. A client entry may not import a\nlibrary's server side, and the build rejects a blueprint that does: it would drag a Durable Object\ninto the iframe. A blueprint imports its own files by relative path and a library by package\nsubpath; everything else the bundle inlines fails the build -- a relative or absolute path into\n`libraries/` (`../../../libraries/ui/src/dom.ts`), a library's `src/` module by package path, the\npackage root, or a bare specifier some `node_modules` above the blueprint happens to satisfy -- so\nthe exported subpath is the only door.\n\n## Rules for a library\n\n- **es2022.** A blueprint's bundles target `es2022`, and the gadget loader's `compatibilityDate` is\n  `\"2026-02-01\"` (`loadGadgetWorker` in overseer.ts). A library that needs a newer runtime feature\n  bumps that date for every gadget, and says so in its README.\n- **No npm.** What a library imports is inlined into a blueprint's files, which nothing audits\n  afterwards, so the blueprint build rejects an input from `node_modules`. A library is written\n  against the platform alone, like a gadget.\n- **Readable.** The inlined code is what the agent reads and edits in an instantiated gadget. The\n  bundle is not minified, so a library's names and structure survive into it and should read well\n  on their own; its comments do not survive, because esbuild drops ordinary comments whatever the\n  minify settings, so the doc comments in `src/` are for this repository's readers.\n- **Lint applies.** Unlike a blueprint's `files/`, which the repo's lint ignores as user-authored\n  gadget source, this is platform code and is checked like the rest of the repo.\n\n## Tests\n\n```\npnpm --filter @gadgets/bundled-blueprints test:run   # the libraries' suites, with the blueprints'\nvp run -F @gadgets/bundled-blueprints build          # the type-check programs\n```\n\nThe package's `tsconfig.client.json` checks each client entry under the DOM lib,\n`tsconfig.server.json` each server entry under the Workers types, `tsconfig.tests.json` the tests\nunder DOM and Node types and `tsconfig.server-tests.json` the server-side tests under Workers and\nNode types; a `src/` module is checked under whichever of those imports it, which is what keeps a\nDurable Object from seeing `document` and iframe code from importing `cloudflare:workers`. A server\ntest runs against a stub of `cloudflare:workers` that the package's vitest config aliases in.\n\n## Libraries\n\n- [`ui`](ui/README.md) -- DOM helpers for document-style gadgets: the `el` builder, SVG icons and\n  toolbar controls, the in-page prompt, the save-status dot, relative time, and image reading and\n  downscaling. Client-only.\n- [`sync`](sync/README.md) -- collaboration plumbing: on the server a mutation queue, a subscriber\n  registry with presence seeding and broadcast, and versioned upserts; on the client a save scheduler\n  with retry backoff, a presence roster and a subscribe helper. The Docs, Sheets and Slides\n  blueprints build on it.\n- `zip` -- a server-only streaming ZIP32 writer shared by the Sheets XLSX exporter and PPTX library.\n- `pptx` -- a server-only PresentationML renderer for block-based slide decks, inlined into each\n  importing blueprint's `server.js`, not shipped as a separate `pptx.js`. `deckToPptx(deck, adaptBlock)`\n  validates authored quotas before a per-block adapter replaces brand-specific blocks; adapted\n  output is bounded separately. Installed gadgets cannot resolve these package imports at runtime.\n","packages/bundled-blueprints/libraries/sync/README.md":"# `sync` — collaboration plumbing\n\nEvery collaborative gadget here has the same skeleton: one Durable Object owns the authoritative\nstate and many browsers edit it live -- a mutation queue in the object, a set of subscriber stubs\nit broadcasts to, presence seeded on join and dropped on leave, per-item optimistic concurrency,\nand in the browser a debounced save loop with retry, a throttled presence heartbeat and an\n`RpcTarget` for the callbacks. This library is that skeleton once, with none of the flesh: it knows\nnothing about blocks, cells or slides, and a gadget keeps its own model, protocol, DOM and export\nformats on top of it.\n\n## Server (`@gadgets/bundled-blueprints/libraries/sync/server`)\n\n- **`MutationQueue`** -- `run(work)` chains mutations so that each loads the state the previous\n  one stored, whatever order their RPCs interleave in; a rejection reaches its caller alone.\n- **`SubscriberRegistry<Callbacks, Info>`** -- `add(subscriber, info)` `dup()`s the stub the RPC\n  layer delivered, registers `onRpcBroken` cleanup and returns the kept handle; `broadcast(send)`\n  delivers to everyone at once and never waits: a failing subscriber is dropped and disposed\n  (never failing the mutation), a hung one holds up nothing but itself, and a callback may call back\n  into the object -- read the document, queue a mutation -- without deadlocking on the mutation that\n  is telling it about the last one; deliveries are issued synchronously in call order, so each\n  subscriber hears events in order. `remove(handle)`, `has(handle)`, `members()`, `size`. Given\n  `PresenceHooks` -- `join(subscriber, who)` and `leave(subscriber, who)` in the gadget's own\n  callback vocabulary -- it seeds a newcomer with everyone already here (all at once; one that\n  fails a seed is dropped, and its leave announced, since it was a member from the moment it was\n  added), announces the newcomer to all, and announces whoever drops out. A gadget with no presence passes none and gets a fan-out.\n- **`applyVersioned(items, batch, options?)`** -- the per-item concurrency rule over any\n  `{ id, version }`: an upsert whose `baseVersion` is stale is rejected with the authoritative\n  item, one for a deleted item is rejected as `missing` (the client may re-create it with\n  `baseVersion` 0), an identical upsert is no change, deletions are version-checked, and accepted\n  items take the next version. Returns the new item map, `accepted`, `deletedIds`, `conflicts`,\n  `changed` and a `status`; `operationStatus(changed, conflicts)` recomputes the status for a\n  gadget whose batch also carries ordering or a title. `normalizeBaseVersion` reads a base version\n  off the wire.\n- **`normalizeCollaborator`** bounds what a client says about itself before it is repeated.\n\nThe entry imports nothing from `cloudflare:workers`: the stubs are whatever the gadget's own\n`subscribe` receives, seen through the structural `SubscriberStub` (`dup`, `onRpcBroken`, the\ndisposer).\n\n## Client (`@gadgets/bundled-blueprints/libraries/sync/client`)\n\n- **`SaveScheduler`** -- `schedule(delay?)` debounces (`DEBOUNCE_MS`), `flush()` serializes and\n  a flush during a save folds into one more. The gadget supplies `save()`, which sends what is\n  dirty and answers `\"saved\"`, `\"conflict\"` (rebased, re-sent at once) or `\"pending\"` (a draft\n  waits on something else; the gadget schedules again), and `isDirty()`. A failed save is retried\n  after `retryDelay(failures)`: `RETRY_BASE_MS` doubling to `RETRY_MAX_MS`, reset by a success,\n  with the failure left on the status line until the retry starts. Statuses (`SaveStatus`) are\n  `saving`, `saved`, `conflict`, `offline`, with `synced` left for the gadget to report on a live\n  update. `readOnly` makes it a no-op.\n- **`PresenceRoster<Cursor>`** -- `apply(event)` takes join, cursor and leave events (ignoring the\n  client's own), keeping a position through a re-join; `people()`, `entries()`, `get(clientId)`;\n  `expire(now?)` drops anyone silent for `STALE_MS`. `Cursor` is the gadget's position type,\n  carried unread.\n- **`PresenceReporter<Update>`** -- `schedule()` sends at most once per `THROTTLE_MS`, `sendNow()`\n  sends at once, `startHeartbeat(onBeat?)` re-sends every `HEARTBEAT_MS` and then runs the\n  gadget's tick (expire the roster, redraw), returning the stop function.\n- **`createSubscriber(RpcTarget, callbacks)`** -- an instance of the host's `RpcTarget` whose\n  prototype carries each callback, since the RPC layer exposes prototype methods and never own\n  properties. The class comes from the gadget's bootstrap, which a library cannot import, so the\n  gadget passes it in the way it passes its `gadget` stub.\n- **`collaboratorFor(clientId)`** -- the guest identity a tab declares for itself.\n\nNothing on the client side touches the DOM; every module runs in Node under its tests.\n\n## Using it\n\nA gadget keeps its RPC surface and its stored shape; the library implements the loop. On the\nserver, every mutation runs through `queue.run`, one registry with hooks that build the gadget's own\n`presence` events holds the subscribers and broadcasts to them, and `applyVersioned` applies the\nupsert/delete rule with the gadget's sanitizer in front and its ordering after. In the browser, a\n`SaveScheduler` whose `save()` is the gadget's payload builder drives saving, a roster holds the\ncollaborators, a reporter sends presence and the heartbeat, and `createSubscriber` builds the\ncallbacks class. Each part stands alone; a gadget may use any subset.\n\n## Tests\n\n`__tests__/` runs in Node (`// @vitest-environment node`): one suite per module, plus\n`server.test.ts`, which assembles the server entry into the smallest gadget and drives it with\nstubs shaped like the RPC layer's.\n","packages/bundled-blueprints/libraries/ui/README.md":"# `ui` — DOM helpers for document-style gadgets\n\nThe Docs, Sheets and Slides blueprints share the same few helpers: an element builder, an SVG icon\nfactory, toolbar buttons and a dropdown, an in-page prompt (the sandboxed iframe blocks\n`window.prompt`), a save-status dot, relative timestamps, and the reading and downscaling of a\npasted image. This library is those helpers once, imported as\n`@gadgets/bundled-blueprints/libraries/ui/client`. It is DOM-only: nothing here talks RPC\nor touches storage, and nothing here knows what a click means -- every control takes the gadget's\naction as an argument.\n\n`@gadgets/bundled-blueprints/libraries/ui/server` exists so that every library has both entries and a\nblueprint's server may import any of them uniformly. It exports one flag, `clientOnly`, and nothing\nelse.\n\n## What it exports\n\n| Export | What it is |\n| --- | --- |\n| `el(tag, props?, children?)` | Element builder. `class`, `text`, `html`, a `style` object and `on*` listeners are the conveniences; `null`/`undefined`/`false` props are skipped, `true` is an empty attribute; children are nodes, strings, numbers or nothing, singly or in an array. |\n| `icon(paths)` | The 24-unit stroked SVG the toolbars draw, `aria-hidden`. |\n| `ICONS` | Path markup for the icons at least two gadgets share (undo, redo, bold, italic, underline, strike, textcolor, the three alignments, ul, ol, link, image, clear). A gadget spreads its own over it. |\n| `iconBtn(paths, title, onClick, label?)`, `segBtn(paths, title, onClick)` | `button.icon-btn` / `button.seg-btn`, `type=button`, titled and `aria-label`led, mousedown prevented so the editor's selection survives; `label` shows text instead of an icon. |\n| `group(prio, items, first?)` | `div.tgroup[.p1/.p2/.p3]` led by a `div.tdiv` divider unless `first`. |\n| `colorBtn(paths, title, defaultColor, onChange)` | `div.color-btn`: icon, `span.bar` swatch, native colour input; a pick recolours the bar and calls `onChange(hex)`. |\n| `customSelect({className?, title?, options, value, showUnlistedValue?, onChange})` | The toolbar dropdown: `button.cselect` plus a `div.cmenu` appended to the body while open; options may carry `style` (docs' font preview), `ex` (sheets' format example) or be `{sep: true}`. A value may be a string or a number, matched and reported as a string. Returns `{el, setValue, getValue}`. |\n| `promptInline(message, initial?, placeholder \\| options?)` | The dialog: resolves the text on OK/Enter, `null` on Cancel/Escape/backdrop. Options: `placeholder`, `okLabel`. |\n| `PROMPT_STYLES` | CSS for the dialog's classes, scoped under `.prompt-overlay`/`.prompt-card`, for a gadget whose stylesheet has none. |\n| `statusIndicator({kind?, text?, title?})` | `div.status` > `span.dot.<kind>` + text; `set(kind, text)` is a no-op when nothing changed. |\n| `relativeTime(epochMs, now?)` | `just now`, `3 min ago`, `2 h ago`, `5 d ago`, then the locale date. |\n| `prepareImage(file, options?)` | Data URL within `maxDimension` (1600px) and `maxDataUrlLength` (1.4M chars): a GIF within `maxGifDataUrlLength` (2.7M chars, about 2 MB) is kept animated, everything else becomes WebP (JPEG where WebP cannot be encoded) at falling quality, then falling size. `alt` defaults to `altFromFileName`. |\n| `readFileAsDataURL(file)`, `loadImage(src)` | Its two steps, for a gadget that wants one of them. |\n| `isImageFile`, `imageFilesFrom(transfer)`, `IMAGE_TYPES`, `DEFAULT_IMAGE_LIMITS`, `altFromFileName` | The rest of the image module. |\n\nEvery element is styled by the gadget's own stylesheet through the class names above; the library\nships no CSS but `PROMPT_STYLES`.\n\n## Behaviour worth knowing\n\n- **`el`** has no `data` prop; write a `data-x` attribute. `text` sets `textContent` before the\n  children are appended, so both may be given.\n- **`iconBtn`/`segBtn`** prevent mousedown so the editor's selection survives the click;\n  **`customSelect`**'s button does not, and a gadget whose selection has to survive it adds that\n  listener to the returned element, as Docs does on its colour button. The dropdown's label for a\n  value no option carries is the first choice's, or the value itself with `showUnlistedValue`\n  (Docs' font size, where a heading's size is off the list).\n- **`promptInline`**'s message is text, not markup.\n- **`prepareImage`** tries four encoding qualities before it shrinks the image, and keeps a GIF\n  animated only while its data URL fits `maxGifDataUrlLength`. A gadget with its own conversion\n  (Slides passes SVG through, keeps PNG as PNG and falls back to the original on any failure) uses\n  `readFileAsDataURL` and `loadImage` and does the rest itself.\n- **`relativeTime`** has no caller among the bundled blueprints yet.\n\n## Tests\n\n`__tests__/` covers every export in jsdom (`time.test.ts` in node). jsdom decodes no images and\ndraws on no canvas, so `images.test.ts` stands in an `Image` that reports the size written in its\nsource and a canvas whose data URL is as long as the requested quality and pixel count say.\n","packages/gatekeeper-cloudflare/README.md":"# Gatekeeper Cloudflare\n\nThis package provides Cloudflare OAuth integration for Gadgets. It serves three purposes:\n\n- **Sign-in:** when `cloudflare` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Cloudflare\" appears on the login page. The grant reads the account email (verified by Cloudflare,\n  via the `/user` API), which becomes the user's identity. Cloudflare sign-in also establishes the\n  persistent billing-only connection described below.\n- **AI Gateway billing:** when a user connects Cloudflare or signs in with it, the billing scopes are\n  requested and the connection persists. The Workshop then reads a usable access\n  token from it (`getUsableAccessToken`) to power the [AI Gateway billing](../../docs/ai-gateway-billing.md)\n  flow — reading the credit balance and routing BYOK inference through the account's default AI\n  Gateway.\n- **Workers Observability:** gadgets can receive read-only access to logs, events, invocations,\n  aggregate metrics, and traces either across an account or restricted to one Worker. Every result\n  is authorized as an observation, and Worker bindings inject an immutable service filter and\n  defensively discard foreign-service events. Distributed trace summaries are account-only because\n  their names, timing, services, and counts describe the whole cross-service trace; a Worker binding\n  can still retrieve its own events for a known trace ID.\n\nObservability connections request `workers-observability.read`. The OAuth client must allow that\nscope or Cloudflare will omit/reject it. Existing billing-only connections can add the grant when the\nuser first selects an observability resource. Cloudflare exposes account and Worker resource choices,\nbut both map to this one indivisible OAuth scope; resource bindings provide the finer capability\nboundary after connection.\n\nWorkers telemetry is retained by Cloudflare for at most seven days. Queries default to the last hour,\nand the Worker picker searches the full retention window. Suggested bindings are\n`CLOUDFLARE_OBSERVABILITY` for account access and `WORKER_OBSERVABILITY` for one Worker.\n\n### Sharing a gadget that reads telemetry\n\nA binding is not transferable. When a gadget with an observability binding is shared, each\ncollaborator is admitted only if **their own** connected Cloudflare account can read that resource\n(`addObserver` checks it against their credentials, not the owner's). A collaborator without access is\nrefused, and a verification that fails for any other reason — a 5xx, a transport error — is also a\nrefusal rather than an admission.\n\n### What a Worker binding trusts, and what it re-checks\n\nThe scope filter is prepended to every query, but a filter the provider *accepted* is not evidence it\n*applied* it — three separate behaviours here return wrong-but-plausible data with no error. So events\nare re-filtered on the way out, and a single foreign event is treated as proof the filter was dropped:\nthe provider's `count` is withheld (it would be a count of the whole account's matching telemetry) and\nthe event is logged at `error`. `statistics` is kept, because it describes what our query cost rather\nthan how much matched, and callers are told to read it for cost.\n\nThe re-check is deliberately not a hard failure. The events returned are filtered and therefore safe,\nand this provider has surprised us often enough that turning a hypothetical disclosure into a\nguaranteed outage would be the worse trade.\n\n`calculate()` is the exception, and knowingly so: an aggregate cannot be un-mixed, so there is no\nsecond line of defence to add. It rests entirely on the injected filter. The fix that would work —\ngrouping by `$metadata.service` and keeping this binding's own group, whose value *is* the correctly\nscoped answer even for a median — changes `limit` and `orderBy` semantics for every caller, so it is a\nfollow-up rather than a footnote.\n\n### Why discovery sometimes costs a query\n\nCloudflare's `telemetry/keys` and `telemetry/values` endpoints ignore the `filters` array they accept:\nverified against a live account, they answer for the whole account no matter what is passed. A\nWorker-scoped binding therefore cannot use them — they would disclose every field name and value in\nthe account. Whenever a discovery call has to be constrained (a Worker binding, or any caller-supplied\nfilter), the answer is derived instead from a filtered `telemetry/query` events sample, which the\nprovider does filter correctly. Unconstrained account-wide discovery still uses the cheap endpoints.\n\n> Unresolved: whether the `workers-observability.read` scope alone is accepted, or whether Cloudflare\n> requires the Workers Observability **Write** permission to read telemetry. This has not been\n> verified against a live token; if reads 403 on a correctly-scoped grant, that is the first thing to\n> check.\n\n### Two names for one field\n\nA log's own structured fields are returned nested under `source` but are **indexed under their bare\nname**: `logger.warn(msg, {event: \"x\"})` comes back as `source.event` and is queried as `event`.\nConfirmed both ways — `telemetry/keys` on a live account reports `event`/`component`/`level` and no\n`source.*` key at all, and the [Workers Logs\ndocs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) show\n`console.log({user_id: 123})` being filtered as `user_id`.\n\nThis matters because the provider accepts an unknown filter key, matches nothing, and returns an empty\npage with no error. So discovery reports indexed names (`observability-discovery.ts`), and\n`observabilityFieldKey` additionally accepts a `source.`-prefixed key as an alias wherever a caller\nnames a field — filters, `calculations`, `groupBys`, `listValues` — because copying a path out of a\n`listEvents` result is the obvious thing to do and used to fail silently.\n\n`openid` is intentionally **not** requested — the Cloudflare dashboard OAuth client isn't permitted\nthat scope; identity comes from the `/user` API (`user-details.read`).\n\n### Why an error's text never reaches the log\n\n`summarizeFilter` deliberately keeps filter *values* out of the audit trail — they are caller text.\nBut a provider error message can quote that same value straight back, so logging the message would\nreadmit through the error path exactly what the audit path excludes. `CloudflareObservabilityApiError`\ntherefore carries Cloudflare's numeric `codes` alongside the message: the request log names the\ncodes, and the message travels only to the caller who caused it.\n\nThe codes are not the discriminator, though — Cloudflare can return a message with no numeric code\nat all, so the error records separately whether the message is *its* or *ours*, and only ours is\nlogged. A provider failure carrying no codes is therefore logged as a bare status, which is the\nfail-closed answer: the status still says what happened without quoting anyone's filter back.\n\n### Listing accounts is a walk, not a request\n\n`/accounts` is paginated and defaults to **20** per page, so a single GET silently returns a truncated\nlist — an account past the first page simply cannot be picked, with nothing to indicate why. The\naccount picker's substring match also stays client-side: `name` is the only documented server-side\nfilter and whether it matches exactly or by substring is not specified, so pushing it down would trade\na visible truncation for an invisible one.\n\n## Setting Up Cloudflare OAuth Credentials\n\nYou need a Cloudflare dashboard OAuth client (client id + secret). The dashboard OAuth endpoints and\nscopes are hardcoded in `src/oauth.ts`, so you only configure the client id/secret and register the\nredirect URI. Ensure the client's scope allowlist includes `workers-observability.read` when this\ndeployment offers Workers Observability resources.\n\n### Step 1: Register the redirect URI\n\nThe gatekeeper's OAuth redirect URI is:\n\n```\n${BASE_URL}/oauth\n```\n\nwhere `BASE_URL` defaults to `http://localhost:8787/gatekeeper/cloudflare` in dev — i.e. the full\nredirect URI is:\n\n```\nhttp://localhost:8787/gatekeeper/cloudflare/oauth\n```\n\nRegister **exactly** this (replace the host with your `PUBLIC_BASE_URL` when not running locally) as\nan allowed/pre-registered redirect URL on the Cloudflare OAuth client. If it isn't registered you'll\nget an `invalid_request` error: _\"the 'redirect_uri' parameter does not match any of the OAuth 2.0\nClient's pre-registered redirect urls.\"_\n\n### Step 2: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-cloudflare/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\nIn local dev, `run-dev-server.ts` will also seed these from `CLOUDFLARE_OAUTH_CLIENT_ID` /\n`CLOUDFLARE_OAUTH_CLIENT_SECRET` if you'd rather set them in the root `.dev.vars`. A per-package\n`.env` takes precedence and keeps the credential with the gatekeeper that uses it.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable Cloudflare sign-in / billing\n\nTo offer \"Continue with Cloudflare\" on the login page, add `cloudflare` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nThe order controls the order of the login buttons. For the AI Gateway billing / top-up flow, also\nset `ENABLE_CLOUDFLARE_LIMITS=true` (see [AI Gateway billing](../../docs/ai-gateway-billing.md)); a\nuser enables billing by connecting Cloudflare, which requests the billing scopes\n(`offline_access aig.read aig.run user-details.read account-settings.read`). Connecting all\nCloudflare gadget resources also requests `workers-observability.read`.\n\n### Step 4: Verify Setup\n\n1. Start the application in dev mode (see the root README.md).\n2. On the login page, click **Continue with Cloudflare**.\n3. A pop-up opens to the Cloudflare authorization page; approve it.\n4. The pop-up closes and you're signed in, identified by your Cloudflare account email.\n5. To use AI Gateway credits, open **Usage & billing** in settings and **Connect Cloudflare** (this\n   requests billing scopes only).\n\n## Troubleshooting\n\n### \"redirect_uri ... does not match any of the ... pre-registered redirect urls\"\n\nThe redirect URI isn't registered on the OAuth client. Register exactly\n`http://localhost:8787/gatekeeper/cloudflare/oauth` (or your `PUBLIC_BASE_URL` equivalent) — no\ntrailing slash, `http` not `https` for localhost.\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` / `CLIENT_SECRET` are missing. Ensure they're set (per-package `.env` or seeded from the\nroot `.dev.vars`), then restart the dev server.\n","packages/gatekeeper-confluence/README.md":"# Confluence (Cloud) gatekeeper\n\nConnects Gadgets to Atlassian Cloud [Confluence](https://www.atlassian.com/software/confluence)\n(`*.atlassian.net`) using OAuth 2.0 (3LO). It exposes spaces, pages, blog posts, comments, labels,\nand attachments through a capability-based Session API, with page/blog bodies converted to and from\nMarkdown. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_CONFLUENCE` binding.\n\nOnly Atlassian **Cloud** is supported; there is no Server / Data Center adapter.\n\n## What it provides\n\nThree connectable resource granularities:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| **Site** — search and open any space/page/blog post the account can access | `https://*.atlassian.net/wiki` | `ConfluenceSite` |\n| **Space** — list/search/create pages and blog posts within one space | `https://*.atlassian.net/wiki/spaces/:spaceKey` | `ConfluenceSpace` |\n| **Page or blog post** — read/edit the body, manage labels/comments/attachments, and (pages only) read/create child pages | `https://*.atlassian.net/wiki/spaces/:spaceKey/pages/:pageId/*` | `ConfluenceContent` |\n\nPages and blog posts share the `ConfluenceContent` session type (distinguished by\n`ContentMetadata.type`); child-page methods are page-only.\n\nA single account-level grant can span multiple sites. `getGatekeeperClassFor()` resolves a URL's\nhost to one of the connection's accessible sites (its cloud ID) — the one chokepoint enforcing site\naccess.\n\nSee `src/types.d.ts` for the full, agent-facing Session API.\n\n## Setting up an Atlassian OAuth 2.0 app\n\n1. Go to the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/) and\n   create an **OAuth 2.0 integration**.\n2. Add the **Confluence API** to the app and enable **both** scope families below (see\n   `CONFLUENCE_SCOPES` in `confluence-api.ts`). The console has separate **Granular scopes** and\n   **Classic scopes** tabs; this gatekeeper needs scopes from each because it uses the v2 API for\n   most operations and falls back to v1 for a few (search, label writes, attachment upload,\n   restore).\n\n   **Granular scopes** (authorize the v2 API — v2 doesn't accept classic scopes):\n   - `read:space:confluence`\n   - `read:page:confluence`, `write:page:confluence`, `delete:page:confluence`\n   - `read:blogpost:confluence`, `write:blogpost:confluence`, `delete:blogpost:confluence`\n   - `read:comment:confluence`, `write:comment:confluence`, `delete:comment:confluence`\n   - `read:label:confluence`, `write:label:confluence`\n   - `read:attachment:confluence`, `write:attachment:confluence`, `delete:attachment:confluence`\n   - `read:user:confluence`\n\n   **Classic scopes** (authorize the v1 fallbacks — v1 doesn't accept granular scopes):\n   - `search:confluence` (CQL search)\n   - `read:confluence-content.all`, `read:confluence-content.summary` (search results / content reads)\n   - `read:confluence-space.summary`\n   - `write:confluence-content` (label add/remove, restore-from-trash)\n   - `write:confluence-file` (attachment upload)\n   - `readonly:content.attachment:confluence` (attachment download)\n   - `read:confluence-user`\n\n   **Account-level:**\n   - `read:me` (authorizing user's identity)\n   - `offline_access` (required for refresh tokens)\n3. Under **Authorization**, set the callback URL to `<BASE_URL>/oauth`:\n   - `http://localhost:8787/gatekeeper/confluence/oauth` for local dev\n   - `<PUBLIC_BASE_URL>/gatekeeper/confluence/oauth` otherwise\n4. Copy the app's **Client ID** and **Secret**.\n\n> **API version:** this gatekeeper targets the Confluence **v2** REST API (`/wiki/api/v2/...`),\n> which Atlassian now requires (the v1 content/space endpoints have been removed) — and which is\n> why the **granular** scopes are needed. A few capabilities with no v2 equivalent (CQL search,\n> label add/remove, attachment upload, restore-from-trash) fall back to v1 endpoints, which need\n> the **classic** scopes, and degrade with a clear error if Atlassian has removed them too. The app\n> therefore must enable scopes from both the Granular and Classic tabs.\n\n## Configuring credentials\n\nFor local development, set these in the repo-root environment (e.g. `.dev.vars`) so the dev server\ninjects them into this Worker:\n\n```\nCONFLUENCE_CLIENT_ID=your-client-id\nCONFLUENCE_CLIENT_SECRET=your-client-secret\n```\n\n`run-dev-server.ts` maps `CONFLUENCE_CLIENT_ID` / `CONFLUENCE_CLIENT_SECRET` into the Worker's\n`CLIENT_ID` / `CLIENT_SECRET` vars. For production, set `CLIENT_ID` and `CLIENT_SECRET` as secrets\non the deployed Worker and set `BASE_URL` to the public gatekeeper URL.\n\n`providesAuth` is false: Confluence is a data connector, not a sign-in method. Wiring it up as a\n\"Sign in with Atlassian\" provider (using the verified `/me` email) is a possible fast follow.\n\n## How the connect flow works\n\n1. The user starts a connection; `connectAccount()` creates a `UserAccount` Durable Object and\n   returns a URL of the form `<BASE_URL>/<doId>/<nonce>`. The flow uses a two-phase nonce\n   (initiation → OAuth).\n2. Visiting that URL redirects to `auth.atlassian.com` to authorize the requested scopes.\n3. Atlassian redirects back to `<BASE_URL>/oauth`; the gatekeeper exchanges the code for access +\n   rotating refresh tokens, lists the account's accessible Confluence sites\n   (`/oauth/token/accessible-resources`), reads the authorizing user's identity (`/me`), and stores\n   them.\n4. Each connected resource routes API calls through\n   `https://api.atlassian.com/ex/confluence/<cloudId>/wiki/...`. Access tokens are short-lived and\n   refreshed automatically (proactively before expiry and again on a 401); each refresh persists\n   the **new** rotating refresh token.\n\nA connection is created either by pasting a Confluence URL into the chat, or from the Connections\nUI.\n\n## API\n\nPage/blog bodies are exchanged as **Markdown** (best-effort conversion to/from Confluence \"storage\nformat\" XHTML; see `confluence-markdown.ts`). Highlights:\n\n- `ConfluenceSite`: `listSpaces`, `getSpace`, `getContent`, `search` (text or raw CQL),\n  `getCurrentUser`\n- `ConfluenceSpace`: `listPages`, `listBlogPosts`, `getContent`, `search`, `createPage`,\n  `createBlogPost`\n- `ConfluenceContent`: `getMetadata`/`getContent`, `setContent`/`appendContent`/`setTitle`,\n  `listChildPages`/`createChildPage`, labels, comments, attachments\n  (`downloadAttachment` is capped at 16 KB), `trash`/`restore`\n\nEndpoint split: v2 for spaces, pages, blog posts, child pages, and reading comments/labels/\nattachments; v1 (with graceful degradation) for CQL search, label add/remove, attachment upload,\nand restore-from-trash.\n\n## Approvals, caching & simulation\n\nEvery read calls `ApprovalQueue.authorizeObservation()`; every side-effecting operation\n(create/edit page or blog post, comments, labels, attachment upload, trash/restore) is recorded as\na pending action and submitted via `submitAction()` — nothing reaches Confluence until the Overseer\ncalls `applyAction()`. Reads simulate pending (unapproved) writes so the agent can keep working\nbefore approval (provisional IDs for created content, body/title/label/comment overlays, trashed\nstate). `rejectAction()` / `revertAction()` discard or undo. Content responses are cached in DO\nstorage with a short TTL and invalidated on write. This logic lives in `confluence-actions.ts`.\n\n## Resource pickers\n\nThe Connections UI shows space / page-or-blog / site pickers supplied by\n`startResourceConfigurator()`. Pasting a resource URL — or an agent's\n`requestConnection(resourceUrl)` — also works and pre-fills the picker. For multi-site Atlassian\naccounts the space/page pickers search across **all** accessible sites (each option's value carries\nthe correct site), so pasting a URL works for any connected site too.\n\n## Notes and current limitations\n\n- Body conversion between Confluence storage format and Markdown is best-effort; unsupported macros\n  degrade to a labeled placeholder.\n- `author` / `lastUpdatedBy` expose only an `accountId` (the v2 content API returns IDs, not user\n  objects).\n- CQL search, label add/remove, attachment upload, and restore-from-trash rely on v1 endpoints; if\n  Atlassian removes them, those operations surface a clear \"not available\" error.\n\n## Development\n\n- `pnpm exec vp run -F @gadgets/confluence-gatekeeper build` — build the configurator UIs (`build:configurator`, which this task depends on) and type-check (`tsc`). A Vite+ task rather than a script, so `pnpm --filter` cannot see it.\n- `pnpm --filter @gadgets/confluence-gatekeeper test:run` — run unit tests (URL/CQL parsing, Markdown conversion, v2 converters, action simulation).\n- The Worker is run via the root `pnpm dev-server`, not directly.\n","packages/gatekeeper-context/app/skills/README.md":"# Skills Navigator Direction and Compatibility\n\nThe target information model is deliberately flat: a collection contains atomic skills. Each skill\nencompasses its `SKILL.md` manifest and all related agentic standards files. Those files may be\nstored together in a skill directory, but the navigator presents the complete bundle as one skill,\nnot as a file hierarchy.\n\nSkill titles are presented as human-readable title case. Creation and rename accept that display\nform, then derive the lowercase, hyphen-separated `name` required by the Agent Skills specification\nfor the manifest metadata and directory name.\n\nOrganizational directories are being abandoned. The navigator does not let users create, rename, or\nmove them, and new product behavior must not depend on adding more directory hierarchy. Directory\nnodes remain only to preserve collections that already use older nested layouts.\n\nSelecting a skill still opens the existing collection editor, which remains the general-purpose\ndocument editing surface. Every reader can also open a collection from its row menu, so collections\nthat contain only ordinary documents remain reachable.\n\n## Legacy directories\n\nExisting organizational directories remain usable for backwards compatibility: skills can be\ncreated in them and moved into, out of, or between them, and the directories themselves can be\ndeleted. They cannot be created, renamed, or moved through the navigator, because the forward model\ntreats only skill bundles as movable units.\n\nLegacy directories can also be deleted. Deleting one removes its complete document subtree,\nincluding files the skills-only projection does not display. None of these compatibility behaviors\nmake directories part of the forward-looking data model: no operation can create another\norganizational directory.\n\n## Mutation boundaries\n\n- Users can create, rename, move, and delete complete skill bundles, but cannot create, rename, or\n  move organizational directories.\n- Skill mutations use the skill-specific RPC methods rather than composing generic document calls in\n  the client. Each operation validates the manifest and applies all storage changes atomically.\n- Renaming or moving a non-root skill operates on its complete directory, preserving supporting\n  files. Destination conflicts fail rather than overwrite existing documents.\n- A root-level `SKILL.md` is supported, but it has no exclusive directory subtree: moving, renaming,\n  or deleting it affects only that manifest. Treating every other root document as its support file\n  would risk moving or deleting unrelated skills and documents.\n- A destination must be the collection root or an existing legacy directory outside every skill\n  bundle. Skills cannot be nested inside other skills, even if an RPC caller supplies a path the UI\n  would not offer.\n\n## Permissions and sources\n\nCollection actions follow `getContextCollectionPermissions`. Skill and directory actions are offered\nonly when the account can edit the collection and the collection is web-backed. Git-backed\ncollections remain read-only at the skill level, but editors, owners, and admins can still edit\ntheir collection settings, manage Git tokens, and refresh them. Sharing is offered on editable\nprivate collections, and deleting the whole collection only to owners and admins. Viewers keep\nnavigation, previews, downloads, and opening the collection. Otherwise read-only collections expose\nno mutation actions and are not offered as Add Skill targets. If loading a collection's documents\nor permissions fails, that collection fails closed as non-writable in the current view.\n\nCollection labels come from the viewer's live membership: deployment-public collections show\nOrganization, an owned collection shows Private or, once anyone else can read it, Shared, and a\ncollection someone else shared shows Shared with you and the viewer's role. Collection titles need\nnot be unique. Labels describe access, not identity: same-titled collections can also have identical\nlabels. UI selection and API operations use collection IDs rather than titles.\n\n## Sharing\n\nThe Share dialog adds people through the Workshop's host-owned person picker\n(`useSelectUsers()` in `bridge.ts`). Each picker session gets its own receiver that fixes the\ncollection and the role chosen when the picker opened, and shares with each picked person as soon as\nthey are delivered; a backend refusal rejects that delivery, so the picker shows the message in a\nstatus notice that names the person. Cancelling the picker does not undo shares already delivered.\nStopping the wait is not a sharing error and does not undo a delivery already running. The dialog\nreloads sharing and permissions when the picker closes and again when a late delivery settles,\nincluding rejected deliveries that may have committed a grant before cleanup failed. A delivery that\nsettles after the Share dialog closes also refreshes the parent collection metadata or listing.\n\nMutation controls remain disabled until refreshed sharing and permissions arrive together. If that\nreload fails, the previous rows remain visible for context but cannot be used to mutate access;\n**Retry** must successfully refresh them first. Backend cleanup errors remain visible independently\nof reload failures.\n\n`Share “<collection title>”` starts with an **Add users with role** dropdown and **Select users**\nbutton. Compact access rows show the current account as **You** with a generic profile icon, then\n**Anyone in organization** (No access or Can view), then a dashed divider and the other members.\nThe add-users role and access controls use ghost-button dropdown menus with right-aligned selection\ncheckmarks; member menus contain role changes and a separate destructive **Remove access** action,\nor **Leave collection** for the current account. The last owner cannot leave or be demoted. A neutral\n**Done** button is the only footer action and stays visible while the member list scrolls. The\nWorkshop's person picker uses the same neutral **Done** action.\n\nAnyone a collection was shared with as a viewer or editor can leave it from the collection's row menu,\nincluding viewers, who cannot open the Share dialog. Owners leave from the Share dialog, which knows\nwhether another owner remains. Both surfaces open the same confirmation before leaving. It warns\nthat the last member leaving an ownerless collection permanently deletes the collection and its\ndocuments, even when organization-wide viewing is enabled. Leaving reconciles the parent even if\nGit token cleanup rejects after the membership was removed, while retaining the cleanup error rather\nthan presenting a rejected operation as success.\n\nThe server remains the authority for every mutation. The client-side checks control affordances and\nprovide earlier feedback; they are not the security boundary.\n\n## Move limitations\n\nDrag-and-drop is intentionally limited to one collection. Within that collection, existing legacy\ndirectories remain valid destinations for the compatibility reasons above. A same-collection move\ncan update the skill directory and all supporting documents in one Durable Object storage\ntransaction.\n\nCross-collection moves are disabled. Implementing one as separate client-side copy and delete calls\ncould overwrite concurrent edits or leave duplicated or partially moved data after a failure. A\nfuture backend operation must authorize both collections, reject read-only sources and destinations,\npreserve the complete skill subtree, fail on destination conflicts, and define recoverable or atomic\nsemantics across the two collection Durable Objects before the UI enables these drops.\n\n## Upload limitations\n\nThe Skills Navigator accepts standalone Markdown files and folders. Each loose `.md` or `.markdown`\nfile becomes one skill. A `SKILL.md` file defines a bundle and imports the other files below its source\ndirectory as related skill files; nested bundles are imported as separate top-level skills. Browser\nfolder selection is based on the non-standard but widely supported `webkitdirectory` input. Chrome's\ndirectory APIs fail or terminate the management app's opaque-origin iframe, so complete folders should\nbe selected with the folder picker. Supporting folder drops requires reading them in the Workshop host\nand passing the files into the sandboxed app.\n\nSkill manifests are created through the skill-specific RPC, but there is currently no backend RPC for\natomically creating a complete bundle. Supporting files are written only after the manifest succeeds.\nIf one of those writes fails, the user is told which skill was only partially uploaded, but the created\nmanifest and any successful related files remain. A future bundle-oriented RPC is required for true\nall-or-nothing uploads.\n\n## Skill upload format contract\n\nThe upload dialog accepts [Agent Skills](https://agentskills.io/specification) and\nlegacy Markdown. Import acceptance is deliberately broader than validation of the\nstored `SKILL.md`.\n\n### Recognizing metadata\n\nAn import opts into skill metadata only when it begins with a complete `---`\nfenced block that parses as a YAML mapping containing `name` or `description`.\nThe delimiter must be unindented; a BOM, CRLF, and trailing fence whitespace are\naccepted. Indented delimiters within YAML block scalars do not close the block.\n\nEverything else is legacy Markdown, including ordinary text, horizontal rules,\nunclosed fences, invalid YAML, sequences, and mappings without skill metadata keys.\nThese inputs are ambiguous, so preserve the entire source as instruction text\nrather than guessing which part to discard. This also applies to imports named\n`SKILL.md`; that filename groups supporting files but does not require a legacy\nimport to already be a valid skill.\n\n### Review and conversion\n\n- Recognized metadata supplies the editable name and description. Missing or\n  non-string fields are left blank for correction; invalid strings are not replaced\n  with inferred defaults. Extra metadata is retained when writing the manifest.\n- Legacy Markdown gets a filename-derived name and a suggested description for\n  user review. Conversion adds new frontmatter and preserves the entire original\n  instruction text, including leading whitespace and line endings.\n- Recognized frontmatter is serialized after edits, but its Markdown body is\n  preserved verbatim. The metadata syntax itself need not round-trip byte-for-byte.\n- The existing review form checks the final name and description before upload;\n  the server's `parseSkillManifest` validates the resulting stored manifest.\n\n`readSkillUploadSource` in `skillUpload.ts` is the single recognition decision used\nby both inference and conversion. New import behavior should be expressed here\nand covered by the table-driven round-trip tests, rather than added as an independent\ndelimiter heuristic in a caller.\n\nThis is not a claim of full specification validation: the existing server validates\nrequired fields, permits names differing from the parent directory, and does not\nvalidate every optional specification field.\n","packages/gatekeeper-email/README.md":"# Gatekeeper Email\n\nThis package provides an email receiving gatekeeper for Gadgets. It allows a Gadget to receive inbound emails at an address like `<name>@<host>` via a hook.\n\nUnlike most gatekeepers which connect to external services, this gatekeeper *is* the service -- it implements a Cloudflare Email Worker that receives mail directly.\n\n## How It Works\n\nThe email gatekeeper uses the URL scheme `http://localhost:8787/gatekeeper/email/mailbox/<name>` to represent the email address `<name>@<host>`, where `<host>` is the domain configured to route email to this worker.\n\nWhen a Gadget is connected to an email address:\n\n1. The Gadget's code exports a `WorkerEntrypoint` that implements the `EmailHook` interface.\n2. The coding agent calls `setBindingHook` to connect the hook to the binding.\n3. The gatekeeper stores the hook reference in a Durable Object keyed by the email username.\n4. When an email arrives, the Email Worker routes it to the appropriate DO, which invokes the Gadget's hook with the parsed email content.\n\nEmails are parsed using [postal-mime](https://www.npmjs.com/package/postal-mime), so the hook receives structured data (from, to, subject, text body, HTML body, attachments) rather than raw MIME.\n\n## Creating a Binding\n\n1. Start the dev server (see root README).\n2. Create or open a Gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose **Email Mailbox**.\n6. Enter the mailbox local part, e.g. `myinbox`.\n7. This represents the email address `myinbox@<host>`.\n8. The binding will appear as `EMAIL` (the suggested name).\n\nMailbox names are canonicalized to lowercase. They may contain letters, numbers, dots, underscores, plus signs, or hyphens, and cannot start or end with a dot or contain consecutive dots.\n\nThe binding provides an `EmailSession` interface with a single method:\n\n```typescript\ninterface EmailSession {\n  getAddress(): Promise<string>;  // e.g. \"myinbox@example.com\"\n}\n```\n\nTo actually receive emails, the Gadget must implement a hook. Ask the coding agent to set up a hook for the email binding, or do it manually:\n\n```typescript\n// In the Gadget's code:\nimport { WorkerEntrypoint } from \"cloudflare:workers\";\n\nexport class MyEmailHandler extends WorkerEntrypoint {\n  async receiveEmail(email) {\n    // email has: from, to, cc, subject, date, text, html, attachments\n    console.log(`Got email from ${email.from.address}: ${email.subject}`);\n    // Store it, process it, etc.\n  }\n}\n```\n\nThen prompt your coding agent to use the `setBindingHook` tool to connect it:\n- Binding: `EMAIL`\n- Export name: `MyEmailHandler`\n\n## Local Development\n\nIn local development, the gatekeeper is served under the path `/gatekeeper/email` on `localhost:8787`. We don't actually support receiving real SMTP email locally.\n\n### Sending a Test Email\n\nIn local dev, wrangler exposes a `/cdn-cgi/handler/email` endpoint that simulates inbound email. You can POST raw email content to it using curl.\n\nWith the dev server running (`pnpm run dev-server` from the repo root), send a test email:\n\n```bash\ncurl -X POST 'http://localhost:8787/cdn-cgi/handler/email' \\\n  --url-query 'from=sender@example.com' \\\n  --url-query 'to=myinbox@example.com' \\\n  --header 'Content-Type: application/json' \\\n  --data-raw 'From: \"Alice\" <sender@example.com>\nTo: myinbox@example.com\nSubject: Hello from local dev\nContent-Type: text/plain; charset=\"utf-8\"\nDate: Mon, 16 Feb 2026 12:00:00 +0000\nMessage-ID: <test-123@example.com>\n\nThis is a test email body.'\n```\n\nThe `to` address's local part (`myinbox`) determines which `EmailAddress` Durable Object receives the email. Make sure:\n- You have a Gadget with an Email Mailbox binding for `myinbox@<host>`\n- The binding has a hook connected via `setBindingHook`\n\nIf no hook is configured for that address, the email will be rejected.\n\n## Production Configuration\n\nIn production, you need to set up [Cloudflare Email Routing](https://developers.cloudflare.com/email-routing/) to forward emails to this worker.\n\n### Step 1: Deploy the email gatekeeper and set env.BASE_URL\n\nSet the `BASE_URL` environment variable to the full base URL (protocol + host + optional path) at which the email gatekeeper's fetch handler is served. No trailing slash. For example:\n\n```\n# Deployed as its own worker at the root:\nBASE_URL=https://gatekeeper-email.example.workers.dev\n\n# Or co-hosted on the same domain as the main app under a path:\nBASE_URL=https://app.example.com/gatekeeper/email\n```\n\nAll occurrences of `http://localhost:8787/gatekeeper/email` in the doc above will in production be replaced by this `BASE_URL` value.\n\n### Step 2: Enable Email Routing\n\n1. In the Cloudflare dashboard, go to your domain's **Email Routing** settings.\n2. Follow the setup wizard to enable Email Routing and configure the required DNS records (MX, SPF, etc.).\n\n### Step 3: Create an Email Worker Route\n\n1. Go to **Email Routing** > **Email Workers**.\n2. Create a route that matches the addresses you want to handle. For example:\n   - **Custom address**: `*@yourdomain.com` (catch-all) or specific addresses like `gadget-*@yourdomain.com`\n   - **Action**: Send to a Worker\n   - **Worker**: Select the deployed `gatekeeper-email` worker\n3. Alternatively, you can configure this in your Worker config for deployment.\n\n### How It Fits Together\n\n```\nInternet email → Cloudflare Email Routing → gatekeeper-email worker\n                                               │\n                                               ├── email() handler parses recipient\n                                               │\n                                               ▼\n                                          EmailAddress DO (per username)\n                                               │\n                                               ├── loads stored hook Fetcher\n                                               │\n                                               ▼\n                                          Gadget's hook entrypoint\n                                          (via Overseer loopback)\n```\n\nEach email address maps to a Durable Object named by its local part. The DO stores the hook `Fetcher` in KV storage, which persists across requests. When an email arrives, the DO loads the hook and calls `receiveEmail()` with the parsed email data.\n","packages/gatekeeper-github/README.md":"# Gatekeeper GitHub\n\nThis package provides GitHub OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `github` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitHub\" appears on the login page. Sign-in requests only minimal scopes (`read:user user:email`)\n  to read the account's **primary verified email**, which becomes the user's identity. The sign-in\n  grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects GitHub (or signs in and later connects it), the full scopes\n  (`repo read:user user:email`) are requested so gadgets can access repositories, issues, and pull\n  requests on the user's behalf.\n\n> **Use a GitHub _OAuth App_, not a GitHub _App_.** Only OAuth Apps honor the OAuth `scope`\n> parameter, which is what makes the minimal-on-login / full-on-connect behavior work, and the\n> `user:email` scope is what grants access to the user's verified email. A **GitHub App** (client id\n> starting with `Iv…`) ignores `scope` entirely and will return `Resource not accessible by\n> integration` for the email lookup unless you separately grant it the **Email addresses** account\n> permission — and even then login won't be minimal-scope. See [Using a GitHub App\n> instead](#using-a-github-app-instead) if you must.\n\n## Auto-approval\n\nEvery queued action carries a stable kind (`github.<action>`, e.g. `github.push`), and the user may\nopt in to approving a kind automatically on a connection. Every action is eligible, including\nclosing and reopening, `requestChanges` reviews and force pushes, except two that always wait for a\nperson: **merging a pull request** and **an approving review**. An approval shares the\n`github.postReview` kind with comment and request-changes reviews, but is never auto-approvable,\nso enabling that kind does not cover it. A force push has its own kind (`github.forcePush`), so\nenabling ordinary pushes does not let history be rewritten. Excluded actions are still approved\nby hand as before. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit (an issue binding, for example, never lists pushes).\n\n## Setting Up GitHub OAuth Credentials\n\nIf you're running this project locally and want to use GitHub integrations, you'll need to create\nyour own GitHub OAuth app. This guide walks you through the process.\n\n### Step 1: Create a GitHub OAuth App\n\n1. Go to [GitHub Settings > Developer settings > OAuth Apps](https://github.com/settings/developers)\n   (**OAuth Apps**, not \"GitHub Apps\").\n2. Click **New OAuth App**\n3. Fill in the application details:\n   - **Application name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - **Homepage URL**: `http://localhost:3000`\n   - **Authorization callback URL**: `http://localhost:8787/gatekeeper/github/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n4. Click **Register application**\n\nGitHub enables expiring user tokens by default for OAuth apps registered since August 2026\n(**Optional features** in the app's settings). The gatekeeper supports both kinds: it refreshes an\nexpiring token shortly before its eight hours run out, and asks the user to reconnect only once\nGitHub rejects the refresh token itself (unused for six months, or revoked).\n\n### Step 2: Generate a Client Secret\n\nOn the app's settings page after registration:\n\n1. Click **Generate a new client secret**\n2. Copy the **Client ID** and the generated **Client secret** — you'll need both in the next step.\n\n### Step 3: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-github/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 4: (Optional) Enable GitHub sign-in\n\nTo offer \"Continue with GitHub\" on the login page, add `github` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nUsers are keyed by their GitHub primary verified email, so the OAuth App must be able to read it\n(the `user:email` scope, requested automatically). No extra setup is needed for an OAuth App.\n\n### Step 5: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitHub resource type: repository, issue, or pull request.\n6. If prompted, connect a GitHub account.\n7. You should be redirected to GitHub's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the repository, issue, or pull request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitHub resource scope.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Event hooks (optional)\n\nGadgets can subscribe to a repository's issue, pull request, comment, review, push and tag events,\nor to one issue's or pull request's (`subscribe()` in `src/types.d.ts`). GitHub delivers them through\na repository webhook, so this needs a public URL:\n\n1. Set `WEBHOOK_ORIGIN` on this worker to the public origin GitHub should deliver to, e.g.\n   `https://gadgets.example.com`. Without it, hooks are refused and everything else works as\n   before.\n2. If the deployment sits behind Cloudflare Access, add a bypass for\n   `/gatekeeper/github/webhook/*`; the worker accepts only deliveries signed with a webhook's\n   secret instead.\n\nEnabling a hook adds a webhook to the repository, delivering to\n`${WEBHOOK_ORIGIN}/gatekeeper/github/webhook/<id>`, so GitHub allows it only if the connected\naccount is an admin of the repository. An account's hooks on one repository share its webhook,\nwhich is deleted when the last of them is disabled, or when the account is disconnected. Should\nGitHub fail to delete it, the hourly check tries again, and a disconnect that leaves it fails,\nkeeping the account connected, with the token that can delete it, until disconnecting again finds\nit gone. Each webhook signs its deliveries with a secret only GitHub and this deployment hold; a\ndelivery of a payload already received, or of an event more than a day old, is ignored; and an\nevent is delivered only while the account can still read the repository, since a webhook outlives\nits creator's access.\n\nThe repository's other admins can see the webhook, though not its secret. Every hour the worker\nchecks each webhook on GitHub, and restores one that someone has deleted, deactivated, pointed\nelsewhere, switched to form-encoded payloads or unverified TLS, or given other events, another\nsecret or none: to stop the deliveries, disable the hooks. The same check moves them to a new\n`WEBHOOK_ORIGIN` within the hour. GitHub never retries a failed delivery itself, so the same check\nasks it to redeliver what it failed to deliver in the past two hours: up to 20 an hour for each\naccount, shared among its repositories so that a busy one can't crowd out the rest. That includes\nwhat was refused while the webhook was misconfigured, by this worker or by another receiver, as\nGitHub redelivers to the webhook as it is configured now. Failures beyond that, or older than two\nhours, are lost, as is anything another receiver accepted while the webhook pointed there. A\ndelivery that reaches the worker is retried until the gadget's hook accepts it, eight attempts in\nall. When every recent delivery failed, as when Cloudflare Access turns GitHub away, the check logs\n`hooks.webhook.deliveries.failing` with the statuses GitHub got.\n\nFor local development, GitHub cannot reach `localhost`: run a tunnel to the dev server (e.g.\n`cloudflared tunnel --url http://localhost:8787`) and set `WEBHOOK_ORIGIN` to its origin in the\nroot `.dev.vars`.\n\nWhat this relies on GitHub for is checked against GitHub itself by `pnpm test:contract`\n(`__tests__/contract`), which the `github-contract` workflow runs weekly and on demand once the\n`CONTRACT_GITHUB_TOKEN` secret and `CONTRACT_GITHUB_REPO` variable name a disposable private\nrepository (the token fine-grained, with access to that repository alone; see the workflow):\n- a reader without access gets a 404, whatever ETag it sends;\n- a redelivery uses the webhook's current URL, encoding and secret;\n- the delivery log is newest first;\n- the account is the sender of what it does;\n- each event's payload parses.\n\n## Using a GitHub App instead\n\nIf you must use a **GitHub App** (client id `Iv…`) rather than an OAuth App, be aware:\n\n- GitHub Apps **ignore the OAuth `scope` parameter**. Permissions are fixed in the App's\n  configuration and apply to every user authorization, so sign-in cannot be limited to minimal\n  scopes — it grants whatever the App is configured for.\n- To read the user's email for sign-in, you must grant the App the **Email addresses** account\n  permission: App settings → **Permissions & events** → **Account permissions** → **Email\n  addresses → Read-only** → save. Existing users must then re-authorize (re-run the sign-in flow) to\n  approve the added permission. Without it, the email lookup fails with `Resource not accessible by\n  integration` and sign-in is rejected.\n\nFor these reasons an **OAuth App is recommended** for sign-in.\n\n## Troubleshooting\n\n### \"Resource not accessible by integration\" error\n\nYou're using a **GitHub App** that lacks the **Email addresses** account permission, so the email\nlookup required for sign-in is forbidden. Either switch to an OAuth App (recommended) or grant the\nApp the Email-addresses permission and re-authorize — see [Using a GitHub App\ninstead](#using-a-github-app-instead).\n\n### \"redirect_uri_mismatch\" error\n\nThe callback URL in your OAuth app settings doesn't match what the app is sending. Double-check that you set it to exactly `http://localhost:8787/gatekeeper/github/oauth` (no trailing slash, `http` not `https`).\n\n### \"bad_verification_code\" error\n\nThe authorization code has expired or already been used. Return to Gadgets and try connecting again.\n\n### \"Not configured\" page during authorization\n\nYour `CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at `packages/gatekeeper-github/.env` and contains both values, then restart the dev server.\n","packages/gatekeeper-gitlab/README.md":"# Gatekeeper GitLab\n\nThis package provides GitLab OAuth integration for Gadgets, for gitlab.com and for self-hosted\nGitLab instances. It serves two purposes:\n\n- **Sign-in:** when `gitlab` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitLab\" appears on the login page. Sign-in requests only the `read_user` scope to read the\n  account's **confirmed primary email**, which becomes the user's identity. The sign-in grant is\n  transient (discarded right after the email is read).\n- **Connections:** when a user connects GitLab (or signs in and later connects it), the `api` and\n  `write_repository` scopes are requested so gadgets can read and manage projects, issues, and\n  merge requests, and pull from and push to repositories, on the user's behalf.\n\nIt is a mirror of the GitHub gatekeeper: the same three resource granularities (project, issue,\nmerge request), the same worktree integration (commit ids advertised, `gitPull()` over git\nsmart-HTTP, `push()` as a queued action), and the same review and merge flows expressed in GitLab's\nown terms. See `plans/gitlab-gatekeeper.md` for the design record.\n\n`gitPull(oids, options)` returns a `GitPullTransfer`: a one-shot raw PACK stream and a separate\nterminal status. Lazy DO reads consume it through the bounded object cache. The same capability\nsupports native Git hydration, but does not enable it here: the container consumer ships in a\nseparate kernel worktree-replica bootstrap change. That consumer must bind the bootstrap to an\nauthorized immutable commit, request its complete tree at depth one, and import the pack without\npopulating the DO cache. `gitPull` is kernel-facing, not a gadget-facing arbitrary Git proxy;\nthe kernel selects objects from recorded provenance, not a provider-side advertisement validator.\nNo encoded Git commands, ref discovery, or arbitrary clone URLs cross this interface. OAuth and\noptional Cloudflare Access credentials remain in the gatekeeper's transport, including on\nself-hosted instances.\n\n## Auto-approval\n\nAs in the GitHub gatekeeper, every queued action carries a stable kind (`gitlab.<action>`), and\nevery one may be auto-approved once the user opts in to its kind, including closing and reopening,\nresolving and reopening diff threads, `requestChanges` reviews and force pushes (under their own\n`gitlab.forcePush` kind). **Merging a merge request** and **an approving review** always wait for\na person: an approval shares the `gitlab.postReview` kind with the other reviews but is never\nauto-approvable. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit.\n\n## Setting up GitLab OAuth credentials\n\nIf you're running this project locally and want to use GitLab integrations, you'll need to create\nyour own GitLab OAuth application.\n\n### Step 1: Create a GitLab application\n\n1. On GitLab, go to **User settings → Applications** (or, to share the application with a team,\n   a group's **Settings → Applications**).\n2. Fill in the application details:\n   - **Name**: anything (e.g. \"Gadgets Local Dev\")\n   - **Redirect URI**: `http://localhost:8787/gatekeeper/gitlab/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n   - **Confidential**: checked\n   - **Scopes**: `api`, `write_repository`, `read_user`\n3. Save the application, and copy the **Application ID** and **Secret**.\n\n> **Why `write_repository` when `api` is \"everything\"?** GitLab's documentation grants Git over\n> HTTPS to OAuth tokens only through the `read_repository`/`write_repository` scopes; `api` covers\n> the REST API. Pushing commits from a worktree needs `write_repository`, so both are requested.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-gitlab/.env`):\n\n```bash\nCLIENT_ID=your-application-id-here\nCLIENT_SECRET=your-application-secret-here\n```\n\nAlternatively, export `GITLAB_CLIENT_ID` and `GITLAB_CLIENT_SECRET` in your shell; `pnpm dev-server`\nmaps them into the worker's `CLIENT_ID`/`CLIENT_SECRET`.\n\nTo develop against a self-hosted instance, set `GITLAB_URL` (and `GITLAB_API_URL`,\n`CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` as the instance needs -- see\n[Self-hosted instances](#self-hosted-instances)) in the root `.dev.vars` or your shell;\n`pnpm dev-server` passes them through to the worker, so nothing tracked has to change.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable GitLab sign-in\n\nTo offer \"Continue with GitLab\" on the login page, add `gitlab` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,gitlab\n```\n\nUsers are keyed by their GitLab primary email, which GitLab only makes primary once confirmed. An\naccount whose primary email is unconfirmed cannot sign in.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitLab resource type: project, issue, or merge request.\n6. If prompted, connect a GitLab account.\n7. You should be redirected to GitLab's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the project, issue, or merge request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitLab resource.\n\n## Self-hosted instances\n\nBy default the gatekeeper talks to `https://gitlab.com`. Point it at another instance with these\nvariables on the worker:\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `GITLAB_URL` | The instance users visit: the OAuth authorization page, links in results, and the URLs of connectable resources. | `https://gitlab.com` |\n| `GITLAB_API_URL` | The origin the Worker sends requests to (REST, token endpoints, git over HTTPS), when it differs from `GITLAB_URL`. | `GITLAB_URL` |\n| `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` (secrets) | A Cloudflare Access **service token**, attached to every Worker→GitLab request as `CF-Access-Client-Id`/`CF-Access-Client-Secret`. Set both or neither. | unset |\n\nBoth URLs must be `https` origins (scheme and host, no path): every request to the API origin\ncarries a user's token, and the OAuth exchanges carry the client secret. Plain `http` is accepted\nonly on `localhost`, `127.0.0.1` or `[::1]`, for a GitLab run on your own machine. A GitLab\nserved under a relative URL root (`https://example.com/gitlab`) is not supported; the worker\nrefuses the URL at its first request rather than sending tokens to the wrong path.\n\nThe typical Access-protected layout is `GITLAB_URL=https://gitlab.example.com` (behind Access for\nbrowsers) and `GITLAB_API_URL=https://gitlab-access.example.com` (a hostname whose Access\napplication admits the service token). The Access application must admit the token on the\n`/api/v4/*`, `/oauth/*`, **and** `/<group>/<project>.git/*` paths — the last is where git fetch and\npush go, and a policy scoped to the API alone makes worktree pulls fail with a login page.\n\nRegister the OAuth application on the instance itself, with the same redirect URI and scopes as\nabove.\n\nA review that requests changes needs GitLab 19.2 or later: earlier versions' review-publishing\nendpoint ignores the reviewer state without an error, so on them the gatekeeper refuses a\n`requestChanges` review rather than post it as a plain comment. Comment and approve reviews work\non every version.\n\n## Worker Preview OAuth callbacks\n\nA Worker Preview's hostname cannot be registered with the GitLab application, so a deployment\nthat tests on previews registers one **stable** callback and relays the result to the preview\nthat started the flow (`@gadgets/gatekeeper-kit/preview-oauth`, as `gatekeeper-google` does).\nGive the stable Worker and its previews the same `OAUTH_STATE_SIGNING_SECRET` and\n`OAUTH_ALLOW_PREVIEW_REDIRECTS=true`, and set the fixed redirect on previews only:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-gitlab.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the application's redirect URI. A preview sends that URI to\nGitLab and carries its own callback in signed, short-lived state; the stable Worker accepts\nreturn URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only GitLab's\nresult and the state. The preview then exchanges the code with the same redirect URI it\nauthorized under. Normal deployments omit all three settings and use `${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Previews\ndo not inherit the stable Worker's secrets: set `CLIENT_ID`, `CLIENT_SECRET`,\n`OAUTH_STATE_SIGNING_SECRET` and, for an Access-protected instance, `CF_ACCESS_CLIENT_ID` /\n`CF_ACCESS_CLIENT_SECRET` on the stable Worker (`wrangler secret put`) **and** in its Previews\nBase configuration (`wrangler preview base-config secret put`); a preview created afterwards\nreceives them.\n\n## Troubleshooting\n\n### \"The GitLab project has been renamed or transferred\"\n\nThe project a connection was bound to has moved. GitLab answers the old path with a redirect that\nthe gatekeeper deliberately does not follow (following it would turn writes into reads). Re-bind\nthe connection to the project's new path.\n\n### \"GitLab credentials have expired or been revoked\"\n\nGitLab access tokens last two hours and are refreshed automatically; the refresh token behind them\nis single-use and rotates on every refresh. When GitLab refuses an access token the gatekeeper\nfirst refreshes past it. This error means the refresh token itself was rejected (`invalid_grant`)\n— typically because the application was revoked in GitLab, or the account sat idle past the\ninstance's refresh-token lifetime. Reconnect the account. A token endpoint that is unreachable,\nanswers 5xx, or is turned away by Cloudflare Access fails the request with \"Could not refresh\nGitLab credentials\" instead and leaves the connection intact.\n\n### \"GitLab credentials were renewed during this request\"\n\nGitLab invalidates an access token when it issues its replacement, so a request in flight while\nthe gatekeeper refreshed can be refused. Reads are rerun once with the new token; anything else\nfails with this message and is safe to retry.\n\n### The redirect URI does not match\n\nThe **Redirect URI** on the GitLab application must be exactly\n`<PUBLIC_BASE_URL>/gatekeeper/gitlab/oauth` (no trailing slash, `http` for local dev) -- or, on a\nWorker Preview, the stable Worker's `OAUTH_REDIRECT_URI` (see above).\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at\n`packages/gatekeeper-gitlab/.env` and contains both values, then restart the dev server.\n\n### \"GitLab did not answer the request: the API redirected to …\"\n\nEvery REST call is answered with a redirect to a login page: the instance is behind Cloudflare\nAccess and the Access application in front of `GITLAB_API_URL` does not admit the gatekeeper's\nservice token, or `CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET` are not set on the worker (see\n[Self-hosted instances](#self-hosted-instances)). Distinct from the rename message above: the\ngatekeeper tells them apart by where the redirect points.\n\n### git fetch or push fails with a redirect or an HTML page\n\nThe same cause on the `.git/` paths: the Access application admits the service token for\n`/api/v4` but not for the repository, or the token is not configured.\n\n### \"This collaborator does not have read access to the GitLab project …\"\n\nSomeone sharing a workspace is not a member of the project it is bound to at **Reporter** or\nabove — or is a member only at Guest. Being able to open the project in GitLab is not enough,\neven on an `internal` or `public` one: a project can hold confidential issues and internal notes\nthat only members at those roles see, and the workspace may have read them. The remedy is for a\nproject maintainer to add the collaborator as a Reporter (inherited group membership counts).\n","packages/gatekeeper-google/README.md":"# Gatekeeper Google\n\nThis package provides Google OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `google` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Google\" appears on the login page. Sign-in requests only minimal scopes (`openid`,\n  `userinfo.email`, `userinfo.profile`) to read the account's **verified email** (`email_verified`),\n  which becomes the user's identity.\n  The sign-in grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects Google (or signs in and later connects it), the scopes for\n  the selected resources (Gmail, Docs, Sheets, Slides, Drive, Calendar, or BigQuery — see below) are requested\n  so gadgets can access those APIs on the user's behalf.\n\nA single Google OAuth client is used for both. Set it up as follows.\n\n## Setting Up Google OAuth Credentials\n\nIf you're running this project locally and want to use Google API integrations, you'll need to create your own Google OAuth credentials. This guide walks you through the process step-by-step.\n\n### Step 1: Create a Google Cloud Project\n\n1. Go to the [Google Cloud Console](https://console.cloud.google.com/)\n2. Sign in with your Google account\n3. Click the project dropdown at the top of the page (it may say \"Select a project\" or show an existing project name)\n4. Click **New Project** in the top-right of the popup\n5. Enter a project name (e.g., \"Gadgets Local Dev\")\n6. Click **Create**\n7. Wait for the project to be created, then select it from the project dropdown\n\n### Step 2: Enable Required APIs\n\nYou'll need to enable the Google APIs that you want to use. Currently supported: Gmail, Google Docs, Google Sheets, Google Slides, Google Drive, Google Calendar, Google Chat, and BigQuery.\n\n1. In the left sidebar, go to **APIs & Services** > **Library** (or [click here](https://console.cloud.google.com/apis/library))\n2. Search for \"Gmail API\"\n3. Click on **Gmail API** in the results\n4. Click **Enable**\n5. Go back to the Library, search for \"Google Docs API\"\n6. Click on **Google Docs API** in the results\n7. Click **Enable**\n8. Go back to the Library, search for \"Google Drive API\"\n9. Click on **Google Drive API** in the results\n10. Click **Enable**\n11. Go back to the Library, search for \"Google Sheets API\"\n12. Click on **Google Sheets API** in the results\n13. Click **Enable**\n14. Go back to the Library, search for \"Google Slides API\"\n15. Click on **Google Slides API** in the results\n16. Click **Enable**\n17. Go back to the Library, search for \"Google Calendar API\"\n18. Click on **Google Calendar API** in the results\n19. Click **Enable**\n20. Go back to the Library, search for \"BigQuery API\"\n21. Click on **BigQuery API** in the results\n22. Click **Enable**\n23. For Chat, enable **Google Chat API**, plus **People API** so direct messages and group chats can be named when Chat omits a participant's name, and so a whole-account Chat connection can search the organization's directory and confirm that everyone in a conversation it starts belongs to the organization.\n24. On the Google Chat API's **Configuration** tab, set an app name, avatar URL, and description, turn off **Interactive features**, and click **Save**. Reads work without this, but Google refuses every Chat send, edit, and reaction until a Chat app is configured.\n\nThe Google Drive API powers the Docs, Sheets, and Slides resource pickers, Drive discovery, and Drive scope checks. Native document, spreadsheet, or presentation content opened from a Drive binding is read through the Google Docs, Google Sheets, or Google Slides API. Direct Google Doc reads and edits still go through the Docs API, direct spreadsheet reads and edits go through the Sheets API, and direct presentation reads and edits go through the Slides API.\n\n### Step 3: Configure the OAuth Consent Screen\n\nBefore creating credentials, you must configure how the consent screen appears to users.\n\n1. In the left sidebar, go to **APIs & Services** > **OAuth consent screen** (or [click here](https://console.cloud.google.com/apis/credentials/consent))\n2. Select **External** as the user type (unless you have a Google Workspace organization and want to restrict to internal users only)\n3. Click **Create**\n4. Fill in the App Information:\n   - **App name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - Details are largely optional / irrelevant here, since this app will run it testing mode.\n   - Click **Save and Continue**\n5. On the Scopes page, you can just click **Save and Continue** without adding anything. The scopes are specified by the OAuth request itself, not the console configuration. (The console's scope UI is only relevant if you later want to publish your app for Google's verification review.)\n\nThe scopes requested depend on what the user is doing. **Sign-in** requests only the identity\nscopes (`openid`, `userinfo.email`, `userinfo.profile`). **Connecting** Google for capabilities\nrequests scopes granularly per resource type, not all at once: connecting a Gmail mailbox asks\nonly for the Gmail scopes, a Google Doc only for the Docs scopes, and so on (identity is always\nincluded). Across all resource types, the gatekeeper can request:\n\n- `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account.\n- `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending.\n- `documents` for direct Google Docs reads, edits, and creation; `documents.readonly` for native Docs opened from account-wide, folder, or exact-file Drive bindings.\n- `drive.metadata.readonly` for the Docs, Sheets and Slides pickers. Google classifies this as a restricted scope.\n- `drive.readonly` for account-wide, folder, and exact-file Drive bindings: the Drive picker, discovery, file metadata, folder descendant proofs, native-file scope checks, and downloading a PDF to read its text with `readPdf()`. Google classifies this as a restricted scope too, so every Drive resource here needs restricted-scope verification.\n- `drive.readonly` and `drive.file` for the Office import grant (`https://docs.google.com/import`), which converts an Office file into a Google Docs, Sheets or Slides copy (see [Office files](#office-files)). Only the connect dialog's **Convert** button asks for it, by incremental consent, the first time an account converts a file; using a copy made before reads only metadata and asks for nothing. Once held, it is part of the account's recorded grants, so a reconnect asks for it again. `drive.readonly` lets the gatekeeper see a source file this OAuth client did not create, which `drive.file` alone cannot, and `drive.file` lets it create the copy. The grant is private to the gatekeeper: it is not a resource, so it is never offered on connect, listed, bound, or shown among an account's granted resources, and admins cannot turn it off separately from Docs, Sheets and Slides. Once held, `drive.readonly` lets the account's token read any Drive file, so bindings stay limited by the gatekeeper's code rather than by the token. A verified OAuth app must add both scopes to its consent screen, and `drive.readonly` is a restricted scope.\n- `drive.readonly` for the shared-drives grant (`https://drive.google.com/drive/shared-drives`), which lets the Drive picker list the shared drives the account belongs to, since Drive's `drives.list` accepts no narrower scope. Only the picker's **Allow access** button asks for it, by incremental consent, the first time an account opens Shared drives; once held, a reconnect asks for it again. It is private to the gatekeeper as the Office import grant is, and an account holding that grant is still asked once, since a private grant counts only once it has been asked for, not because its scopes happen to be held.\n- `spreadsheets` to read metadata, bounded cell ranges and their formatting from directly selected spreadsheets, edit their values, sheets, rows, columns and formatting with approval, and create spreadsheets. The switch from `spreadsheets.readonly` retracted every existing Sheets grant, including one derived from `drive.readonly`: owners re-consent the next time they connect a spreadsheet, and collaborators observing an existing Sheets binding are asked to grant read-write `spreadsheets` the next time they open its workspace. A `spreadsheets` grant covers the Drive resources' `spreadsheets.readonly`.\n- `spreadsheets.readonly` to read native Sheets opened from account-wide, folder, or exact-file Drive bindings.\n- `presentations` to read, edit, and create directly selected presentations, and to render slide thumbnails; `presentations.readonly` to read and render native Slides opened from account-wide, folder, or exact-file Drive bindings. Google counts each thumbnail as an expensive read, limited to 60 a minute per user and 300 per project.\n- `calendar.calendarlist.readonly` so the resource picker can list calendars.\n- `calendar.events` to manage selected calendar and check calendar availability.\n- `chat.spaces.readonly`, `chat.messages`, and `chat.memberships.readonly` for every Chat resource. A whole-account Chat connection adds `chat.users.readstate.readonly` for its unread-only search, and `chat.spaces.create` and `directory.readonly` to start direct messages and group chats with people in the connected account's Workspace directory. Starting a conversation is its own approval kind, separate from sending in an existing one, and people outside the organization can't be added to a new conversation.\n- `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries.\n\n### Step 4: Test Users\n\nThis is important! While your app is in \"Testing\" mode (which it will be by default), only users you explicitly add here can use OAuth.\n\n1. Click **Add Users**\n2. Enter your own Google email address (the one you'll use to test Google API integrations)\n3. Click **Add**\n4. Click **Save and Continue**\n5. Review the summary and click **Back to Dashboard**\n\n### Step 5: Create OAuth Credentials\n\n1. In the left sidebar, go to **APIs & Services** > **Credentials** (or [click here](https://console.cloud.google.com/apis/credentials))\n2. Click **Create Credentials** at the top\n3. Select **OAuth client ID**\n4. For **Application type**, select **Web application**\n5. **Name**: Enter anything (e.g., \"Gadgets Local\")\n6. Under **Authorized redirect URIs**, click **Add URI** and enter: `http://localhost:8787/gatekeeper/google/oauth`\n7. Click **Create**\n\nA popup will appear with your **Client ID** and **Client Secret**. Keep this window open or copy these values somewhere safe.\n\n### Step 6: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-google/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here.apps.googleusercontent.com\nCLIENT_SECRET=your-client-secret-here\n```\n\nReplace the values with the credentials from Step 5.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 7: (Optional) Enable Google sign-in\n\nTo offer \"Continue with Google\" on the login page, add `google` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nSign-in only needs the identity scopes, which are always available, so no extra Google setup is\nrequired. (While the app is in Testing mode, the signing-in user must still be listed as a Test\nUser — see Step 4.)\n\n### Step 8: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Slides Presentation, Google Drive, Google Calendar, or BigQuery.\n6. If prompted, connect a Google account.\n7. You should be redirected to Google's consent screen in a new tab.\n8. The consent screen acts extra-scary since this is an \"unverified\" test app.\n9. After granting access, the tab closes, and you're back to Gadgets.\n10. Use the picker to choose the mailbox scope, document, presentation, project, dataset, or table to connect, or for Google Drive, the whole of Drive, a folder, or a single file.\n11. Create the connection. Ask the agent what it can do, or ask it to write a gadget using the new binding.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Gmail attachments\n\nNew messages, replies, forwards, and their draft-creation methods accept\n`attachments?: GmailAttachmentInput[]`. Each input is a regular file with a filename,\nMIME media type, and exact `ArrayBuffer` bytes; strings, base64 inputs, inline uploads,\nand multipart container uploads are not supported.\n\nFor example, given an exported PDF as `pdfBytes: ArrayBuffer`:\n\n```ts\nusing draft = await gmail.createDraft({\n  to: [\"recipient@example.com\"],\n  subject: \"Cloudflare at a Glance\",\n  text: \"Attached is the one-page overview.\",\n  attachments: [{\n    filename: \"cloudflare-at-a-glance.pdf\",\n    mimeType: \"application/pdf\",\n    content: pdfBytes,\n  }],\n});\n```\n\nFor an existing draft, `draft.addAttachment(input)` returns a readable, removable\n`GmailDraftAttachment`. `draft.attachments()` lists `{info, attachment}` entries, and\n`entry.attachment.remove()` removes that exact part, even when filenames are duplicated.\nDispose attachment capabilities when finished. `draft.update()` preserves attachments;\nit does not replace their list. Message attachment capabilities remain read-only.\n\nReplies include only supplied files. Forwards preserve the original attachments and\ninline parts, then append supplied files. A forward draft's preserved source parts are\nimmutable; its added files can be removed.\n\nFiles may be empty, but each upload is limited to **10 MiB**. Filenames must be nonempty\nand contain no path separators or control characters; `.` and `..` are refused.\nThe upload's `filename` is authoritative: redundant MIME `name`/`name*` parameters are\ndiscarded, while other media-type parameters such as `charset` are preserved.\nOutgoing MIME is limited to **25 MiB**, including transfer encoding and message headers.\nThe binding's existing **50 MiB** retained-file quota is shared by uploads and forward\nsource snapshots, including uploads in active drafts.\n\nUploaded bytes are captured in the existing integrity-checked chunk store, not embedded\nin queued-action metadata. Pending reads expose the files before approval, and body edits\nand Durable Object restarts preserve their exact bytes. Approval descriptions include\nfilename, media type, size, and SHA-256, with uploaded bytes marked as incomplete for\nhuman review. Files remain retained while a pending action or active draft references\nthem and are released when the last reference is removed. Rejecting a prerequisite\ninvalidates dependent edits; reject those edits too to release their references.\n\nCalendar files retain their original line endings and charset rather than being normalized.\nNested `message/rfc822` files also retain their exact bytes, including trailing line endings,\nthrough edits and sends; duplicate `.eml` filenames and identical files remain separate parts.\nProvider draft rewrites correlate attachment identities by metadata and SHA-256 in the actual\nMIME order, so queued edits and removals still select the right part when inline parts move.\nAn imported attachment without a disposition keeps its identity when a draft rewrite\nemits the regular `attachment` disposition.\n\nDraft and send fingerprints are prefixed `v2:` and hash attachment bytes exactly. Approvals\nqueued before that upgrade keep their unprefixed fingerprints and still apply, including a send\nwhose response was lost, which is reconciled rather than sent again. See\n`plans/gmail-simulation.md` for how they are verified.\n\n## Google Chat and Gmail new-message hooks (optional)\n\nGadgets can watch a Chat conversation for new messages (see `docs/google-chat-capabilities.md`),\nand a Gmail inbox, label or thread for new mail (`subscribeNewMessages()` in `src/types.d.ts`).\nGoogle delivers both through Pub/Sub, Chat's by Workspace Events and Gmail's by `users.watch`, so\nthis needs a public URL and these steps in the same Cloud project as the OAuth client:\n\n1. Enable the **Google Workspace Events API**, the **Cloud Pub/Sub API**, and the **Gmail API**.\n2. Create a Pub/Sub topic for Chat, granting `chat-api-push@system.gserviceaccount.com` the\n   **Pub/Sub Publisher** role on it, and one for Gmail, granting\n   `gmail-api-push@system.gserviceaccount.com` the same role. One topic granting both may serve\n   for both.\n3. Create a service account for push authentication (it needs no roles). Whoever creates the push\n   subscriptions needs `iam.serviceAccounts.actAs` on it (e.g. **Service Account User**). Pub/Sub\n   signs push tokens as its service agent, `service-PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com`,\n   which needs `iam.serviceAccounts.getOpenIdToken` on this account. Its default **Cloud Pub/Sub\n   Service Agent** role on the project includes that; only if it lacks the permission, grant it\n   **Service Account Token Creator** on this service account.\n4. Create a **push** subscription on each topic with endpoint\n   `${BASE_URL}/pubsub` (e.g. `https://example.com/gatekeeper/google/pubsub`), **Enable\n   authentication** with the service account from step 3, and audience set to that same endpoint.\n5. Set these values for this worker (in `.env` locally), both topics to the same one if it serves\n   for both:\n\n   ```bash\n   PUBSUB_CHAT_TOPIC=projects/your-project/topics/your-chat-topic\n   PUBSUB_GMAIL_TOPIC=projects/your-project/topics/your-gmail-topic\n   PUBSUB_PUSH_SERVICE_ACCOUNT=your-push-account@your-project.iam.gserviceaccount.com\n   ```\n\nIf the deployment sits behind Cloudflare Access, add a bypass for `/gatekeeper/google/pubsub`;\nthe worker instead accepts only pushes whose Google-signed token names that endpoint as audience\nand `PUBSUB_PUSH_SERVICE_ACCOUNT` as sender. That token proves a push came through a push\nsubscription, not who published to its topic, so each topic's Pub/Sub principals are trusted with\nhooked messages: anyone who can subscribe to it reads them all, and anyone who can publish to it\ncan inject messages into hooks of either kind, since a push is routed by its shape rather than\nits topic. Grant those roles to no one beyond step 2. A Gmail push carries only a mailbox address\nand a history ID, so a forged one can only make the worker read that mailbox's new history with\nthe connected account's own credentials. Without its topic and the push service account a kind of\nhook is refused, and everything else works as before.\n\nDisconnecting a Google account drops its hooks from the mailbox's and each space's driver, so\nnothing goes on reading or renewing with credentials that are gone; a watch or subscription no\nhook uses any more lapses, as when the hooks are disabled.\n\n## Worker Preview OAuth callbacks\n\nDeployments using Worker Preview hostnames can register one stable Google callback and relay the\nresult to the preview that initiated authorization. Configure the stable Worker and its previews\nwith the same `OAUTH_STATE_SIGNING_SECRET` and `OAUTH_ALLOW_PREVIEW_REDIRECTS=true`. Set the fixed\nredirect only on previews:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-google.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the authorized redirect URI in Google. The preview sends that fixed\nURI to Google and carries its own callback in signed, short-lived OAuth state. The stable Worker\naccepts return URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only the OAuth\nresult and state. Normal deployments should omit these settings and continue using\n`${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Wrangler\nstores baseline and Preview secrets separately, so provision the same signing value in both places.\n\n## Creating files\n\nGoogle Docs, Sheets and Slides are creatable resource types: an agent can ask for a new one with\n`createExternalResource`, giving a one-line title (trimmed, at most 256 characters), and its binding\nworks at once, before anything exists at Google. Until the creation is approved the gatekeeper\nsimulates an empty file: a Doc reads as one empty tab and queues edits as usual, a spreadsheet as\none empty `Sheet1` of 1000 × 26 cells, which takes no change until it exists, and a presentation as the one Google creates, a 16:9 deck\nwith a title slide, which queues changes as usual. Of the default theme's layouts it offers only\nfive (title slide, section header, title and body, title only, and blank); the others become\navailable once the presentation exists. Nothing reaches Google and no account is involved.\n\nThe approver picks which of their connected Google accounts to create the file in; it lands in\nthat account's My Drive, and the binding switches to the real file. Doc edits queued against the\nsimulation then apply to the created document like any other edit, and Slides changes apply to the\ncreated presentation unchanged.\n\nThat works for Slides because Google gives every new presentation the same object IDs (master\n`simple-light-2`, title slide `p`, and layouts `p2` to `p12`, seen both on a personal account\nthrough the API and on a Workspace account in the editor), so the simulation reads a recording of\none, `src/blank-presentation.json`: each read a session makes, through the session's own field\nmasks, less the presentation's ID, title and revision and the layouts it does not offer.\n`pnpm --filter @gadgets/google-gatekeeper record:blank-presentation <token-file>` records it again;\nthe file holds an access token with the `presentations` and `drive.file` scopes. It creates two\npresentations, refuses to record unless they read the same, and deletes them; with `--check` it\ncompares them with the committed recording instead. Should Google's new presentation change, a\nqueued change naming a slide, element or layout the created presentation lacks fails when\napproved, writing nothing.\n\nGoogle's create calls take no idempotency key. A retried approval after a lost reply binds the file\nalready created (the gatekeeper records it), but a file can still be orphaned in the approver's\nDrive: a create request that times out or fails with a 5xx after Google acted, a crash between\nGoogle's reply and that record being written, or a creation rejected after an approval whose\nsuccess was lost. Every created file's ID is logged (`google.creation.file.created`), so an orphan\ncan be found.\n\n## Office files\n\nGoogle's Sheets, Docs and Slides APIs cannot edit an Office file stored in Drive (`.xlsx`, `.xls`,\n`.xlsm` and `.ods`; `.docx`, `.doc`, `.odt` and `.rtf`; `.pptx`, `.ppt` and `.odp`): Sheets and\nSlides refuse to open one, and Docs reads one but refuses every edit to it. So the Docs, Sheets and\nSlides pickers list these files too, named with their extension and marked \"Makes a Google …\ncopy\", and connecting one converts it. The connect dialog says what converting does and offers\n**Convert**, which has Drive copy the file into the native type (`files.copy` with the native MIME\ntype: Google converts it, and no file content passes through the Worker). The copy is named after\nthe original without its extension, lands next to it when the original's folder takes new files and\nat the root of My Drive otherwise, and is what gets connected. The original is never changed, and later edits\nto it do not reach the copy. The conversion is Google's, and loses what Google's import loses, such\nas macros, some formulas, and complex layout.\n\n**Convert** asks for the Office import grant (see the scopes above) when the account does not yet\nhold it. Workshop opens Google's consent pop-up, and the dialog waits up to three minutes for the\ngrant before giving up. Each copy carries app properties naming its source (`gadgetsConvertedFrom`)\nand the source's modification time when it was copied (`gadgetsSourceModified`), so choosing the\nsame file again offers the copy made before instead of making another; using it reads only Drive\nmetadata, so it needs no new grant. App properties are private to the OAuth client that set them\nbut not to a user, so only copies this deployment's client made are found, and of those only ones\nthe account can edit; that may include a copy another user made in a folder they both edit. The\nlookup is best effort: a missed match makes another copy. A copy request is never retried, since a\nlost reply may still have made one. A file whose owner has turned off copying, a file in the trash,\nor one larger than Google imports into that type (`about.maxImportSizes`) is refused before\nanything is copied.\n\nA pasted link to an Office file fails to connect, and its error offers to open the connect dialog,\nwhere it can be converted. An Office file bound any other way is refused with a message pointing to\nthe dialog: by Sheets and Slides when they cannot open it, and by Docs on connecting, since Docs\nreads it. An Office file already bound as a Doc answers each approved edit with the same message.\n\n## Google Docs request costs\n\nConnecting a Doc reads its Drive metadata once, to refuse an Office file (see\n[Office files](#office-files)). A read costs one full `documents.get`, replayed for 10 seconds and then revalidated by a\nrevision-only `get`. Queuing an edit costs nothing more. Applying one costs a full `get` (the\nedit is materialized against the current revision), the `batchUpdate` carrying the edit and its\nwrite marker, and a second `batchUpdate` removing the marker; the document is reread after it only\nwhen more edits are queued behind it, since they must be replayed over the new revision.\n\n## Known limitations\n\nVery large Google Docs can exceed Durable Objects' 2 MB value limit after Markdown conversion,\ncausing tab listing, content reads, and edits to fail.\n\nA Google Slides presentation's slide summaries come from one response capped at 10 MiB. It holds the\ntext of every slide's shapes and speaker notes, but no styles or geometry: about 4 KiB a slide on a\nlive deck, so a presentation needs thousands of slides to exceed it. It also holds every master's\nand layout's name and background and the masters' theme colours, a few KiB in all. Slide content is\nread one slide at a time, capped at 2 MiB each. While changes await approval, reads also fetch the\nslides they address, so each can be checked as approving it would: slide content reads the other\nslides of any batch touching a slide it shows, since a batch applies all or none, and the summaries\nread every slide a queued batch changes, since they hold no tables or grouped shapes. Large batches\nawaiting approval therefore cost reads more requests against Google's per-user quota.\n\nThe simulated presentation was recorded from an English-locale account, so until it is created it\nreads with locale `en` and English layout names. Google translates those names: a presentation a\nSpanish-language account made in the editor read locale `es-419` and layout names such as\n`Título y cuerpo`, with the recording's object IDs, placeholders and geometry. One created through\nthe API in a non-English account has not been checked, so an approver's presentation may name its\nlayouts differently from what the agent saw before approval, including in an approval card queued\nthen. `createSlide()` takes a layout's ID, never its name.\n\n## Google Slides reads\n\n`getSlides()` reads each element as the Slides editor shows it: its box and rotation, and what it\nsets rather than inherits. Shapes carry their text and its formatting (links to URLs or to slides,\nfont weight, paragraph indents and bullets), and their fill, its opacity, and their outline with its\ndash. Tables carry their cells, column widths, row heights and borders, collapsed to one `border`\nwhen every edge matches. A table's `size` is nominal, 3,000,000 EMU square on every live read, so\nits box is its columns and rows added up; a row's height is the least it may have, since Google\ndraws it taller to fit its text and reports that nowhere. Lines carry their ends, computed from\ntheir transform, arrowheads, and the elements they connect; videos their YouTube or Drive ID; and\nimages and Sheets charts their source URL and spreadsheet. The `contentUrl` an image or chart also\nhas is a bearer URL, like a thumbnail's, so it is never read. `getPresentation()` lists the\npresentation's layouts with their placeholders, for `createSlide()`.\n\n## Google Slides edits\n\nA `.pptx` or other Office presentation cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound presentation accepts six changes, each queued for approval: `updateSlides()`,\n`duplicateSlide()`, `deleteSlide()`, `moveSlides()`, `createSlide()`, and `setSlidesSkipped()`.\nChanges are journaled with the kit's `ActionJournal`, and apply in the order they were queued.\n\n`updateSlides()` takes up to 50 changes applied together: find-and-replace or whole-text\nreplacement in shapes, table cells, and speaker notes, text and paragraph formatting (including\nbullets), shapes and their fill, outline and vertical alignment, moving, resizing, rotating,\nstacking and deleting elements, alt text, images by public `https:` URL, and tables with their\nrows, columns and cell fills. Elements a batch creates get IDs the gatekeeper mints: later changes\nin the batch can address one by its `ref`, and later batches by the ID returned for it. An image\nURL is never fetched by the gatekeeper: Google downloads it when the change is applied, so nothing\nis read from it before approval.\n\nTwo of its changes reach beyond a slide's elements:\n\n- `setBackground` sets the background of a slide, a layout or a master, named by exactly one of\n  `slideId`, `layoutId` and `masterId`: a colour, `\"none\"`, a picture by public `https:` URL, or\n  `null` to inherit the background of the layout or master the page is made from. A master has\n  nothing to inherit from, and Google turns a reset there into no background, so `null` is refused\n  for one. As with images, Google, not the gatekeeper, downloads a picture URL when the batch is\n  applied. Reads report a picture background only as `\"picture\"`, and never request its content\n  URL, a short-lived URL that reads as the account that fetched it.\n- `setThemeColors` sets some of a master's 12 theme colours. Google takes all 12 at once, so the\n  colours not given are merged in from the master's scheme as the batch is planned against its\n  fresh read: a colour a collaborator changed meanwhile is kept, and one changed after that read\n  makes Google refuse the revision-pinned write, so it is planned again.\n\n`getPresentation()` lists the masters, with their backgrounds and theme colours, and the layouts,\nwith their master and background, but not their elements; each slide reports its layout, its master\nand a background of its own. The Slides API cannot change which layout an existing slide is made\nfrom.\n\nA batch is queued as one of three action kinds, which share everything but which ones a user may\nlet apply without asking: \"Slide text edits\" (only text edits), \"Slide formatting and arrangement\"\n(only formatting text, setting no link or font, since Google keeps their whole strings; formatting\nparagraphs and shapes; moving or stacking elements; and setting slides' backgrounds to a colour,\n`\"none\"` or `null`), and every other batch, mixed ones included, which always waits for approval.\nA picture background, any change to a layout or master, and theme colours therefore always wait:\nthey change slides the user is not looking at, or make Google fetch a URL.\n\n`createSlide()` adds a slide made from one of the presentation's layouts, at the start, the end, or\nafter a given slide. The gatekeeper mints the slide's ID and one for each placeholder it gets from\nthe layout, read from the layout's page when the change is queued, so later changes can fill the\nplaceholders before it is approved. Reads show the new slide with the layout's placeholders, empty,\nat the layout's size and position, as a live probe showed Google makes them, but for a slide\nnumber: Google adds one only while the presentation shows slide numbers, which a new one does not,\nand otherwise ignores its mapping, so none is minted and a slide number appears once the slide\nexists (`instantiatedPlaceholders()` in `slides-simulation.ts`).\nGoogle takes a new slide's layout only from the master of the slide before it, of the first slide\nwhen it goes first, or of the presentation's first master when it has no slides, so a deck with\nslides imported in another theme has layouts that fit only some places: layouts and slide\nsummaries carry their master's ID, and a layout that does not fit where it is to go is refused\nwhen queued, and again on approval against a fresh read, as is one deleted since; approval also\nrefuses one that has lost a placeholder the slide was to get. Google deletes a master and its\nlayouts with the last slide on it, unless it is the presentation's first master, as a live probe\nshowed, so reads drop a master whose last slide a queued deletion removes, with its layouts, and\na slide queued after it on one of those layouts is refused. Google names a new slide's speaker notes\nonly as it creates the slide, so they cannot be edited until it is approved. Adding a slide always\nwaits for approval.\n\n`setSlidesSkipped()` skips slides, leaving them out when presenting, or shows them again. It is\nthe \"Skipping slides\" kind, which a user may let apply without asking: it destroys nothing, and\nis undone the same way. It applies against the presentation's outline alone, reading no slide.\n\nReads show queued changes as if applied, by replaying them over Slides' own JSON before it is\nprojected; thumbnails show the presentation as saved. Each change is replayed as Google documents\nits request, and one the replay cannot follow exactly is refused instead: a bullet list started\nright after another list item, which Google may join to that list; table rows or columns inserted\nor deleted beside a merged cell; deleting a grouped element that would leave its group with one;\nrotating a video, which Google cannot shear; and new text filling a list item whose bullet is\nstyled apart from its text, which Google would restyle to match. The replay makes no claim about\nwhat Google renders: shrinking text to fit, wrapping, the box Google fits an image to, the borders\naround new or deleted table rows and columns, and the formatting new rows and columns take appear\nonce applied. Table rows and columns are sized as live reads show Google sizing them: a new table\nshares its box evenly, a new row or column copies the one beside it, every column then narrows so\nthe table keeps its width, and the lines a deletion leaves keep theirs. It does follow Google in\nturning a shape's autofit off once its text changes, fixing the font sizes and line spacings the\ntext sets at what the autofit had shrunk them to. A replacement takes the style of the text it\nreplaces, and text left unchanged at either end of a match is not rewritten, so it keeps its own.\nFormatting a whole list paragraph also updates its bullet's style in replay, which later\nreplacements use when checking whether they can keep that style. Setting a font sets it at\nregular weight, so the text is no longer bold unless the change says so. A slide number or other\nAutoText can only be replaced whole.\n\nEach approved change is one `batchUpdate`, planned against a fresh read and pinned to its revision\nwith `requiredRevisionId`, so a change applies only to the text it was planned against: one that no\nlonger applies fails without writing, and a concurrent edit makes Google refuse the write so it is\nplanned again. A write whose response is lost may have been committed, so it is only ever resent as\nfirst sent, at the same revision. If that is refused, a read decides whether it landed, counting\nonly what the write would have changed: an element it created that it does not also delete, an\nelement it deleted that was there before, text it edited, a table it adds or deletes rows or columns\nof, by its size and its cells' positions, spans and text, a theme colour it set, and a background\nother than a picture it left a page with, that the page did not already have (every picture reads\nalike). If the read cannot tell, as for a batch that only formats or moves elements or leaves a page\na picture, the change is marked as having an unknown outcome and never retried.\n\nGoogle returns a presentation's revision only to an account that can edit it, so a view-only\naccount can read a presentation but every change to it fails. A queued change is stored in one\nDurable Object value, so each is capped at 100 KiB.\n\n## Google Sheets edits\n\nAn `.xlsx` or other Office spreadsheet cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound spreadsheet accepts `updateSpreadsheet()`, which queues up to 50 changes for\napproval as one batch, applied all or none and in order: `writeCells` enters values in every cell\nof a bounded A1 range that names its sheet, such as `'Sales 2026'!A1:C3`, and `clearRange` clears\na range's contents, keeping its formatting. Batches are journaled with the kit's `ActionJournal`, and\napply in the order they were queued. A spreadsheet opened from a Drive binding has no change method.\n\nInput is literal and typed, so nothing depends on the spreadsheet's locale: a string starting with\n`=` is a formula, any other string is text exactly as given, never read as a number or date,\nnumbers and booleans are entered as such, and `null` clears a cell. A date is entered as its\nserial number in a cell formatted as a date, as `formatCells` can format it. Text starting with\n`=` cannot be written as text, and formula-mode reads show it exactly as a formula, so writing\nback what was read can turn it into one. A batch of only literal values and clears is queued as\n\"Sheet value edits\", which a user may let apply without asking; a batch with any formula always waits for approval.\nA formula naming `IMPORTRANGE`, which reads another spreadsheet and so escapes the binding, or\n`IMPORTDATA`, `IMPORTHTML`, `IMPORTXML`, `IMPORTFEED` or `IMAGE`, which make Google fetch a URL, is\nrefused in any letter case, outside string literals. Formulas the spreadsheet already holds are\nnot inspected, and one of its named functions could wrap a refused function, which is why every\nformula needs approval.\n\nReads show queued changes as entered and never guess what Google computes. Formula-mode reads are\nexact, and raw reads are exact for literal values. A queued formula's result, and in formatted\nreads a queued number or boolean, whose display depends on number format and locale, read `null`,\nand their cells are listed in the range's `pendingCells`. Formulas elsewhere keep their saved\nresults, even when they depend on queued cells. Google tidies the references in a formula as it is entered: column\nletters upper-cased, a sheet named by its title as the spreadsheet spells and quotes it, a range of\none cell collapsed to that cell. A queued formula is shown, approved and written already tidied, but\nwith its whitespace as typed, which Google may later drop. A queued change that no longer applies,\nsuch as one writing to a sheet since shrunk, is reported as `queuedChangeConflict`, and it and the\nchanges queued after it are not shown.\n\nThe same batch can change a spreadsheet's structure: `addSheet`, `renameSheet`, `duplicateSheet`\nand `deleteSheet` act on its sheets, and `insertRows`, `deleteRows`, `insertColumns` and\n`deleteColumns` on a sheet's rows and columns. A change names its sheet by title in `sheet`, as a\nrange does, including a title an earlier change of the batch gives a sheet it adds, copies or\nrenames, so it never needs an ID the spreadsheet has not assigned yet. The gatekeeper mints the ID\nof each sheet a batch adds. A batch with any structural change always waits for approval.\nEvery default is sent explicitly, so the approver sees what will be made: a new sheet has Google's\n1,000 rows and 26 columns and comes last, and a copy is titled `Copy of <title>` and placed right\nafter its source (Google would put it first, under a name of its own choosing). Changes are refused\nwhen queued that would delete the last visible sheet, every row or column of a sheet, or every one\nof its unfrozen ones, delete a sheet of more than 50,000 cells, whose content the guard below could\nnot cover (delete it in Google Sheets instead), grow a sheet past 18,278 columns or the spreadsheet\npast 10,000,000 cells, or change or copy a sheet holding a protected range the account cannot edit. Reads show the\nqueued structure: sheets, rows and columns where the changes put them, cells moved with their rows\nand columns, and formulas rewritten as Google rewrites them, references shifted, grown, shrunk or\nturned into `#REF!`, and sheet titles requoted. A formula's value is shown only while every\nreference it holds keeps exactly its cells; one whose reference grows, shrinks or breaks, or that\ncalls a position-dependent function such as `ROW`, `OFFSET` or `INDIRECT`, or `QUERY`, whose query\nnames columns by letter, or uses a name such as a named range, or calls a function the\ngatekeeper does not know to depend only on its arguments (one of the spreadsheet's named functions\nmay hide references or such calls), reads `null` and is listed in `pendingCells`, as are cells a\nqueued formula enters. Cells of inserted rows and columns and of\nadded sheets read as empty. Deleting a sheet leaves the formulas that refer to it as written, as\nGoogle does; they read `#REF!` once it applies, and stay so even if a later sheet takes the title.\nOnce rows, columns or sheets are queued, cells Google fills with nothing entered in them, such as an\narray formula's results (`SORT`, `SEQUENCE`, `FILTER`, `QUERY`) or a pivot table, read `null` and\nare listed in `pendingCells`.\n\n`formatCells` formats every cell of a range, keeping its contents: bold, italic, underline,\nstrikethrough, font size, text and fill colour (`#rrggbb` or a theme colour such as `ACCENT1`),\nnumber format (a type and an optional pattern in Google's syntax), horizontal and vertical\nalignment, wrapping, and borders along the range's edges and between its rows and columns. A field\nset to `null` is reset to the default, and a border set to `null` is removed; fields left out are\nkept. A batch of only `formatCells` changes is queued as \"Sheet formatting\", which a user may let\napply without asking; mixed with any other change it waits for approval. `readFormats()`, which a\nspreadsheet opened from a Drive binding offers too, reads the formatting set on up to 10,000 cells\nof a range, never their values, and shows queued formatting merged in queue order. Cells of rows\nand columns a queued change inserts, whose formatting Google inherits from a neighbour in ways that\ndiffer between rows and columns, read `null` and are listed in `pendingCells`. A border along a\nrange's outer edge removes the facing border of the cell beside it, since Google gives an edge one\nowner, and reads show that. A formatted value read of a cell whose number format a queued change\nsets or resets reads `null` too, listed in that range's `pendingCells`, unless a queued change\nleaves the cell empty: a format such as `;;;` can hide what a blank-looking cell holds. So does a\nvalue queued on an inserted row or column, which takes the number format of the line before it.\nGoogle accepts any font size and number pattern, so the gatekeeper's checks are the only ones: a font size is an\ninteger from 1 to 400, and a pattern holds 1 to 200 characters and no control characters.\nFormatting is not guarded: a collaborator's formatting change made after a batch is queued is\noverwritten when it applies.\n\nSheets has no revision to pin a write to, so each approved batch is planned against a fresh read\nand guarded by a digest of what it overwrites or removes: the cells it writes and the rows,\ncolumns and sheets it deletes, as entered, the title and size of each sheet it addresses, and,\nwhen it adds or copies a sheet, the order of every sheet, ignoring whitespace in formulas. Cells\nthat changes queued before it write are expected to hold what those enter, and sheets those\nrestructure to have the shape they leave, so a batch built on one that was rejected or failed fails\ntoo, as does a batch with a formula when any structural change queued before it was not applied,\nand a batch adding or copying a sheet when a sheet queued before it to be added, copied or deleted\nwas not. A batch guards at most 50,000 cells. A change whose cells or sheet\nchanged since it was queued fails without writing; a collaborator's edit between that read and the\nwrite is not caught. Each batch also creates a developer-metadata marker whose ID is minted at queue\ntime, which Google stores at most once, so a write whose response is lost is found by its marker or\nresent exactly as first sent. A lost write that cannot be resent, because its cells changed in the\nmeantime or Google refused the resend, or that three sends leave unfound, is marked as having an\nunknown outcome. A marker is visible only to the Google Cloud project of this deployment's OAuth\nclient, leaves no Drive revision, and is deleted by the first write at least ten minutes after its\nbatch was last sent, since a send given up on may commit until then. While a write with an unknown\noutcome could still land, later changes stay pending.\n\nA change is refused when it is queued if the connected account can only view the spreadsheet, or\nif it writes outside a sheet's grid or into a protected range the account cannot edit. A change\naddresses at most 10,000 cells and a batch 20,000, a cell holds at most 50,000 characters, the\nchanges queued at once may set values or formatting in at most 100,000 cells in all (a cell given\nboth counting twice, a copied sheet counting what changes queued to it set) and make at most 50\nchanges to rows, columns and sheets, and a queued batch is capped at 100 KiB. While such changes are\nqueued, a read or a batch may involve at most 5,000,000 characters of formulas times their number,\nsince each formula is rewritten through each of them, and hold at most 5 MiB of text as rewritten.\nGoogle allows 60 write requests a minute per user; a write over that quota stays pending.\nuser; a write over that quota stays pending.\n\n## Google Drive read-only bindings\n\nDrive exposes three permanent resource URL forms:\n\n- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: any ID the account token resolves is in scope. Listings use `corpora=user`, which Google defines as My Drive items the account created or opened plus items shared directly with it, so a shared drive's contents may be readable by ID without appearing in a listing. Bind a folder or file when this authority is too broad.\n- `https://drive.google.com/drive/folders/<folderId>` selects one folder or shared-drive root.\n- `https://drive.google.com/file/d/<fileId>/view` selects one file by its immutable ID.\n\nThe Drive picker opens on Google Drive, whose own choice binds the whole account, above My Drive, Shared drives and Shared with me. Locations list a page at a time. Search spans My Drive, \"Shared with me\" and every shared drive the account is a member of (`corpora=allDrives`) and says where each result lives, and a Drive, Docs, Sheets or Slides link pasted into it answers with the one folder or file the link names. A pre-filled folder or file opens where it lives, with breadcrumbs up to My Drive or its shared drive, or with none when an ancestor is out of reach or more than 20 folders up. All of this runs on the Drive resources' own grant except listing Shared drives, which also needs the shared-drives grant above: until the account holds it, opening Shared drives offers **Allow access**, and a shared drive's folders stay reachable through search, a pasted link or a pre-filled URL. Folders whose children the account cannot list are shown but cannot be opened or bound; on a pre-filled node's breadcrumbs, where Google's [folders with limited access](https://developers.google.com/workspace/drive/api/guides/limited-expansive-access) put them above a file shared directly, they can be opened but are not offered. A shortcut stands for its target, which a listing says nothing of, so a page's shortcut targets are read in one batch: a shortcut left behind when its target was deleted, unshared or trashed is left out, and one to a folder is judged as that folder would be. A search Google reports as incomplete fails rather than presenting partial results as complete.\n\nA folder URL carrying `?resourcekey=` is not supported: the key is dropped, and the binding then fails with a Drive 404 for anyone whose access to that folder comes from the link rather than from a direct grant. Google requires resource keys only for items shared by link before September 2021, and only for link-access users — an owner or anyone granted access directly is unaffected, even when the URL they paste happens to carry a key. Such a folder needs direct access to connect.\n\nThe agent-facing `GoogleDriveReadSession` covers account and exact-file bindings. `GoogleDriveFolderSession` is positioned at the selected root and exposes only its current folder's direct children: `list()`, provider-side structured `search()`, `getEntry()`, native Doc/Sheet/Slides opens, and `openFolder()` for one live direct child. Listing and search return disposable RPC cursors; child folders and native content sessions are independently disposab\n\n<!-- [TRUNCATED AT 50KB] -->","packages/gatekeeper-homeassistant/README.md":"# Gatekeeper Home Assistant\n\nThis package provides a [Home Assistant](https://www.home-assistant.io/) gatekeeper for Gadgets. It lets a Gadget read state from a connected HA instance, call services on devices (turn lights on/off, set thermostats, lock doors, etc.), edit Lovelace dashboards, render templates, and read history — all mediated through the standard Gadgets approval queue.\n\n## Authentication\n\nThe gatekeeper uses Home Assistant's **long-lived access token (LLAT)** + **URL** combination. There's no OAuth flow because:\n\n- Each HA instance has a different URL (no central directory).\n- LLATs don't expire (10-year default lifetime).\n- LLAT is the path every other HA-adjacent tool uses, so users are familiar with it.\n- It works for both Nabu Casa Cloud users and self-hosted users on a LAN.\n\nWhen the user connects an account, they paste their HA URL and an LLAT into a form. The gatekeeper validates by calling `GET /api/`, then stores both in the per-user Durable Object.\n\n### Reachability\n\nThe gatekeeper makes HTTP and WebSocket calls to the configured HA URL. On a Cloudflare-hosted Gadgets deployment, that means HA must be publicly reachable (via Nabu Casa, Cloudflare Tunnel, port-forwarding, etc.). On a self-hosted (workerd) deployment, LAN addresses like `http://homeassistant.local:8123` and `http://192.168.x.x:8123` work fine — that's the intended setup for users running Gadgets on the same network as their HA.\n\n## Resource granularities\n\nFive granularities are exposed; each comes with its own configurator UI for picking a specific resource from the connected HA instance:\n\n| Granularity   | What it grants                                                      |\n| ------------- | ------------------------------------------------------------------- |\n| Whole instance | Access to every area, device, entity, dashboard, and service.       |\n| Area          | A single area (room) — its devices and entities only.               |\n| Label         | All entities carrying a particular HA label.                        |\n| Device        | A single physical device and the entities it provides.              |\n| Entity        | A single entity (light, sensor, switch, etc).                       |\n\n## TypeScript API\n\nBindings expose one of `HomeAssistantSession` (whole-instance), `Area`, `Label`, `Device`, or `Entity`, depending on the granularity granted. Common operations:\n\n```ts\n// Whole-instance:\nconst config = await session.getConfig();\nconst areas = await session.listAreas();\nconst lights = await session.listEntities({ domain: \"light\" });\n\n// Capability-based:\nconst light = await session.getEntity(\"light.kitchen\");\nawait light.turnOn({ brightness: 200 });\nconst state = await light.getState();  // reflects the simulated post-write state\n\n// Area-scoped service call (affects every entity in the area):\nconst livingRoom = await session.getArea(\"living_room\");\nawait livingRoom.callService(\"light\", \"turn_off\");\n\n// Templates:\nconst temp = await session.renderTemplate(\"{{ states('sensor.outside_temp') | float }}\");\n\n// Dashboards:\nconst dashboard = await session.getDashboard(\"lovelace\");\nconst dashboardConfig = await dashboard.getConfig();\n// ... mutate the JSON ...\nawait dashboard.saveConfig(dashboardConfig);\n```\n\nSee `src/types.d.ts` for the complete API and `@example` blocks for every method.\n\n## Approval & simulation\n\nEvery read calls `authorizeObservation` and every write goes through `submitAction`. Writes do **not** execute against HA until the user approves them.\n\nUntil approval, reads reflect a **simulated post-action world**: e.g. after `entity.turnOn()`, an immediate `entity.getState()` shows `state: \"on\"` even though HA hasn't been touched. This lets agents chain reads and writes without waiting for user approval. Simulation predicts final states only — no transition timing, and unrecognized service calls (custom integrations, scenes, scripts, templates) leave state untouched.\n\nSee the **APPROVAL & SIMULATION** section at the top of `src/types.d.ts` for the full list of caveats.\n\n## Phase 2 — not yet implemented\n\n- **Caching.** Every read does a fresh registry fetch. Once caching is added, area / device / label-scoped reads will be much cheaper.\n- **Hooks (push events).** `setHook` is a no-op. WebSocket `subscribe_events` / `subscribe_entities` would enable a `HomeAssistantHook` interface — e.g. a Gadget receiving a callback when a motion sensor fires.\n\n## Implementation notes\n\n- **Service calls go through the WebSocket API.** HA's REST `POST /api/services` endpoint expects target fields flattened at the top level of the body and has uneven support for area / label / floor targets across versions. The WebSocket `call_service` command is HA's modern path and supports the full target shape natively.\n- **All actions get an integer `id`** assigned by a per-DO counter. Pending actions are stored under `pending:<id>` in DO storage so reads can simulate the post-action state.\n- **Defensive validation.** Malformed action bodies (e.g. an agent passing a single options object instead of positional arguments) fail synchronously with a rich error message that includes a suggested corrected call.\n\n## Files\n\n```\nsrc/\n├── homeassistant.ts        # main: Vendor, UserAccount, User, GatekeeperImpl, Session impls\n├── homeassistant-api.ts    # REST + WS clients (incl. timeouts and error-body sanitization)\n├── approvals.ts            # describeAction, executeAction, applyRevertForEntity, ...\n├── simulation.ts           # pure overlay-at-read-time helpers\n├── registry-utils.ts       # shared resolveTargets helper\n├── types.d.ts              # public Session/Area/Label/Device/Entity/Dashboard interfaces\n└── configurator/           # 5 picker UIs (instance / area / label / device / entity)\n```\n","packages/gatekeeper-kit/AGENTS.md":"# Gatekeeper Kit contributor notes\n\n`@gadgets/gatekeeper-kit` is a library, not a deployable Worker. Do not add a `cloudflare.config.ts`:\nthe `wrangler.jsonc` generated from it is what makes release tooling treat this package as a\ngatekeeper deployment.\n\n## Package boundaries\n\n- Only Layer 1 leaf modules are shipped. Layer 2 remains a proposal in\n  [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md).\n- Keep leaf modules independently usable. Do not make one depend on a future assembly layer.\n- Add a leaf, or a leaf option, only for behavior several gatekeepers share under one contract.\n  Behavior only one gatekeeper has belongs in that gatekeeper. Support it with a plain TypeScript\n  hook or callback on the leaf, or by implementing the canonical interface by hand. Do not add a\n  variant for a single provider.\n- Accept the narrowest structural KV surface a module needs. Pass stable `ctx.storage.kv` objects to\n  modules that coordinate work by storage identity.\n- Treat shipped storage keys and prefixes as compatibility. Use existing key and prefix options when\n  porting a gatekeeper with a different layout.\n- Public modules are explicit subpath exports in [`package.json`](package.json). Document new public\n  symbols with JSDoc and add the subpath to the README inventory.\n\n## Documentation ownership\n\n- [`README.md`](README.md) maps the package and its modules.\n- [`USAGE.md`](USAGE.md) owns cross-module integration guidance and operational sharp edges.\n- Exported-symbol JSDoc owns the exact API contract visible in editors.\n- [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md) is the design record, including the\n  unshipped Layer 2 proposal. Do not duplicate that proposal into current usage documentation.\n\n## Verification\n\nUse Node tests for pure logic. Use the workerd project for persisted RPC stubs, `RpcTarget`,\nDurable Object behavior, and `crypto.subtle.timingSafeEqual`.\n\nFrom the repository root:\n\n```sh\npnpm --filter @gadgets/gatekeeper-kit test:run\nvp run -F @gadgets/gatekeeper-kit build\n```\n","packages/gatekeeper-kit/README.md":"# `@gadgets/gatekeeper-kit`\n\nShared building blocks for gatekeeper connect flows, credentials, actions, observations, cursors,\ncaching, and simulation. These modules replace security-sensitive plumbing that gatekeepers had\nimplemented separately.\n\nThe kit provides a pragmatic baseline for common gatekeeper behavior. Provider-specific or esoteric\nbehavior can use the canonical TypeScript interfaces directly while retaining whichever leaf\nmodules still fit.\n\n## Current scope\n\nOnly Layer 1 is shipped: independent leaf modules exposed through package subpaths. Import them à la\ncarte; none requires a gatekeeper assembly.\n\nLayer 2, including `KitUserAccountBase`, `KitVendorBase`, and `KitGatekeeperBase`, remains a proposal\nin [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md). No gatekeeper consumes it.\n\nThe code and tests define the shipped behavior. The plan records the design and the unshipped\nproposal.\n\n## Responsibility boundary\n\nThe kit owns provider-independent mechanisms inside each imported module. The gatekeeper owns\nprovider facts, policy, and the assembly around those modules. Importing a leaf does not transfer\nthe duties in the right-hand column.\nWorkshop owns connect completion ticket minting and redemption.\n\n| Concern | Kit owns | Gatekeeper owns |\n| --- | --- | --- |\n| Assembly and lifetime | Independent leaf contracts; no Layer 2 assembly is shipped. | Worker, account, and resource-facet RPC surfaces; stub disposal; keeping stateful kit objects stable for one Durable Object activation. |\n| Connect | Nonce generation and comparison, the two-stage handshake, staged reconnect escrow, hardened handoff HTML, and browser mutation guards. | Provider exchange, staged payload shape, exact live commit, provider cleanup policy, routes, and authorization parameters. |\n| Preview OAuth | Signed state, stable-to-preview callback relay, and return-host validation. | Deployment configuration, provider callback parameters, issuer checks, and retaining the exact redirect URI for code exchange. |\n| Credentials | Atomic credential records, refresh coalescing, identity and connection generations, replay, and rejection adjudication. | Grant shape and projection, the refresh callback (see OAuth token endpoint), and display-safe errors. |\n| Credential expiry and simple auth retry | Durable deduplication of expiry notifications and one-refresh/one-replay helpers. | Deciding what proves grant expiry beyond an OAuth `invalid_grant`, provider refresh and revoke calls, and choosing the coordinator flow versus the standalone retry helper. |\n| OAuth token endpoint | Token-request hardening, response parsing, PKCE, the default `invalid_grant` death rule, and the canonical refresh merge. | Endpoints and client authentication, provider death evidence beyond `invalid_grant`, non-canonical merges, and non-RFC revocation. |\n| Actions | Durable submission and resolution, serialization, journaling, retention mechanics, connection fences, and dependency tracking and refusal. | Approval text, provider calls, idempotency and reconciliation, action-specific simulation, revert semantics, and retention policy. |\n| Action files | Bounded chunk storage, integrity verification, aggregate accounting, deletion, and orphan-pruning mechanics. | Byte limits and key prefixes, keeping references in action records, describing the same bytes that will be applied, and releasing files with their records. |\n| Simulation | Ordered pending-action views, pure replay with explicit incomplete results, and durable provisional-ID allocation and binding. | Target extraction, state transitions, unsupported-effect policy, provider ID syntax, and projecting pending effects onto every affected read. |\n| Observations | Admission strategies, tracked-collection fencing, exclusion derivation, and the guarded authorization call. | Calling the gate for every read, truthful escaped descriptions, choosing the matching strategy and scope, ACL oracles, and collection canonicalization. |\n| Cursors | Serialized walks, buffering, a per-call provider fetch bound, common page/offset/token continuation, merging injected simulated rows, filtering hooks, and per-page authorization callbacks. | Provider page adapters, page limits and termination semantics, the injected rows and their order, resource lifetime, and the observation description for each returned page. |\n| Cache | Authority partitioning, stale-fill fencing, TTL storage, single-flight loads, and invalidation. | Cache families, names and keys, authority dimensions, TTLs, value projection, and deciding which reads are safe to cache. |\n| HTTP endpoints and responses | Operator-supplied endpoint normalization, dot-segment-safe path-segment encoding, common no-access probes, byte-capped text decoding, and best-effort error-body prefixes. | Host allowlists, redirect and header policy, provider id grammar, response schemas, provider errors, and binary or streaming limits. |\n\nWhen a provider does not fit a leaf, implement the canonical TypeScript interface directly and keep\nthe other leaves that still fit. Bypassing a leaf also means owning its guarantees for that concern;\ndo not call around a stateful module while relying on its journal, fence, or lifetime elsewhere.\n\n## Start here\n\n- For an OAuth-shaped provider, start with [Connect flows](USAGE.md#connect-flows), then read the\n  [credentials guide](USAGE.md#credentials). They cover browser handoff sequencing, account-side\n  storage, consumer-side RPC, refresh, replay, expiry, and action fences.\n- For provider writes, use [`./actions`](#module-inventory) and read\n  [Actions and files](USAGE.md#actions-and-files).\n- For every gatekeeper's observer methods, select a strategy from `./observers` and read\n  [Observations](USAGE.md#observations).\n- For details attached to one class, function, option, or error, read that export's JSDoc.\n\nImport from the narrow subpath:\n\n```ts\nimport {\n  CredentialCoordinator,\n  CredentialSource,\n} from \"@gadgets/gatekeeper-kit/credentials\";\n```\n\n## Module inventory\n\n| Subpath | Purpose | Use it when |\n| --- | --- | --- |\n| `./connect-nonce` | Nonce generation, expiry, and constant-time comparison. | A connect flow mints or checks its own nonce. The handshake and credential modules already use it. |\n| `./connect-handshake` | Two-stage `initiation` to `oauth` nonce storage, and the once-only `complete()` marker. | A connect link or form redirects through an OAuth provider, or has no single-use nonce to keep `complete()` from running twice. |\n| `./credential-stage` | Durable reconnect escrow, exact commit, and exact-stage cleanup. | A reconnect must remain inert until Workshop commits the completed stage. |\n| `./connect-pages` | Hardened connect HTML, escaping, and browser mutation guards. | A gatekeeper serves HTML from its own origin. |\n| `./credentials` | Account-side `CredentialCoordinator` and consumer-side `CredentialSource`. | An OAuth-shaped provider stores, refreshes, or rejects credentials. |\n| `./credential-expiry` | Durable, deduplicated `credentialsExpired()` notification. | An account has a Workshop connect callback to notify. |\n| `./auth-retry` | One refresh and replay without account adjudication. | A token flow has no `CredentialSource`; otherwise use `CredentialSource.run()`. |\n| `./cache` | Authority-partitioned Durable Object TTL caching. | Provider reads repeat and reconnects must fence stale fills. |\n| `./single-flight` | Keyed coalescing of concurrent work, without caching results. | Concurrent callers must share one in-flight request or write, such as two approvals that would each create the same resource. |\n| `./serial-queue` | FIFO serialization of async operations; a failed one never blocks the next. | Operations on shared state must not interleave, such as preparing changes that each check those queued before them. |\n| `./cursors` | Array, page-number, offset, and continuation-token cursors, with injected simulated rows (`CursorInjection`); `PageHookCursor`, which runs a hook on each page of a cursor the gatekeeper already built; and `SessionGitCache`, a git session's `GitCache` stub holder that advertises returned commit ids. | A session returns more rows than one RPC reply should carry. |\n| `./actions` | Action declaration, approval, application, retention, and journaling. | An operation has an externally visible side effect. |\n| `./action-files` | Bounded, integrity-checked action-file storage. | A queued action carries file bytes. Store only its `ActionFileReference` in the action. |\n| `./action-description` | Approval-text builder: prose plus exact typed fields under one byte budget, and the `descriptionIsComplete` claim. | An action's `describe` renders what it will write or send. |\n| `./simulation` | Pending-action replay and provisional-ID mapping. | An action continues with simulation and later reads must include its projected effect. |\n| `./observers` | Observer admission strategies and per-read authorization. | A gatekeeper implements its required observer methods. |\n| `./hook-delivery-queue` | Per-hook queue of pushed events: deduplication, retry with backoff, and bounded delivery runs from the driver's alarm. | A hook driver delivers provider pushes through `HookInitiator.startHook()`. |\n| `./oauth-client` | OAuth 2.0 token-endpoint client, PKCE, and refresh adapter. | A gatekeeper exchanges, refreshes, or revokes OAuth 2.0 tokens. |\n| `./preview-oauth` | Signed OAuth state and stable-to-preview callback relay. | Preview Workers share one callback registered with the OAuth provider. |\n| `./endpoint` | User-supplied provider endpoint normalization, and agent-supplied path-segment encoding (`pathSegment`, `pathSegments`). | A user enters a self-hosted provider URL, or an agent-supplied id goes into a request path. |\n| `./http-errors` | HTTP access-error classification and ACL probes. | A verifier distinguishes no access from provider failure. |\n| `./response-body` | Strict byte-capped body decoding, and `readTextPrefix` for error excerpts. | A gatekeeper reads any provider response body, or a connect form posted to a route that reads the body before checking the nonce. |\n| `./git-transport` | Git smart-HTTP framing: protocol-v2 fetch (pkt-line, filter specs, sideband demux into a raw pack stream) and send-pack ref updates, plus `simulateBranchHead` for a branch with queued pushes. | A gatekeeper implements `Gatekeeper.gitPull()` or a `push` action against a git host; it supplies only the URL, auth, and timeouts as a fetch callback. |\n| `./git-pull-transfer` | `startGitPull`: a one-shot raw PACK stream with a separate RPC-visible terminal status. | A gatekeeper implements `Gatekeeper.gitPull()` and must preserve provider errors across Workers RPC. |\n| `./git-objects` | Commit-id validation, raw commit-object parsing, and commit advertising (`advertiseCommits`, and `advertisePages` as a `PageHookCursor` hook). | A session returns commit ids (advertise them) or simulates reads of commits queued for push from their local bytes. |\n| `./git-diff` | Tree-to-tree diff over an injected `TreeDiffSource`, and `parseUnifiedPatch` for provider patches, both producing the unified-patch hunk shape agents see. | A pull/merge request's source branch has queued pushes and the provider cannot compute the diff yet, or a provider returns unified patches. |\n\n## Internal modules\n\nThe package does not export `kv`, `positive-int`, `per-storage`, `action-journal`, or\n`observer-tracker`. The last two are re-exported through `./actions` and `./observers`.\n\n## More documentation\n\n- [`USAGE.md`](USAGE.md): integration sequencing, storage, bounds, and operational sharp edges.\n- Exported-symbol JSDoc: exact API contracts and examples.\n- [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md): design record and Layer 2 proposal.\n- [`AGENTS.md`](AGENTS.md): package-specific contributor constraints and verification commands.\n","packages/gatekeeper-mcp-portal/README.md":"# MCP Server Portals gatekeeper\n\nConnects the deployment's own MCP portal as a Gadgets capability. An administrator configures one\nURL; everyone in the organization then reaches every MCP server the organization has approved\nthrough it, and no user ever types an endpoint. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_MCP_PORTAL` binding.\n\nThe intended deployment is a\n[Cloudflare MCP server portal](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/):\nAccess decides who may connect, Cloudflare Gateway logs and inspects what crosses, and this\nconnector turns what is behind it into typed capabilities that Gadgets can grant one server at a\ntime.\n\nFor endpoints a user supplies themselves, use [`gatekeeper-mcp`](../gatekeeper-mcp/README.md).\n\n## What it provides\n\nOne resource type, **MCP portal server**, at two grant breadths. A grant always names one upstream\nserver; \"everything the portal offers\" is not offered, since it would hand a Gadget every tool of\nevery system the organization has connected in one click.\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool of one upstream server, including ones it adds later | `<endpoint>#server=github` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools of that server | `<endpoint>#server=github&tool=github_a&tool=github_b` | `Mcp<Name><tag>Session` |\n\n`<tag>` is four hex characters derived from the resource URL. It matters most here: two grants\npinning different tools of one upstream server share both the name and the endpoint, so the scope is\nthe only thing that distinguishes them.\n\nThe session API — a typed method per described tool, plus `callTool`, `getActionResult`, and\n`listTools` with progressive search/name options — is the same as\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).\n\nA server-wide grant can cover more tools than one catalog describes, so its authority is not limited\nto the ones with generated signatures. `listTools({ search })` searches beyond the bounded preview\nand returns up to 20 compact matches within the shared 5,000-tool / 4 MiB discovery scan;\n`listTools({ name })` loads one exact bounded definition, and `callTool` resolves that name under the\nsame bound before dispatch. Every path rejects names outside the\nbinding's scope before loading the catalog or contacting the endpoint. Discovery and read results are\nrecorded as observations; writes retain the ordinary approval flow.\n\nScoping to one server also shrinks what the agent reads: only that server's tools are rendered as\nsignatures, and only as many as the budget describes. Additional tools are discovered on demand\nwithin the explicit scan limits above; exceeding a limit fails rather than pretending a tool is absent.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `MCP_PORTAL_URL` | The portal's MCP endpoint. Unset means the connector hides itself. |\n| `MCP_PORTAL_NAME` | Display name in the connector list and every approval prompt. Defaults to `MCP Server Portal (<host>)`. |\n| `MCP_PORTAL_AUTH` | `oauth` (default), `none`, or `token`. |\n| `MCP_PORTAL_TOKEN` | Secret bearer token, for `MCP_PORTAL_AUTH: \"token\"`. |\n| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default; see below. |\n| `MCP_PORTAL_HIDDEN_SERVER_IDS` | Comma-separated upstream server IDs to hide from the configurator and refuse at the grant boundary. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, for the portal and every OAuth URL discovered from it. Local dev only. |\n\nThe portal must expose upstream tools directly. Use a portal where Code Mode is off or opt-in, or\nappend `?codemode=off` when its policy is default-on. Enforced Code Mode is unsupported. Do not add\nan `optimize_context` parameter or opt in to Code Mode on `MCP_PORTAL_URL`.\n\nOnly `MCP_ALLOW_INSECURE` is set in the repo's `cloudflare.config.ts`, pinned to `\"false\"` so the default\nis explicit rather than merely absent. None of the others is, and a portal URL committed there would\nbecome the default for every deployment of this repo and would send their users' OAuth flows to\nwhichever host it named, so it belongs in the deployment's own configuration — for Cloudflare's\ninternal deployment, the per-package overrides in `gadgets-internal`.\n\nUnconfigured, `getSupportedResources()` returns nothing and the Workshop drops the vendor. A\n`MCP_PORTAL_URL` that cannot be used — a non-`https` typo or a URL containing `username:password`,\nsay — is treated the same way, so a misconfiguration hides the connector rather than producing one\nthat fails on first use or copying URL credentials into account state and configurator fields.\n\nChanging `MCP_PORTAL_URL` on a deployment that already has connected accounts is a **repoint**.\nExisting bindings fail closed at once: the minting path checks facet props against current\nconfiguration, and an already-minted facet must name its endpoint when asking the account for\ncredentials, which the account refuses after it has moved. The user recovers by reconnecting, which\nis the one endpoint change an account will accept. It is accepted only because the new endpoint\ncomes from this Worker's configuration rather than from a form. Nothing held for the old portal\nsurvives the move: tokens, the transport session, and any in-progress authorization are dropped, so\nthe user re-authorizes against the new host. The account advances a persisted generation before\nprobing; refreshes, expiry notifications, and session writes that started under the old generation\nare ignored when they eventually return. Always-approve action kinds also include the exact endpoint,\nso consent for the old portal does not carry over to the new one.\n\n`MCP_PORTAL_TRUST_ANNOTATIONS` is read at each point of use (`portalTrust(env)`) and never\npersisted on an account or a binding's props, so clearing it de-escalates every existing connection\non the next call. Setting it does not retroactively auto-apply anything; the user must still enable\na rule for each action kind.\n\n## How the connect flow works\n\nThere is no connect form: the endpoint is a deployment setting, so pressing \"connect\" goes straight\nto the portal's own sign-in. Under `MCP_PORTAL_AUTH: \"oauth\"` the gatekeeper runs the same\ndiscovery chain as [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#how-the-connect-flow-works)\nagainst the portal; under `\"token\"` it presents `MCP_PORTAL_TOKEN` and no user interaction is\nneeded; under `\"none\"` it connects unauthenticated.\n\nThe account records `provenance: \"deployment\"`, which is what keeps an upstream server from renaming\nitself over `MCP_PORTAL_NAME` in every approval prompt.\n\n## Granting\n\nThe configurator asks which server behind the portal, how broad the grant is, and — if it is\npinned — which tools:\n\n```\nServer · Which server behind this portal to grant. Its tools appear next.\n[ 🔍 GitHub ]\n\nTools · Choose how much of this server the Gadget may call.\n(•) All tools      Every tool this server offers, including ones it adds later.\n( ) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · Read-only tools return data straight away. The rest queue for your approval.\n[ 🔍 Filter tools...                                                   ]  12 of 12 selected\n☑ List issues                                                                     read-only\n☑ Create issue                                                               needs approval\n```\n\n**All tools** is the default. The breadth is asked outright rather than inferred from whether every\nbox is ticked, since the two look identical and diverge as soon as the server publishes another\ntool. A portal with one server behind it collapses to the tool questions alone, because the choice\nis made for the user rather than skipped — the grant still records that server.\n\nEvery grant here names exactly one upstream server. There is no breadth that spans the portal, so a\nconfigured endpoint with no upstream servers — a plain MCP server, or a portal that currently fronts\nnothing — is **not grantable through this connector**: the configurator has no server to offer and\nthe form stays unsubmittable. That is deliberate. The alternative is a grant recorded against the\nbare endpoint, which would silently include every server added to it later. Point a plain MCP\nendpoint at the **MCP** connector (`gatekeeper-mcp`) instead, which is built for exactly that and\noffers endpoint-wide and per-tool grants.\n\n## How upstream servers are recovered\n\nA portal flattens every upstream server's tools into one `tools/list`, so the seam has to be\nrecovered from two facts in the portal's documented contract:\n\n1. Every tool is named `{server_id}_{original_name}`, split on the first underscore only, and an\n   alias replaces the tool name but never the server-id prefix. Membership is therefore a pure\n   string test needing no network call, so a server scope cannot fail open on a transient error.\n2. `portal_list_servers` is available in every portal session and returns each upstream server's id,\n   name, and enabled state.\n\nDetection is a capability probe — does the endpoint offer `portal_list_servers`? — not a hostname\nmatch, so it works for a custom portal hostname and for any other aggregator adopting the\nconvention. A *truncated* listing counts as a portal whether or not the probe tool is in it:\n`tools/list` is unordered, so concluding \"not a portal\" because the evidence fell past the cut would\nfail open on the `portal_*` exclusion below. Truncation is reported by the client rather than\ninferred from the tool count, because either cap can stop a listing — the count the caller asked for,\nor the 96 KiB UTF-8 budget, and the latter can cut a listing of verbose tools short while leaving an\narray that looks complete. The byte budget leaves 32 KiB below Durable Object's per-value limit for\nthe cache wrapper and serialization overhead; if storage nevertheless rejects the cache value, the\nfresh catalog is still used for that operation rather than turning a cache miss into a failure.\n\n### Surveying a portal too large for one catalog\n\nThe configurator normally gets server names directly from `portal_list_servers`, without surveying\nevery upstream tool. If that response is unavailable or only partly understood, it falls back to a\n**name-only tool index** of up to 1,000 entries. The index detects the portal and recovers server\nmembership from tool-name prefixes; it carries no descriptions, schemas, or policy claims, so the\n96 KiB result budget covers as many names as possible. A truncated fallback cannot establish the\ncomplete server list and blocks the form rather than presenting a partial list as complete.\n\nAfter a server is selected, a separate filtered scan returns up to 200 compact summaries from that\nserver. Each summary carries its bounded title, description, and annotations, and is classified\nthrough the shared `tools.ts` trust boundary before it becomes a read-versus-approval label. The\nfilter is applied before result budgets, so unrelated servers cannot crowd the selected one out.\nOnly the returned prefix is offered for an individual-tool grant; additional tools require the\nserver-wide grant, and a call resolves the full definition before approval or dispatch.\n\nIndex entries are typed separately (`IndexedTool`) to distinguish name-only survey results from tool\ndefinitions rendered into approval prompts or handed to an agent.\n\nThe server list is advisory: it supplies display names and ordering while tool-name prefixes remain\nthe authority on membership, and a failed call degrades to bare ids recovered from tool prefixes.\nThe gatekeeper makes that call\nwhile building a form, so it does not pass through the approval queue. Failing to reach the portal\nat all is different, and blocks the grant rather than falling back to the bare endpoint — the\nconfigurator reports it and stays unsubmittable.\n\n`portal_*` tools are excluded from every grant at every scope. `portal_toggle_servers` and friends\nchange which upstream servers the session can reach, so granting one would let a Gadget widen its\nown authority. This is a capability-boundary rule, not a policy preference, and is not configurable.\n\n## Approvals and sharing\n\nIdentical to [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#approvals-and-sharing), except for the\ntrust tier. A portal is an aggregator: `destructiveHint` and `idempotentHint` are written by\nwhichever upstream server it fronts, servers the administrator who chose the portal never reviewed,\nand any one of them could self-declare both and get writes that skip the approval prompt. So the\ntier is `byo` unless a deployment sets `MCP_PORTAL_TRUST_ANNOTATIONS=true`. That flag is the\n\"trusted server\" assertion MCP's guidance asks for, and it has to be about the upstreams themselves,\nnot just the portal in front of them.\n\n`addObserver` refuses everyone, as it does for user-supplied endpoints. Reaching the portal is not\nthe same as being allowed to see a particular tool result, and the Gadget runs on the owner's\ncredentials regardless. A sharper check is available here than for a bare endpoint —\n`portal_list_servers` is per-user and Access-filtered, so it could confirm a collaborator may reach\nthe same server — but that is still server-granular, not record-granular. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\n## Notes and current limitations\n\nThe limitations in\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#notes-and-current-limitations) apply here too: no\nsimulation, no revert, no hooks, no scoping below tool names, and `tools/*` only. SSRF is enforced\nthe same way, by `global_fetch_strictly_public` rather than by hostname patterns. In addition:\n\n- **One portal per deployment.** `MCP_PORTAL_URL` is a single value; a second portal needs a\n  second Worker.\n- **Server ids come from tool-name prefixes.** An aggregator that flattens tools without prefixing\n  them exposes no upstream seam, so this connector finds no server to scope a grant to and the form\n  stays unsubmittable. It is not granted as a whole endpoint — that grant does not exist here. Use\n  the **MCP** connector (`gatekeeper-mcp`) for such an endpoint, where endpoint-wide breadth is an\n  explicit, reviewable choice.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/portal.ts` | Vendor, account DO, user, gatekeeper facet, session, configurator RPC |\n| `src/config.ts` | Reading and validating the deployment's portal configuration |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nEverything else comes from [`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-portal-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-portal-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n","packages/gatekeeper-mcp/README.md":"# MCP gatekeeper\n\nConnects any [Model Context Protocol](https://modelcontextprotocol.io) server as a Gadgets\ncapability. The user pastes an endpoint URL, the gatekeeper runs the OAuth discovery chain against\nit, and each of the server's tools becomes a typed method on the session. One Worker covers every\nMCP server, so a server needs no Gadgets-specific work to be usable from a Gadget. Runs as its own\nCloudflare Worker and is auto-discovered by the backend from its `GATEKEEPER_MCP` binding.\n\nIf your organization runs an MCP server portal, use\n[`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) instead: it takes one\nadministrator-configured URL, needs no user to paste an endpoint, and scopes a grant to a single\nupstream server behind the portal.\n\n## What it provides\n\nOne resource type, **Any MCP server**, at two grant breadths:\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool the endpoint offers, including ones it adds later | `<endpoint>` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools; anything else is refused | `<endpoint>#tool=a&tool=b` | `Mcp<Name><tag>Session` |\n\nA `#server=` fragment is refused here; scoping to one server behind a portal is the MCP Server Portals\nconnector's grammar. `@gadgets/mcp-shared/scope` owns the grammar and the enforcement.\n\n`<Name>` comes from the endpoint's host and is for reading, not identity — `acme.com` and `acme.io`\nboth give `Acme`. `<tag>` is four hex characters derived from the resource URL above, so two grants\nthat differ in what they may call get different type names. The agent is shown each binding's types\nin its own block with no way to compare them, so a shared name would present two unrelated tool\nsurfaces as one.\n\nThe session carries a named method per tool, generated from the server's own `inputSchema`:\n\nThe binding is named after the endpoint's host, so `https://mcp.linear.app/mcp` suggests\n`MCP_LINEAR` (see `server-id.ts`):\n\n```ts\n// Read-only tool: resolves immediately, recorded as an observation.\nlet search = await env.MCP_LINEAR.searchIssues({ query: \"state:open\" });\nif (search.status !== \"ok\") throw new Error(search.message);\nlet { text } = search;\n\n// Anything else: queued for approval, result collected afterwards.\nlet queued = await env.MCP_LINEAR.createIssue({ title: \"Fix the thing\" });\nif (queued.status === \"pending\") {\n  let outcome = await env.MCP_LINEAR.getActionResult(queued.actionId);\n}\n\n// The same call by its exact wire name, for a tool whose name cannot be a method.\nawait env.MCP_LINEAR.callTool(\"search_issues\", { query: \"state:open\" });\n```\n\nEach generated method is a one-line delegate to `callTool`, so the scope check, approval queue, and\nobservation record stay in one place. Tools whose names the RPC layer cannot deliver (`then`, `map`,\n`dup`), names that collide with session methods (`listTools`, `callTool`, `getActionResult`), names\nthat are not identifiers (`2fa`), and both\nsides of a case collision (`list_issues` and `listIssues`) get no method and remain callable through\n`callTool`.\n\nThe agent normally discovers tools statically: `describeGatekeeper()` sends it the binding name and\nthe bounded generated `.d.ts`, where each method carries the tool's own description as JSDoc and\nstates whether calling it needs approval. A server can publish more definitions than that bounded\ncatalog holds: `listTools({ search })` returns up to 20 compact summaries from a bounded scan of the\ngrant, limited to 5,000 tools / 4 MiB; `listTools({ name })` fetches one exact granted definition and\nschema under the same bound, and `callTool()` invokes it by wire name.\n`listTools()` remains the list of definitions currently described in the generated surface.\n\nSee `src/types.d.ts` in `@gadgets/mcp-shared` for the base session API.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `BASE_URL` | Public base URL of this Worker, for OAuth redirects. |\n| `MCP_CLIENT_NAME` | Client name sent in `initialize` and dynamic client registration. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, on the endpoint and on every OAuth URL discovered from it. Local dev only. |\n\nThere is nothing to configure per server: users supply endpoints, and an administrator's only lever\nis whether this connector is offered at all, in the Gatekeepers admin panel. There is no server\ncatalog — a deployment that wants to offer a chosen set of servers should front them with a portal\nand use [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md).\n\nFor local development no credentials are needed. Set `MCP_ALLOW_INSECURE=true` in the repo-root\n`.dev.vars` to connect a server running on localhost.\n\n## How the connect flow works\n\n1. The user starts a connection and gets a form asking for the server's endpoint URL. The URL is\n   validated against the host blocklist in `endpoint.ts` (no private, loopback, or metadata hosts;\n   HTTPS required unless `MCP_ALLOW_INSECURE`). The form says plainly that connecting a server is a\n   decision to trust it, since the server's own annotations decide which of its tools run without\n   asking (see below).\n2. The gatekeeper opens a Streamable HTTP session and calls `initialize`. A server that completes\n   the handshake unauthenticated is recorded as public and no token is demanded of it later.\n3. A `401` starts the official MCP client's OAuth flow:\n\n   ```\n   401 + WWW-Authenticate  ->  protected resource metadata   (RFC 9728)\n                           ->  authorization server metadata (RFC 8414)\n                           ->  dynamic client registration   (RFC 7591)\n                           ->  authorization code + PKCE     (RFC 7636)\n                               + resource indicator          (RFC 8707)\n   ```\n\n   The SDK falls back to the conventional\n   paths (`/authorize`, `/token`, `/register`) that the Workers OAuth provider and most MCP servers\n   use. That means a server which does not implement dynamic client registration is not detected as\n   such up front — the synthesized `/register` is tried and refuses, so the failure reads as a\n   rejected registration rather than a missing capability. The remedy is the same either way:\n   connect a server that supports registration, or reach it through a portal an administrator has\n   configured with a preissued token.\n4. Tokens are stored in the `McpAccount` Durable Object and refreshed proactively, before the\n   recorded expiry. Nothing outside this Worker can obtain one.\n\n   A `401` mid-session is not a refresh trigger: it means the server rejected a token this Worker\n   believed was valid, so the account is marked as needing attention and the user is asked to\n   reconnect. Refreshing in response would paper over a revoked or repudiated grant. Refresh\n   failures are classified — only the authorization server's own verdict on the credential\n   (`invalid_grant` and friends) marks the account expired, while transport and unrecognised failures\n   leave it alone to be retried.\n\nThe endpoint is fixed at first connect. Reconnecting an account cannot point it at a different\nserver, since the binding's props still name the original. (The MCP Server Portals connector is the\nexception: its endpoint comes from deployment configuration rather than a form, so a reconnect there\nmay adopt a repointed gateway. See its README.)\n\n## Scoping a binding\n\nThe configurator asks how broad the grant is and, if it is pinned, which tools:\n\n```\nTools · Choose how much of this server the Gadget may call.\n( ) All tools      Every tool this server offers (14 today), including ones it adds later.\n(•) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · 3 selected. Read-only tools return data straight away; the rest queue for approval.\n[ 🔍 Filter tools...                                  ]  3 of 14 selected   Select all   Clear\n☑ Search issues                                                                        read-only\n☑ Create issue                                                                    needs approval\n```\n\nThe breadth is asked outright rather than inferred from whether every box is ticked: \"all 14 ticked\"\nand \"these 14 by name\" look identical and diverge as soon as the server publishes a fifteenth. The\nlist stays on screen in both modes, disabled under **All tools**.\n\nTools named `portal_*` are never grantable on an endpoint that identifies itself as a portal, since\nthey let a session change which upstream servers it can reach.\n\n## Approvals and sharing\n\nA call to a tool the server annotates `readOnlyHint: true` is an observation and returns straight\naway. Every other call is queued via `submitAction()` and reaches the server only when the Overseer\ncalls `applyAction()`. Annotations are optional in MCP and most servers publish none; every hint is\ntested with `=== true` or `=== false`, so an unannotated tool is an action, needs approval, and can\nnever auto-apply.\n\nMCP's own guidance is that a client must treat tool annotations as untrusted unless they come from a\ntrusted server. The trust tier is that distinction, and it is the deployment's to make rather than\nthe server's: endpoints here are user-supplied rather than administrator-vetted, so they get `byo`.\n`readOnlyHint` classifies reads, but no claim the server makes can auto-apply a write.\n\nHonouring `readOnlyHint` on `byo` is a knowing departure from that rule, and the limit of what this\nconnector can promise. A server that labels a destructive tool `readOnlyHint: true` gets that tool\nrun without a prompt. Refusing the hint would mean an approval prompt for every `search` and `list`,\nwhich makes the connector unusable for the thing people connect it to do; and it would buy less than\nit looks, since a dishonest server can act on any call, including one the user did approve. What the\ntier does guarantee is that no BYO server gets a write *auto-applied*, and that every call trusted on\nthe server's word is recorded as such.\n\nSo the trust decision is made once, by the person pasting the URL, and the connect form says so\nplainly: an annotation is only as trustworthy as the server that sent it. Auto-approval requires a\n`vetted` endpoint, which only [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) can\nproduce. Every call records which side classified it (`McpToolInfo.classifiedBy`), so an audit can\nfind each one taken on the server's word. See `tools.ts` in `@gadgets/mcp-shared` for the tier\nrules.\n\nA Gadget bound to an MCP server can only be opened by its owner: `addObserver` refuses\nunconditionally. Being able to authenticate to a server is not evidence of being allowed to see what\nthe *owner* read from it, and the Gadget runs on the owner's credentials throughout. Writes still\nwork — the alternative, marking every observation `containsRestrictedData`, would set that\nflag, whose restricted mode blocks every action for the rest of the session. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\nTo share the work rather than the binding, publish the Gadget as a blueprint and let each person\nconnect their own server.\n\n## Notes and current limitations\n\n- **No simulation.** MCP describes no way to predict a tool's effect, so a queued call is not\n  reflected in later reads. `awaitDecision` is set and the agent's turn suspends until the user\n  decides.\n- **No revert.** MCP describes no inverse for a tool call, so `implementsRevert` is false.\n- **No hooks.** `notifications/tools/list_changed` is session-scoped; a Gadget hook is durable and\n  must survive restarts.\n- **No scoping below tool names.** MCP tools take arguments, not capabilities, so \"this repo only\"\n  cannot be expressed. A list of allowed tool names is the narrowest grant available.\n- **Only `tools/*`.** Prompts, resources, sampling, and elicitation are not implemented. Sampling\n  and elicitation would let a server drive the agent.\n- **Tool-list changes are adopted, not pinned.** A changed list is taken and logged\n  (`catalog.changed`), since refusing to see new tools would break working Gadgets. A binding with a\n  tool scope cannot widen this way, which is the reason to prefer **Choose tools** for anything that\n  writes.\n- **SSRF is enforced after DNS, not by the blocklist.** The hostname patterns in `endpoint.ts` are a\n  legible refusal at connect time; they cannot see through a public hostname that resolves, or\n  rebinds, to a private address. The actual boundary is the `global_fetch_strictly_public`\n  compatibility flag in `cloudflare.config.ts`, which makes workerd reject reserved IP ranges after\n  resolution on every request and redirect hop. It does not apply under `wrangler dev`, which is\n  what keeps `MCP_ALLOW_INSECURE` usable locally.\n- **Sharing UI reports late.** `GadgetMetadata.containsRestrictedData` derives only from\n  `ObservationDescription.containsRestrictedData`, so creating a share key appears to succeed and\n  fails when the recipient opens it (their observer verification is refused). Fixing this needs a\n  kernel change.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/mcp.ts` | Vendor, account DO, user, verifier, gatekeeper facet, session |\n| `src/connect-form.ts` | The endpoint prompt served during connect |\n| `src/server-id.ts` | Endpoint to display slug, for the binding name and session type |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nThe MCP client, OAuth, tool classification, generated TypeScript, and the scope grammar come from\n[`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n","packages/gatekeeper-notion/README.md":"# Notion gatekeeper\n\nMediates a Gadget's access to a user's [Notion](https://www.notion.so) workspace: pages and\ndatabases. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_NOTION` binding.\n\n## Auth\n\nOAuth 2.0 public connection. Configure a Notion **public integration** and provide its client\ncredentials to the worker as `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts`\nmaps `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nThe integration's **redirect URI** must match `<BASE_URL>/oauth`, which in local dev defaults to\n`http://localhost:8787/gatekeeper/notion/oauth`. Recommended capabilities: read/insert/update\ncontent, read/insert comments, read user info (no email needed — `providesAuth` is false).\n\nThe connect flow uses a two-phase nonce (initiation → OAuth) and stores the access + refresh\ntokens in a `UserAccount` Durable Object; access tokens are refreshed on a 401.\n\n## Resources\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all) | `NotionWorkspace` |\n| A page or database | `https://www.notion.so/:path+` | `NotionPage` or `NotionDatabase` (detected server-side) |\n\nOnly the pages/databases the user shares with the integration during the OAuth page-picker are\nreachable.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Page bodies are\nexchanged as Markdown and property values use a simplified union. Highlights:\n\n- `NotionWorkspace`: `search`, `getPage`, `getDatabase`, `createPage`, `listUsers`\n- `NotionPage`: `getMetadata`/`getProperties`/`getContent`, `appendContent`, `setTitle`/\n  `setProperties`/`setIcon`, `createSubPage`, `archive`/`restore`, comments, `listChildPages`\n- `NotionDatabase`: `getSchema`, `query` (typed filters/sorts), `getPage`, `createPage`\n\n## Approvals, caching & simulation\n\nEvery read calls `authorizeObservation()`; every write is staged via `submitAction()` and only\nperformed in `applyAction()`. Reads simulate pending (unapproved) writes so a Gadget sees its own\nchanges immediately — including provisional IDs for created pages. Page/database/data-source/user\nresponses are cached in DO storage with short TTLs.\n\n## Data sources\n\nNotion's newer model splits a database into one or more **data sources**. Database `query`,\n`getSchema`, and row creation resolve the database's primary data source under Notion-Version\n`2025-09-03`; pages/blocks/comments/search use `2022-06-28` so user-facing IDs/URLs stay\nconsistent. This split is hidden from the Session API.\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/notion-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/notion-gatekeeper test:run    # vitest\n```\n","packages/gatekeeper-scheduler/README.md":"# Scheduled Tasks\n\nScheduled Tasks is an ambient Gatekeeper that lets workspace code register persistent callbacks for\nelapsed intervals, wall-clock recurrences, and one-time runs. Each account also gets a read-only\nmanagement app at `/gatekeepers/scheduler`.\n\n## User flow\n\n1. Ask the agent to create a scheduled task, or open **Scheduled** and choose a starter prompt.\n2. Confirm what the task should do, the workspace and resources it may use, its cadence, and its\n   IANA timezone when it uses wall-clock time.\n3. The agent registers a persistent callback. Registration creates a disabled hook and returns its\n   schedule ID; it does not start the schedule.\n4. Enable the hook in the Workshop's Connections UI.\n5. Use **Scheduled** to search and inspect schedules across the account. Select a schedule row to\n   open its workspace.\n\nThe Scheduler app is intentionally read-only. Enabling and disabling remain in Connections; the app\ndoes not provide editing, pausing, deletion, run history, or a second hook toggle.\n\n## Agent API\n\nThe ambient binding exposes `ScheduleSession`. The exact agent-facing contract and examples live in\n[`src/types.d.ts`](src/types.d.ts).\n\n```ts\n// executeCode, where `callback` is a persistent stub for the params `{ type: \"dailyBrief\" }`\nconst scheduleId = await env.SCHEDULER.calendarAt(\n  {\n    timeZone: \"America/Chicago\",\n    freq: \"weekly\",\n    byDay: [\"MO\", \"TU\", \"WE\", \"TH\", \"FR\"],\n    hour: 8,\n    minute: 0,\n  },\n  callback,\n  {\n    title: \"Daily brief\",\n    description: \"Prepare the morning calendar and inbox brief.\",\n    occurrences: { count: 10 },\n  },\n);\n```\n\nUse the three registration methods according to the user's intent:\n\n- `every(everyMs, callback, options)` uses elapsed UTC time. The minimum interval is 60 seconds.\n- `calendarAt(rule, callback, options)` follows local wall-clock time and requires an explicit IANA\n  timezone. It supports hourly, daily, and weekly rules.\n- `runAt(when, callback, options)` runs once at an absolute epoch-millisecond timestamp or an\n  explicit timezone-aware wall-clock time.\n\nRecurring `every()` and `calendarAt()` calls may stop after a finite bound. Give one or the other,\nnever both; `runAt()` accepts neither.\n\n- `occurrences: { count: 10 }` allows ten logical scheduled occurrences.\n- `occurrences: { until: { timeZone, year, month, day, hour, minute } }` allows occurrences through\n  that instant, inclusively. An absolute epoch-millisecond `until` is also accepted. Registration\n  rejects a cutoff that precedes the schedule's first occurrence.\n\nThe count bounds *due slots*, not successful runs. A slot consumes one count as soon as it becomes\ndue and takes a `runId`, even if admission or callback delivery then fails; admission failures skip\nthe slot without retrying it. Retries reuse the same `runId` and do not consume another count.\nMissed occurrences remain skipped and do not count.\n\nA bound is checked against live state, so a schedule that reaches it reports `completed`. A schedule\nwhose cutoff already passed by the time its hook is enabled reports `expired` instead: it never got\na slot. A schedule whose callback exhausts its eight attempts reports `dead` and stops there,\nwhether or not the bound was reached — a `count: 10` schedule that dies on its third occurrence\nnever reaches the fourth. Disabling a hook drops its driver state, so re-enabling the same schedule\nrestarts the count — treat `count` as a bound per enablement, not a lifetime guarantee.\n\n`list()` returns active and terminal schedules for enabled hooks in the current workspace only. It\ndoes not expose schedules from other workspaces in the account.\n\n## Persistent callbacks and retries\n\nCallbacks implement `ScheduledTaskHook.onSchedule()` and must be made persistent before\nregistration: `this.ctx.restore(params)` inside the Gadget, or through the Gadget's binding from\n`executeCode`, as the agent's instructions on persistent stubs describe. Each firing contains:\n\n- `scheduleId`, the stable registration ID;\n- `runId`, stable across retries of one logical occurrence;\n- `scheduledTime`, the planned Unix epoch time;\n- `actualTime`, the current delivery-attempt time; and\n- `timeZone`, the schedule's display timezone (`UTC` for elapsed intervals and numeric one-shots).\n\nCallback delivery is best-effort within a bounded retry window and may occur more than once. Callback\ncode should use `runId` as an idempotency key. Authorization or callback failures retry up to eight\ntotal attempts with exponential delays beginning at one minute and capped at one hour. Exhausted\nschedules enter the **Needs attention** state.\n\nThe Workshop admission check runs before every attempt. If the hook, gatekeeper, or account is no\nlonger allowed, the occurrence is skipped without consuming a callback attempt. Recurring schedules\nadvance to their next future occurrence; a due one-shot expires.\n\n## Cadence behavior\n\n- Recurrences preserve the phase established at registration.\n- Missed occurrences are skipped, not replayed or caught up.\n- Fixed intervals measure elapsed time and therefore shift relative to local clocks across DST.\n- Calendar schedules retain their requested local wall-clock cadence across DST.\n- A nonexistent spring-forward time moves forward by the transition gap.\n- An ambiguous fall-back time uses the earlier instant and fires once.\n- Date-less wall-clock one-shots resolve to the next future occurrence of that local time.\n\nAlways ask the user for the timezone of a wall-clock schedule. Do not infer it from locale or silently\nchoose UTC.\n\n## Lifecycle\n\nRegistration only binds a disabled Workshop hook; it writes no schedule row. Enabling records the\ntarget workspace ID and optional gadget ID, then creates the account-driver row and arms its alarm.\nDisabling removes the row and its stored capabilities. Re-enabling creates fresh active state,\npreserves the original recurrence phase, and does not replay missed work. A repeated enable while\nthe row still exists instead preserves its current schedule state and refreshes its activation details.\n\nSuccessful one-shots become **Finished**. Past or rejected one-shots become expired. Terminal rows\nremain visible and consume enabled-schedule quota until their hook is disabled in Connections.\nCreating a workspace from a blueprint does not copy schedules or capabilities: the new workspace must\nregister its callback and receive fresh enablement.\n\nDisconnecting the Scheduler account revokes its driver, deletes schedule state, and leaves a permanent\ntombstone so retained stale controllers cannot recreate the account.\n\n## Management app\n\nThe account advertises its UI through the generic `AccountDescription.providesUi` mechanism, the same\nmechanism used by other Gatekeeper management apps. The Workshop discovers it dynamically and hosts\nthe single-file app in an opaque-origin, network-isolated `srcDoc` frame.\n\nThe app can only call its account-scoped, read-only `list()` capability plus bounded host methods for\ntheme updates, workspace-title resolution, navigation, and starter prompts. Pages contain at most 100\nschedules. Search is case-insensitive over title and description and is limited to 200 characters.\nCursors are opaque, bounded, and tied to the normalized search and status filters.\n\nThe **All**, **Active**, **Needs attention**, and **Finished** tabs map to the projected\n`active`, `dead`, and `completed`/`expired` statuses. Target metadata is presentation data only;\nopening its validated internal route grants no new authority. Create and starter actions place fixed\neditable text in the Home composer and never submit it automatically.\n\n## Architecture and security\n\nEach Scheduler account owns one SQLite-backed `ScheduleDriver` Durable Object and one alarm for all of\nits workspaces. Workspace sessions inherit their opaque scope from the containing Overseer facet;\ncallers cannot supply an account or workspace ID. Plain schedule metadata and reconstructable RPC\ncapabilities are stored under separate keys and changed transactionally.\n\nThe alarm persists state before crossing an RPC boundary. It processes at most 20 due schedules per\npass with four concurrent deliveries, then arms an immediate continuation when a backlog remains.\nStable `runId` fencing prevents late completion or retry continuations from mutating a disabled,\nre-enabled, or revoked schedule.\n\nOne account-wide driver keeps management and revocation simple, but it is also a shared failure domain:\na user callback that never settles can delay other schedules in that account until the runtime aborts\nthe alarm. Bounded batches limit ordinary load; they do not eliminate that tradeoff.\n\nThe Scheduler is capability-authorized. It does not receive Workshop user identity, assert its own\nambient policy, expose external network authority, or implement actions. The Workshop's existing hook\nadmission and observation authorization remain the security boundaries.\n\n## Limits\n\n- 500 enabled or terminal schedule rows per account.\n- 100 enabled or terminal schedule rows per workspace.\n- 100 rows per management page.\n- 20 due schedules per alarm pass and four concurrent deliveries.\n- Eight callback attempts per logical occurrence.\n- Schedule titles: 200 characters; descriptions: 2,000 characters.\n\nThese are fixed policy limits rather than deployment settings.\n\n## Development\n\nInstall and build from the repository root:\n\n```sh\npnpm install\npnpm --filter @gadgets/gatekeeper-scheduler test:run\npnpm --filter @gadgets/gatekeeper-scheduler build\npnpm run dev-server\npnpm run dev-client\n```\n\nThe development server discovers `gatekeeper-*` packages, builds `app/` into the generated single-file\nasset, and creates the local `GATEKEEPER_SCHEDULER` service binding. Do not edit generated local\nWrangler configuration.\n\nWorker code gets its runtime and `Cloudflare.Env` types from generated\n[`worker-configuration.d.ts`](worker-configuration.d.ts), referenced by `tsconfig.json`. Regenerate it\nwith Wrangler after changing Worker configuration; do not hand-maintain a parallel `Env` interface.\nThe browser app uses the separate DOM-only `tsconfig.app.json` and imports only environment-neutral\nmanagement DTOs.\n\n## Deployment\n\nBefore exposing Scheduler:\n\n1. Deploy the Workshop hook target-metadata contract and the `startHook()` admission backstop.\n2. Deploy this Worker so its `ScheduleDriver` and `SchedulerGatekeeper` SQLite migration exists.\n3. Add `GATEKEEPER_SCHEDULER` to the deployment's Workshop service bindings.\n\nScheduler declares only that it can auto-provision an account. Workshop provisioning policy defaults\nthe `scheduler` vendor to **optional**; an administrator can explicitly choose disabled, optional, or\nenabled. Production service bindings are site-specific and are not generated by this package.\n\nThe `allow_irrevocable_stub_storage` compatibility flag is required while stored callback capabilities\nexist and must not be removed from an existing deployment.\n\n## Troubleshooting\n\n- **Scheduled is missing from navigation:** confirm the Scheduler binding exists, the vendor is enabled\n  or optional with a connected account, and account discovery returns `providesUi`.\n- **A new schedule is not listed or running:** registration starts disabled. Enable its hook in the\n  workspace Connections UI.\n- **A schedule says Needs attention:** callback authorization or execution exhausted its retry budget.\n  Fix the callback or resource access, then disable and re-enable the hook.\n- **A one-shot says Expired:** its firing time passed without admission or it was already past when\n  enabled. Register a new future one-shot.\n- **A recurrence appears an hour off:** use `calendarAt()` for local wall-clock intent and `every()` for\n  elapsed time; verify the explicit IANA timezone.\n- **A blueprint-created workspace has no schedules:** schedules are deliberately not copied. Register\n  them again in the new workspace.\n\n## Non-goals\n\nV1 does not provide schedule editing, local pause/toggle/delete controls, recurring run history,\n`lastRunAt`, account default timezones, actor attribution, blueprint schedule cloning, catch-up delivery,\nor collaborator-owned schedule routing. Hook lifecycle remains in the Workshop Connections UI.\n","packages/gatekeeper-slack/README.md":"# Slack gatekeeper\n\nMediates a Gadget's **read-only** access to a user's [Slack](https://slack.com) workspace:\nchannels, direct messages, threads, members, and search. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_SLACK` binding.\n\nThis gatekeeper is read-only and never sends or modifies Slack data.\n\n## Auth\n\nOAuth 2.0 using a **user token** (`xoxp-…`), requested via `user_scope` (not a bot token) so the\nagent sees exactly what the connecting user can see — including private channels, DMs, and search.\n\nCreate a Slack app (https://api.slack.com/apps) and provide its client credentials to the worker\nas `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts` maps `SLACK_CLIENT_ID` /\n`SLACK_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nApp configuration:\n\n- **Redirect URL** must match `<BASE_URL>/oauth`, which in local dev defaults to\n  `http://localhost:8787/gatekeeper/slack/oauth`.\n- Enable **token rotation** (OAuth & Permissions → *Token Rotation*). Tokens are then short-lived\n  (~12h) and refreshed via `oauth.v2.access?grant_type=refresh_token`. Non-rotating tokens also\n  work as a fallback (they're returned as-is).\n- Request the **User Token Scopes** the granted resources need (see below). `users:read` is always\n  requested for connected-account display and user-name resolution.\n\n## Org-wide installations\n\nBoth workspace and org-wide installations use the connecting user's token; the bot token in an\norg OAuth response is never used for reads. Org OAuth can return `team: null`, an `enterprise`\nidentity, and `is_enterprise_install: true`. Those describe the installation, not a resource's\nworkspace. In particular, an `E…` enterprise ID must never be used in a `/client/T…` workspace URL.\n\nAn org admin must also assign the app to the desired workspaces. Installing it at the org level\nalone does not make every workspace available. The workspace picker intersects all pages of\n`auth.teams.list` (the app's assignments) with `users.info.enterprise_user.teams` (the connecting\nuser's memberships). See Slack's [Enterprise development guide](https://docs.slack.dev/enterprise/developing-for-enterprise-orgs/)\nand [auth.teams.list reference](https://docs.slack.dev/reference/methods/auth.teams.list/).\n\nEach granted resource still selects exactly one workspace. Workspace and conversation pickers\nretain its `T…` ID in the resource URL and Durable Object props. For thread URLs, workspace\ninstallations verify the permalink host against their own workspace's metadata; only org\ninstallations use the Enterprise-only `team.info(domain)` lookup, followed by membership checks.\nThe agent-facing session API is unchanged.\n\n| API calls | Workspace selector |\n| --- | --- |\n| `users.conversations`, `users.list`, `search.messages` | `team_id` |\n| `team.info` | `team` (not `team_id`) |\n| Channel-addressed conversation calls | `client_context_team_id`, plus workspace membership verification |\n\nPassing channel context is not itself an authority grant. Before reading a channel by ID, the\nclient verifies it belongs to the selected workspace, allowing shared channels linked to that\nworkspace. Responses without decisive membership metadata (including DMs) must appear in a\nworkspace-scoped conversation listing. Search matches undergo the same check. Direct user lookup\nis limited to the workspace directory; author/mention resolution can still resolve participants\nfrom other workspaces in shared channels.\n\nEnterprise DM responses can name another workspace in `shared_team_ids` even when the selected\nworkspace's DM discovery includes the conversation. For DMs and group DMs, that mismatch falls\nthrough to the existing workspace-scoped `users.conversations` proof instead of rejecting the\nread. An exact conversation ID must still be found in the selected workspace's listing; neither\norg-token visibility nor `context_team_id` is sufficient. Shared-channel mismatch denials are\nunchanged. See the [users.conversations reference](https://docs.slack.dev/reference/methods/users.conversations/).\n\nObserver checks use each collaborator's own credentials and the selected workspace ID, never\nenterprise-ID equality. Workspace bindings additionally track the conversations actually read,\nexcluding collaborators who cannot read newly observed private channels or DMs.\n\n### Upgrading existing connections\n\nOlder credentials need not be reconnected solely to learn the installation kind: discovery can\nrecognize an org token through `auth.test`. Legacy bindings did not retain the workspace from their\nURL. On first use, they recover a stored workspace ID, a thread's permalink workspace, or the sole\neligible workspace, then persist that selection separately from credentials. A subsequent reconnect\ncannot silently switch a pinned binding to another workspace.\n\nThe access proof is cached separately from that workspace selection and keyed to the access\ntoken. Every outgoing bound read, including rate-limit retries, checks the exact credential it\nwill use. Replacement credentials must demonstrate access to the pinned workspace before any\ndata request is sent. Failed checks are evicted so transient failures can recover without\nchanging the selected workspace; concurrent checks using the same credential are coalesced.\n\nIf a legacy org binding has several eligible workspaces and no recoverable selection, it fails\nclosed: add the resource again and choose its workspace. Old resource URLs containing an `E…` ID\nalso need to be replaced with a selected `T…` workspace URL.\n\nLegacy conversation-only/thread-only grants may lack the metadata scopes listed below. Reconnect\nor grant the resource again to approve the additional scopes; the reconnect flow preserves those\nprevious resource selections while requesting the new consent. Reads do not treat old scopes as\nsufficient. Workspace grants already requested the required scopes.\n\n## Resources\n\nAccess is granted at one of three granularities. Each grantable resource maps to a URL pattern\nthat drives both consent (which OAuth scopes are requested) and routing:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all whole-instance) | `SlackWorkspaceSession` |\n| A conversation (channel, DM, or group DM) | `https://app.slack.com/client/:teamId/:conversationId` | `SlackConversation` |\n| A thread | `https://*.slack.com/archives/:conversationId/:messageId` | `SlackThread` |\n\nWorkspace grants use the framework's account-wide `https://*` pattern; more-specific conversation\nand thread URLs take precedence. Channels and DMs share one \"Conversation\" grant.\n\n### Scopes per resource (user token scopes)\n\n- **Workspace**: `team:read`, conversation read scopes, `search:read`\n- **Conversation**: `team:read`, conversation read scopes, `search:read`\n- **Thread**: `team:read`, conversation read scopes\n- **Always**: `users:read`\n\nwhere the conversation read scopes are `channels`/`groups`/`im`/`mpim` `:read` + `:history`.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Highlights:\n\n- `SlackWorkspaceSession`: `getInfo`, `listChannels`, `listDirectMessages`, `listUsers`,\n  `getUser`, `getConversation`, `search`\n- `SlackConversation`: `getInfo`, `members`, `listMessages`, `getThread`, `search`\n  (conversation-scoped search is **hard-restricted** to the bound conversation regardless of query)\n- `SlackThread`: `getRoot`, `listReplies`\n\nList and search methods return paginated `Cursor` objects. Known mentions are rendered with readable\nnames.\n\n## Build\n\n```\npnpm exec vp run -F @gadgets/slack-gatekeeper build\npnpm --filter @gadgets/slack-gatekeeper test:run\n```\n\nNode regression tests cover OAuth response shapes, workspace API parameters, shared-channel/DM\nboundaries, legacy recovery, reconnects, observer exclusion, and configurator field dependencies.\nEnterprise DM regressions include nonmatching shared-team metadata, membership on a later listing\npage, nonempty search validation, and denial when the conversation is absent from that workspace.\nValidate the Worker RPC schema with `pnpm exec capnweb-validate build --out .wrangler/validate`\nfrom this package before a local Wrangler dry-run.\n","packages/gatekeeper-spotify/README.md":"# Gatekeeper Spotify\n\nThis package provides Spotify Web API integration for Gadgets. Connect a Spotify account and gadgets\ncan search the catalog, read and edit your library and playlists, manage follows, and control\nplayback on your Spotify Connect devices — all behind the approval queue.\n\nIt offers two resource granularities:\n\n- **Spotify Account** (whole-instance): profile, catalog search, library (saved tracks/albums, top\n  items, recently played), follows, playlists, and playback control via `getPlayer()`.\n- **Spotify Playlist**: read / edit / follow / unfollow a single playlist.\n\nThis gatekeeper is **not** an authentication provider (`providesAuth: false`) — Spotify isn't offered\nas a \"Continue with…\" sign-in method.\n\n> **Targets Spotify's post-February-2026 development-mode API.** Library writes use the generic\n> `PUT/DELETE /me/library`, playlist contents use `/playlists/{id}/items`, etc. Some fields Spotify\n> removed in dev mode (e.g. track `popularity`, parts of the user profile) are returned as `null`.\n\n## Setting up Spotify OAuth credentials\n\nIf you're running this project locally and want to use Spotify integration, you'll need to create\nyour own Spotify app.\n\n### Step 1: Create a Spotify app\n\n1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and click\n   **Create app**.\n2. Fill in a name and description (anything).\n3. **Redirect URI**: add exactly\n\n   ```\n   http://127.0.0.1:8787/gatekeeper/spotify/oauth\n   ```\n\n   > Spotify **does not allow `localhost`** as a redirect URI for new apps — you must use the\n   > loopback IP literal `127.0.0.1`. Replace host/port with your `BASE_URL` when not running\n   > locally (production must use HTTPS).\n4. Under **APIs used**, select **Web API**.\n5. Save, then copy the **Client ID** and **Client secret**.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-spotify/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n# Must match the redirect URI host you registered. Spotify rejects \"localhost\", so use 127.0.0.1.\nBASE_URL=http://127.0.0.1:8787/gatekeeper/spotify\n```\n\n> **Note**: `.env` is gitignored and should never be committed. Because the redirect uses\n> `127.0.0.1`, open the Workshop at `http://127.0.0.1:8787` (not `localhost`) so the OAuth popup\n> stays on the same host.\n\n### Step 3: Add yourself as a user (development mode)\n\nA new Spotify app starts in **development mode**, which limits it to a small number of users you add\nexplicitly. In the dashboard, under **User Management**, add the Spotify account (name + email) you\nintend to connect — otherwise authorization will fail. Lifting this limit requires requesting\nextended quota from Spotify.\n\n> **Playback control requires Spotify Premium.** Player write commands (play/pause/seek/volume/…)\n> return 403 for free accounts.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see the root README.md).\n2. Create or open a gadget and go to the **Connections** tab.\n3. Click **+ New Connection** and choose a Spotify resource type (whole account, or a playlist).\n4. If prompted, connect your Spotify account — you'll be redirected to Spotify's authorization page\n   in a new tab; after granting access the tab closes and you're back in Gadgets.\n5. For a playlist resource, use the picker to choose the playlist to connect.\n6. Create the connection.\n\nYou can see and manage connected accounts in settings (via the account menu in the upper-right).\n\n## Notes & limitations\n\n- **Approvals & simulation.** Reads are logged and writes are queued for human approval; nothing is\n  performed on Spotify until approved. Reads optimistically reflect your own pending edits (e.g. a\n  pending playlist add shows in `listTracks`) so a gadget can keep working before approvals land.\n  Playback commands are gated but **not** simulated — `getState()` always shows real device state.\n- **Non-owned playlists.** Spotify withholds track contents for playlists you don't own/collaborate\n  on, so `listTracks()` returns an empty list (metadata via `getDetails()` still works).\n- **Spotify Connect quirks.** Some third-party Connect endpoints (e.g. Music Assistant) can be\n  invisible to `getDevices()` and `getState().device` may misidentify the active device; confirm\n  device transfers with a follow-up `getState()`.\n\n## Troubleshooting\n\n### \"Spotify Gatekeeper Not Configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Ensure `packages/gatekeeper-spotify/.env` exists with both\nvalues, then restart the dev server.\n\n### \"INVALID_CLIENT: Invalid redirect URI\"\n\nThe redirect URI sent doesn't exactly match one registered on the app. Confirm the dashboard has\n`http://127.0.0.1:8787/gatekeeper/spotify/oauth` and that `BASE_URL` in `.env` is\n`http://127.0.0.1:8787/gatekeeper/spotify` (note `127.0.0.1`, not `localhost`; no trailing slash).\n\n### Authorization fails with \"User not registered in the Developer Dashboard\"\n\nThe connecting Spotify account isn't on the app's **User Management** list (development-mode limit).\nAdd it in the dashboard and retry.\n","packages/gatekeeper-supabase/README.md":"# Gatekeeper Supabase\n\nThis package provides Supabase integration for Gadgets via the [Supabase Management\nAPI](https://supabase.com/docs/reference/api/introduction), using OAuth2.\n\nIt exposes two resource granularities:\n\n- **Project** (`https://supabase.com/dashboard/project/:ref`) — the recommended unit. A project is a\n  hosted Postgres database plus its auth, storage, and edge functions. Gadgets can run read-only and\n  (approval-gated) mutating SQL, introspect schema, and list edge functions and storage buckets.\n- **Organization** (`https://supabase.com/dashboard/org/:slug`) — broader access to discover and act\n  across every project in an organization.\n\nA connected account corresponds to the organization the user authorizes during the OAuth consent\nflow.\n\n## Creating the OAuth app\n\n1. In your Supabase **organization settings**, open the **OAuth Apps** tab\n   (`https://supabase.com/dashboard/org/_/apps`) and **Add application**.\n2. Set the **Redirect URI** to your deployment's callback (replace the host with `PUBLIC_BASE_URL`):\n\n   ```\n   ${PUBLIC_BASE_URL}/gatekeeper/supabase/oauth\n   ```\n\n   For local development that is `http://localhost:8787/gatekeeper/supabase/oauth`.\n3. After creating it, copy the **Client ID** and **Client Secret** (the secret is shown once).\n\n> Use the OAuth app's **Client ID + Client Secret** — not a personal access token or project API\n> key. The `authorization_code` exchange requires the OAuth app credentials.\n\n## Configuration\n\nThe gatekeeper Worker reads `CLIENT_ID` and `CLIENT_SECRET`. In local development these are seeded\nfrom shell/`.dev.vars` variables by `run-dev-server.ts`:\n\n```\nSUPABASE_CLIENT_ID=<oauth app client id>\nSUPABASE_CLIENT_SECRET=<oauth app client secret>\n```\n\n## Notes\n\n- Reads are logged as observations; mutating SQL (`execute()`) is submitted to the approval queue\n  and only runs once a human approves it.\n- Mutating SQL is **not** simulated — arbitrary statements cannot be reliably previewed through the\n  stateless query endpoint, so a `query()` will not observe an un-approved change. This is\n  documented in the API types.\n","packages/gatekeeper-websearch/README.md":"# Web Search\n\nWeb Search is an ambient gatekeeper that lets the agent and gadgets search the public web through\nCloudflare's [Web Search API](https://developers.cloudflare.com/web-search/). It has no connect\nflow, no settings and no management UI.\n\n## Who gets it\n\nThe admin Gatekeepers panel sets the mode, like every auto-provisioned gatekeeper\n(`provisioning-policy.ts` in workshop-backend):\n\n- **Optional** (the default): onboarding offers it switched on, and a user can turn it on or off\n  later on the Gatekeepers page.\n- **Enabled**: every user gets it.\n- **Disabled**: no one gets it, and existing accounts go dormant.\n\nA user who has it gets a `WEB_SEARCH` binding in the agent's `executeCode`, in every workspace. The\nagent can bind it into a gadget with `setGadgetBinding`. The binding appears when the workspace\nnext opens, from the next turn of each chat, including chats that started before. The agent is\ntold about it in that chat; the system prompt doesn't change. A spawned agent's chat keeps the\nbindings its spawner gave it.\n\nA change of mode, or a user turning it off, also takes effect when each workspace next opens. Until\nthen, its open chats and scheduled gadgets can still search. Uninstalling the Worker stops every\nsearch at once.\n\n## Agent API\n\nThe binding exposes `WebSearchSession`. The contract lives in [`src/types.d.ts`](src/types.d.ts).\n\n```js\nexport default async function(self, env) {\n  const results = await env.WEB_SEARCH.search(\"durable objects alarms\");\n  // [{ title, url, description?, lastModifiedDate? }, ...]\n}\n```\n\n`query` is 1 to 1,024 characters, the API's limits. The caller picks no provider and no result\ncount, and gets up to 10 results.\n\n## How a search runs\n\n<p align=\"center\"><img src=\"../../docs/images/web-search-flow.svg\" alt=\"A search passes the gatekeeper's query check and is recorded in the Activity log before it goes through the Workers AI binding and the deployment's AI Gateway to Ceramic\" width=\"860\"></p>\n\n`src/search.ts` does the work:\n\n1. It refuses a query outside 1 to 1,024 characters. Nothing is sent or recorded.\n2. It records the query through the session's `ApprovalQueue`. The query goes in a literal field,\n   built with gatekeeper-kit's `buildDescription`, so the record shows exactly what was sent. If\n   the record is refused or fails, nothing is sent.\n3. It calls `env.AI.websearch({ gatewayId: env.CF_AI_GATEWAY ?? \"default\", query, provider: \"ceramic\", limit: 10 })`.\n4. It parses the response with zod, which drops the image and favicon fields. A failed search\n   throws `Web search failed with HTTP <status>.` without the provider's body, which can quote the\n   query back. The record stays.\n\n## Security\n\nA query goes only to the provider that `search.ts` pins. The agent cannot choose where it goes, as\nit can with a `webFetch` URL, so a workspace that has observed restricted data can still search,\nas it can search GitHub or Slack through their gatekeepers.\n\n- **Observers.** `addObserver` admits every collaborator, because search results are public.\n- **Ambience.** The gatekeeper declares only that it can mint an account and that the account has\n  a singleton. The admin's mode and the user's opt-in decide who gets it.\n\n## Why Cloudflare's Web Search API\n\n- It runs on a Workers AI binding and the deployment's AI Gateway, so the gatekeeper needs no\n  secrets or inputs.\n- Logs, billing and provider keys stay in AI Gateway.\n- The standalone `web_search` Worker binding (`env.WEBSEARCH.search`) is not an option. Cloudflare\n  removed it before launch ([workers-sdk#15453](https://github.com/cloudflare/workers-sdk/pull/15453)).\n  Its types are still in the generated `worker-configuration.d.ts` files.\n\n## Why Ceramic\n\n<p align=\"center\"><img src=\"../../docs/images/web-search-providers.svg\" alt=\"Price per 1,000 searches and data retention: Ceramic $0.25 with ZDR, Linkup $5 with ZDR, Exa $7 without ZDR, model-native search $10 to $35\" width=\"860\"></p>\n\n- **Zero data retention.** A query can carry workspace text, so the provider must not keep it.\n  Exa offers no zero data retention, so we exclude it. Ceramic's zero data retention applies to\n  searches billed to AI Gateway credits. With an operator's own Ceramic key, their Ceramic terms\n  apply instead.\n- **Cost.** $0.25 per 1,000 searches: a twentieth of Linkup and a twenty-eighth of Exa.\n- **Content.** Ceramic runs its own index of 40B+ pages and returns descriptions of up to 8,000\n  characters, so a result often answers the question without a fetch.\n- **Pinned in code.** `search.ts` sets `provider: \"ceramic\"` instead of trusting the API's default,\n  which Cloudflare could change to a provider that keeps data. There is no fallback chain: each\n  fallback would be one more way out of the platform.\n- **Linkup** is the other provider with zero data retention. Switching to it changes one constant.\n\nWe rejected model-native search (Anthropic, OpenAI, Gemini). It runs at the model provider, where\nthe Workshop cannot record it, and costs $10 to $35 per 1,000. We rejected direct\nvendor APIs (Brave, Tavily, Parallel and others) because each needs a new secret and sends traffic\noutside AI Gateway.\n\n## Deployment\n\n- The release manifest gives the Worker an `AI` binding and asks for no inputs. The deploy service\n  installs it on every new deploy, like Scheduled Tasks and the Context Library.\n- **Gateway.** `CF_AI_GATEWAY` names the AI Gateway searches go through. The deploy service sets\n  it on every gatekeeper it uploads to the instance's gateway, the one the backend's\n  `CF_AI_GATEWAY` names. Everywhere else (the dev server, previews, a deploy you run yourself) it\n  stays `default`, which every account has, unless you set it on this Worker. The gateway must be\n  in the Worker's own account: the `AI` binding can't reach another.\n- **Billing.** Each search spends the deployment account's AI Gateway credits, unless the gateway\n  holds a Ceramic key (see Known limits). The free daily allowance and Cloudflare-credits top-up\n  for models do not cover searches, and there is no per-user limit. On a public server, set the\n  mode to Disabled unless you accept that: under the default, Optional, every new user who leaves\n  it on in onboarding gets it.\n- **Local dev.** Start with `pnpm dev-server -- --use-workers-ai-binding`, which needs a Cloudflare\n  login. Without the flag the Worker has no `AI` binding and every search fails.\n\n## Known limits\n\n- **No approval.** A search is a read: recorded, not queued. Code that runs on a schedule can send\n  workspace data in a query with no one present.\n- **Untrusted results.** Results are page text. An agent that reads them can meet a prompt\n  injection, as with `webFetch`.\n- **Gateway logs.** The gateway that `CF_AI_GATEWAY` names logs each query (log collection is on\n  by default). When that is `default` and the backend uses a gateway with another name, search\n  logs sit apart from model logs. Ceramic's zero data retention covers Ceramic, not those logs.\n  That gateway's own settings, such as rate limits, apply to searches.\n- **Your own Ceramic key.** If the gateway that `CF_AI_GATEWAY` names holds a Ceramic key under\n  the alias `default`, AI Gateway uses it. Ceramic bills the operator, their Ceramic terms apply,\n  including data retention, and a search no longer falls back to credits: a revoked or empty key\n  fails every search. The deploy wizard can store the key; a key on any other gateway is not used.\n- **Blueprints.** A blueprint export drops ambient bindings, so a gadget installed from a blueprint\n  loses `WEB_SEARCH` until the agent binds it again with `setGadgetBinding`.\n","packages/gatekeeper-x/README.md":"# Gatekeeper X\n\nConnects X (formerly Twitter) accounts to Gadgets. A gadget can read an account's timelines,\nmentions, search, bookmarks and likes, and draft posts, threads, replies, likes, reposts, follows,\nmutes and List changes. Nothing reaches X until a user approves it; every draft is shown to the\ngadget as if it had already happened, so a gadget keeps working while it waits. See\n`plans/x-gatekeeper.md` for the design record.\n\nFour resource types, each its own grant:\n\n| Resource | Link | What it reaches |\n|---|---|---|\n| X Account | `https://x.com/settings/account` | The whole account. |\n| X Post | `https://x.com/<user>/status/<id>` | One post and its conversation: read, like, repost, bookmark; reply where X allows it (to the account's own posts, or posts that mention or quote it); hide replies in conversations the account started. |\n| X List | `https://x.com/i/lists/<id>` | One List's posts and members; its details and members, if the account owns it. |\n| X Profile | `https://x.com/<user>` | One user's public profile and posts. Read-only. |\n\nThe account resource's link names no user, so a blueprint that binds it binds whichever account\nre-resolves it.\n\n## What it costs\n\nX's API is pay-per-use, billed to the X app's prepaid credits: the deployment pays for every user's\nreads and posts. As of October 2026:\n\n| Request | Price |\n|---|---|\n| A post read | $0.005 |\n| A user read (a profile, or a post's author) | $0.010 |\n| A post created | $0.015 |\n| A post containing a link | $0.20 |\n\nX bills each resource once per UTC day, however often it is read. For example, a daily digest of\n200 posts costs $1-3 a day depending on how many distinct authors it shows, and a single post with a\nlink costs $0.20, which approval descriptions point out.\n\nTwo limits keep a gadget from running up the bill:\n\n- **A daily read limit per connection**, set by the deployment's `X_DAILY_READ_LIMIT` var: the\n  billable resources one connection may read per UTC day, across every binding and gadget using it.\n  The default is 2,000 (about $10 a day at the post-read price); `0` removes the limit. Reads past\n  it fail with a message naming when it resets.\n- **Approvals** bound writes: nothing is posted, liked or followed without a user approving it.\n\nReads are cached briefly (a post for 5 minutes, a profile for an hour, a listing's first page for a\nminute), so a gadget polling one page pays at most once a minute.\n\n## Setting up an X app\n\n1. In the [X Developer Console](https://console.x.com), create a Project and an App.\n2. Under **User authentication settings**, enable OAuth 2.0 with the type **Web App, Automated App\n   or Bot** and the permission **Read and write**.\n3. Set the **Callback URI** to `{PUBLIC_BASE_URL}/gatekeeper/x/oauth`, and give the app a website\n   URL.\n4. Buy credits, and set a spending limit.\n5. Copy the **OAuth 2.0 Client ID** and **Client Secret** (not the API key and secret, which are for\n   OAuth 1.0a) into the deployment's `CLIENT_ID` and `CLIENT_SECRET`.\n\n### Local development\n\nExport `X_CLIENT_ID` and `X_CLIENT_SECRET` in your shell or the root `.dev.vars`; `pnpm dev-server`\nmaps them into the worker's `CLIENT_ID` and `CLIENT_SECRET`, and passes `X_DAILY_READ_LIMIT`\nthrough when it is set.\n\nRegister `http://localhost:8787/gatekeeper/x/oauth` as a callback. If the console refuses\n`localhost`, register `http://127.0.0.1:8787/gatekeeper/x/oauth` instead and start the dev server\nwith `VITE_BACKEND_HOST=127.0.0.1:8787`, so the callback the gatekeeper sends matches.\n\n## Obligations that come with an X app\n\nX's developer agreement puts these on the app's owner, not on Gadgets:\n\n- **AI-generated replies need X's prior approval.** An app that replies to posts with AI-generated\n  content must be approved by X before it does. Replies drafted by agents are sent only after a user\n  approves each one, but X still counts them as the app's.\n- **Automated accounts must say so.** An account run by an agent must carry X's \"Automated\" label,\n  set in the account's settings.\n- **Likes, follows, reposts and posts must be the user's own.** X forbids automating them. That is\n  why only bookmarks, mutes and hiding replies can be auto-approved: they are private to the account\n  or reversible moderation. Everything else waits for a user to approve it.\n\n## Sharing a workspace\n\nA collaborator can observe a workspace that reads X only with an X connection of their own (and,\nfor a Post or List binding, one that can see the bound post or List). What the account reads that\nis private to it -- bookmarks, likes, mutes, private Lists, and posts by protected accounts -- is\nshown only to collaborators connected as the same X user. See `docs/observers.md`.\n\n## Troubleshooting\n\n### \"The X connection has expired or was revoked. Reconnect the X account.\"\n\nX refused the connection's refresh token: the user revoked the app in X's settings, the app's\ncredentials changed, or the refresh token was used twice. Reconnect the account from the Workshop's\nConnections page.\n\n### \"That's a different X account\"\n\nThe reconnect was authorized by another X user than the one the connection was made for. A\nconnection stays pinned to its X user; sign in to X as that user and reconnect, or connect the other\naccount separately.\n\n### \"This X connection has used today's 2,000 reads; the limit resets at 00:00 UTC.\"\n\nThe connection reached the deployment's daily read limit. Wait for the reset, or raise\n`X_DAILY_READ_LIMIT`.\n\n### \"The X API credits for this deployment are used up, or its spending limit was reached.\"\n\nThe X app's prepaid credits ran out, or its spending limit was hit. Add credits or raise the limit in\nthe X Developer Console.\n\n### \"X's rate limit for this request is used up for this account.\"\n\nX limits each endpoint per account per 15 minutes; the message names when the window resets. The\ngatekeeper never retries on its own.\n\n### \"X never confirmed whether this post was published, so it may already be on X and can't be rejected.\"\n\nAn approval sent the post, and X's answer was lost. Approve it again: the gatekeeper first checks the\naccount's recent posts and records the post if it landed, so it is never posted twice. Revert it\nafterwards if it shouldn't stay.\n\n### The callback URI does not match\n\nX compares the callback exactly. It must be the deployment's `{PUBLIC_BASE_URL}/gatekeeper/x/oauth`,\nwith the same scheme, host and port the gatekeeper's `BASE_URL` uses.\n","packages/gatekeeper-zoominfo/README.md":"# Gatekeeper ZoomInfo\n\nThis package provides [ZoomInfo](https://www.zoominfo.com) integration for Gadgets via ZoomInfo's\nGTM (go-to-market) API, using OAuth2 (Authorization Code + PKCE).\n\nIt exposes a single **whole-account** resource (`https://app.zoominfo.com/`): a session scoped to one\nconnected ZoomInfo account's OAuth grant and package entitlements. Through it a gadget can:\n\n- **Lookup** — resolve controlled filter values (industries, intent topics, scoop types, …) and\n  discover the enrich fields the account is entitled to.\n- **Search** (free) — companies, contacts, intent signals, scoops, and news, with full firmographic\n  and technographic filter fidelity.\n- **Enrich** (consumes credits) — turn matched records into full detail: companies, contacts,\n  corporate hierarchy, hashtags, intent, scoops, and news.\n- **Copilot** — company/contact lookalikes, contact recommendations, AI account summaries (+ ask),\n  and curated company insight signals.\n- **Usage** — read the account's credit/limit counters.\n\nA connected account corresponds to the ZoomInfo user who authorizes the OAuth consent flow. ZoomInfo\nis **not** offered as a \"Continue with…\" sign-in method (`getAuthenticatedEmail()` returns `null`).\n\n## Creating the OAuth app\n\n1. In the [ZoomInfo Developer Portal](https://api.zoominfo.com/), create an OAuth application.\n2. Set the **Redirect URI** to your deployment's callback (replace the host with your base URL):\n\n   ```\n   ${BASE_URL}/gatekeeper/zoominfo/oauth\n   ```\n\n   For local development that is `http://localhost:8787/gatekeeper/zoominfo/oauth`.\n3. Enable all API scopes the account is entitled to. The gatekeeper requests exactly the scopes\n   ZoomInfo's authorization server recognizes:\n\n   ```\n   api:data:company  api:data:contact  api:data:intent  api:data:news  api:data:scoops\n   api:recommendations:read  api:account-summary:read  api:insights:read\n   ```\n\n   There is no `lookup` scope — lookup/enrich-field reference data comes with the data scopes.\n4. Copy the **Client ID** and **Client Secret**.\n\n> Use the OAuth app's **Client ID + Client Secret**. The `authorization_code` exchange posts them to\n> the token endpoint as HTTP Basic auth (form-urlencoded), with PKCE (S256) and refresh-token\n> rotation.\n\n## Configuration\n\nThe gatekeeper Worker reads `CLIENT_ID` and `CLIENT_SECRET`. In local development these are seeded\nfrom the root `.dev.vars` by `run-dev-server.ts`:\n\n```\nZOOMINFO_CLIENT_ID=<oauth app client id>\nZOOMINFO_CLIENT_SECRET=<oauth app client secret>\n```\n\nOptional overrides read by the Worker:\n\n- `BASE_URL` — the gatekeeper's public base (default `http://localhost:8787/gatekeeper/zoominfo`);\n  the OAuth redirect is `${BASE_URL}/oauth`.\n- `ZOOMINFO_API_BASE_URL` — the ZoomInfo API root (default `https://api.zoominfo.com/gtm`).\n\n## Notes & limitations\n\n- **Credits.** Search, lookup, recommendations, insights, and account summaries are free. Enrichment\n  consumes ZoomInfo credits (roughly one per newly-enriched record; already-owned records and\n  no-match/error results are free). `getCreditUsage()` is the authoritative source for accounting —\n  the per-record `creditCharged` flag is a display-only upper bound, because ZoomInfo often omits\n  management status from enrich responses and so over-reports records served for free.\n- **Approvals.** Reads are logged as observations. Each `enrich*` call is submitted to the approval\n  queue and spends credits only once a human approves it; results are fetched afterward via\n  `getEnrichmentResult(ticket)`. Enrichments are **not** simulated — the action carries\n  `awaitDecision`, so the agent turn suspends until the decision rather than reading back\n  un-enriched state.\n- **Intent/scoop company targeting.** Intent and scoop *search* filter by firmographics only —\n  ZoomInfo has no company-identity inputs there. To pull intent or scoops for a specific known\n  company, use `enrichIntent` / `enrichScoops` (which take a company identifier). The API surface and\n  a runtime guard both enforce this with a clear message instead of ZoomInfo's opaque 400.\n- **`state` vs `country`.** These are mutually exclusive: ZoomInfo silently ignores `state` when\n  `country` is also set and returns the whole country. The gatekeeper rejects the combination up\n  front rather than returning broadened results with no signal. `state` already scopes to the\n  US/Canada, so pass just one.\n- **Entitlements.** Every call is scoped to the account's package: ZoomInfo returns only the fields\n  and datasets the account is entitled to; unentitled fields come back empty.\n\n## Troubleshooting\n\n### \"ZoomInfo Gatekeeper Not Configured\" during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Ensure `ZOOMINFO_CLIENT_ID` / `ZOOMINFO_CLIENT_SECRET`\nare set in the root `.dev.vars`, then restart the dev server.\n\n### `invalid_scope` on the authorization page\n\nA requested scope isn't recognized by ZoomInfo's authorization server. Confirm the app has the eight\nscopes listed above enabled and that no others are being requested.\n\n### Redirect URI mismatch\n\nThe redirect URI sent must exactly match one registered on the app. Confirm the portal has\n`${BASE_URL}/gatekeeper/zoominfo/oauth` (for local dev,\n`http://localhost:8787/gatekeeper/zoominfo/oauth`, no trailing slash).\n\n### \"ZoomInfo did not return a refresh token\"\n\nThe OAuth app isn't issuing refresh tokens. Enable the refresh-token grant for the app in the\nDeveloper Portal and reconnect — the gatekeeper needs a refresh token to keep the long-lived session\nalive after the access token expires.\n","packages/integration-tests/README.md":"# integration-tests\n\nEnd-to-end tests that drive the real Workshop and a real gatekeeper over the actual RPC API, plus the\ntoolkit those tests are built from. Part of `pnpm test`, so CI runs it like any other package.\n\n```bash\npnpm --filter @gadgets/integration-tests test:run\n```\n\n## The toolkit\n\nThree source-only modules, consumed both by the tests here and by per-vendor suites in repos that\nvendor this one as a submodule:\n\n- **`src/harness.ts`** — boots `workshop-backend` and any set of gatekeepers as real Workers under\n  [`wrangler`'s `createTestHarness()`](https://developers.cloudflare.com/changelog/post/2026-07-21-integration-test-harness/),\n  patching their checked-in `wrangler.jsonc` in memory. Parameterised over gatekeepers on purpose: a\n  suite for a new gatekeeper should be \"point the harness at the package\", not a forked copy.\n  `Harness.redeployWorkshop()` deploys another build of the Workshop over the running one, keeping\n  its storage, and `bundleBlueprints()` is a patch that changes which blueprints a build ships with.\n- **`src/network-interceptor.ts`** — `NetworkInterceptor`, mechanism only. It patches\n  `globalThis.fetch` (the harness routes Worker subrequests back through the Node process, so that is\n  enough), passes loopback through, and **throws on anything a handler didn't match** — a test cannot\n  reach the real internet. What a given vendor's endpoints answer lives in a handler module you pass\n  in, which is what makes it reusable across gatekeepers.\n- **`src/rpc-client.ts`** — speaks Cap'n Web over a WebSocket to `/api`, the same transport the\n  browser uses: sign-up, reading connected accounts, and `ObserverConfigRecorder`, which records the\n  overseer's `configure()` calls and answers from a scripted queue.\n\n## Writing a test here\n- **No test may assume a clean slate.** Everything in a file shares one harness, `it.concurrent` runs\n  the cases together, and storage is never reset. Take fresh identities from `nextUsernames()` and use\n  per-test resource URLs; account labels are allocated for you, so two tests can't pick the same one.\n- **The escape assertion lives in `afterAll`, not `afterEach`** — an `afterEach` fires while sibling\n  tests are still running, so it would inspect and clear state they are still using.\n- **A test that redeploys the Workshop starts a harness of its own.** A redeploy restarts the Workers\n  under every session they have, which would break the tests sharing the file's harness.\n\n## The fixture gatekeeper\n\n`fixtures/gatekeeper-test/` is a real Worker speaking the real gatekeeper protocol, whose verification\noutcome the tests set over an HTTP control route. It exists because the overseer cases need a\ngatekeeper that will refuse an observer *on command*, and every shipping one can do that only at a\ncost that would dominate the test:\n\n- The OAuth ones need a whole vendor auth surface mocked before an account exists at all.\n- The Context Library only refuses after an observation has been *recorded*, which takes a gadget read\n  session or a slash command — and it is a singleton, so it cannot produce two simultaneously failing\n  bindings.\n\nAdding a test hook to those workers was considered and rejected: a \"mark observed\" hook would stub the\nvery state the tracker maintains, and an injected dev credential for an OAuth gatekeeper would bypass\nexactly the flow that makes a real vendor worth testing.\n\nOne deliberate departure from a shipping gatekeeper, to keep the fixture cheap:\n\n- One control knob, `allow`. A settled denial and an expired credential reach the overseer identically\n  — both as a thrown error, which it deliberately cannot tell apart because it treats every failure as\n  repairable — so the reason string is what carries the difference. Tests cover both narratives by\n  choosing reason text.\n\n## A real gatekeeper: GitHub's hooks\n\n`__tests__/github-hooks.test.ts` runs a GitHub hook's whole life through the real Workshop and the\nreal `gatekeeper-github` — subscribe, enable, a signed delivery, the gadget's write approved, then\ndisable, connection removal and disconnect — and `github-hooks-off.test.ts` what a deployment\nwithout `WEBHOOK_ORIGIN` gets. GitHub is `__tests__/github-fake.ts`, a handler module for the\ninterceptor. The gatekeeper boots from the tree `vp run -F @gadgets/github-gatekeeper\nbuild:integration-worker` validates, which the `test` task and `test:prebuild` build first.\n\n## An upgrade: Google's hooks\n\n`__tests__/google-hooks-upgrade.test.ts` enables Gmail and Chat hooks under the release of\n`gatekeeper-google` that came before its hook delivery queue moved into gatekeeper-kit, then\nupgrades that Worker in place to this tree's build, keeping its storage. It checks that queued\ndeliveries, the hooks' capabilities and alarms, the Gmail watch and cursor and the Chat subscription\nall carry over, and that disconnecting reaches hooks the old release never told the account about.\nGoogle is `__tests__/google-fake.ts`. `vp run -F @gadgets/integration-tests build:upgrade-base`\n(`scripts/build-upgrade-base.ts`) bundles the old release from a temporary worktree of its revision.\nThe new one comes from `vp run -F @gadgets/google-gatekeeper build:integration-worker`. The `test`\ntask and `test:prebuild` build both first.\n\n## Further reading\n\n[`docs/integration-testing.md`](../../docs/integration-testing.md) covers the reasoning behind the\nshape of all this: why fake timers cannot work here, why a fixture gatekeeper rather than a real one,\nhow storage isolation works, and the capnweb, wrangler, and workerd traps to expect. Read it before\nchanging the toolkit or starting a suite of your own.\n","packages/mcp-shared/README.md":"# MCP shared\n\nThe protocol client, policy decisions, and stateful machinery common to the two MCP-speaking\ngatekeepers. Not a Worker: a library both of them import.\n\n| Package | Endpoint comes from | Grant is scoped to |\n| --- | --- | --- |\n| [`gatekeeper-mcp`](../gatekeeper-mcp/README.md) | a user pastes a URL | the whole server, or named tools |\n| [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) | a deployment var, `MCP_PORTAL_URL` | one upstream server behind the portal, or named tools |\n\nCode lives here when two copies of it would eventually disagree and the disagreement would be a\nsecurity bug: tool classification, the scope grammar, the OAuth lifecycle, the approval-queue\nwiring. A Worker's own vendor entrypoint, Durable Object classes and migrations, `Env`, connect\nform, and configurator UI stay in the connector. Where a connector must vary shared behaviour it\ndoes so through a named hook (`staticToken`, `mintAccount`), not a private copy.\n\n## Modules\n\n| Module | Purpose |\n| --- | --- |\n| `client` | Bounded Streamable HTTP transport (`initialize`, `tools/list`, `tools/call`) using official MCP wire types |\n| `oauth` | Small adapter around the official MCP client's OAuth errors and token revocation gap |\n| `tools` | The trust boundary: read/action classification, auto-approval eligibility, approval prompts, catalog fingerprinting |\n| `schema-to-ts` | JSON Schema to TypeScript, strict `callTool` overloads plus progressive discovery |\n| `session-methods` | Installs those methods at runtime, so the generated types are not a fiction |\n| `tool-search` | The one query matcher every catalog search uses, so a query cannot mean two things |\n| `portal` | Gateway detection, tool-name to upstream-server mapping, server listing |\n| `scope` | The resource-URL scope grammar, and the check every call passes through |\n| `endpoint` | Validation and host blocklist for a user-supplied endpoint |\n| `fetch` | Every outbound request; redirects are followed by hand and each hop re-checked, including SDK OAuth fetches |\n| `account` | Durable Object base and persisted SDK OAuth state: connect, refresh, revocation |\n| `facet` | Common session, catalog, action, and sharing behavior for connector-owned Durable Object facets |\n| `catalog` | One binding's tool list: fetched, cached, scoped to the grant, classified; plus the cache for tools hydrated past that list |\n| `connection` | `withClient` — transport sessions, retries, credential-expiry reporting |\n| `action-store` | Staged to applied/rejected/failed, with a bound on what is retained and a claim so one approval is never sent twice |\n| `session` | The Gadget-facing capability, and the one path every tool call takes |\n| `sharing-policy` | The owner-only sharing rule |\n| `html` | The connect-flow pages, so both connectors look like one product |\n| `http` | Base-path, OAuth callback, and connect-link routing shared by both Workers |\n| `log` | The field vocabulary both connectors log against |\n| `user` | The common account description, revocation, and reconnect lifecycle |\n| `util` | Hex encoding, host extraction, binding-name slugs; no policy |\n| `types.d.ts` | Base types prepended to every generated per-server `.d.ts` |\n\nNothing outside `tools.ts` reads a tool's `annotations`.\n\n## Trust tiers\n\nA tier governs how far a server's own claims about its tools are believed. MCP's own guidance is\nthat a client must treat tool annotations as untrusted unless they come from a trusted server, and\nthe tier is where this deployment records which servers those are. It is named by provenance, is\ndeployment configuration rather than account state, and is read at each point of use so withdrawing\nit takes effect at once.\n\n- **`byo`** — a user typed the URL in. `readOnlyHint` classifies reads; nothing the server says can\n  auto-apply a write.\n- **`vetted`** — a deployment has asserted this endpoint's annotations can be relied on, so\n  `destructiveHint: false` plus `idempotentHint: true` may drive auto-approval. Configuring an\n  endpoint is not by itself enough to earn this: a portal aggregates upstream servers whose\n  annotations the administrator never saw, which is why `gatekeeper-mcp-portal` defaults to `byo`\n  and requires `MCP_PORTAL_TRUST_ANNOTATIONS=true`.\n\nHonouring `readOnlyHint` on `byo` is a tradeoff, not a free win: a tool the server mislabels runs\nwith no approval, where an unlabelled one would have been queued. It is accepted because prompting\non every read makes the connector unusable for its main purpose, and because the owner chose to\nconnect the server. Auto-applying a write is not accepted on those terms.\n\nAnnotations are optional in MCP and most servers publish none. Every hint is compared with\n`=== true` or `=== false`, so an unannotated tool is an action, needs approval, and can never\nauto-apply, on either tier. That matches the spec's own defaults (`readOnlyHint: false`,\n`destructiveHint: true`, `idempotentHint: false`).\n\nNeither tier can be shared. A Gadget bound to any MCP endpoint is owner-only, for reasons unrelated\nto provenance — see [`sharing-policy.ts`](src/sharing-policy.ts).\n\nWhat an account records instead of a tier is `provenance`, `\"user\"` or `\"deployment\"`, settled when\nit connects. Provenance decides whether a server may rename itself over an administrator's chosen\nlabel in an approval prompt, which is a question that should not move when an annotation setting\ndoes.\n\n## Applying an approved call\n\nThe guarantee is *at most once*, not exactly once. MCP has no idempotency key that would make a\nrepeated call harmless and no inverse operation that would undo one, so where the two conflict the\nstore prefers losing a result over repeating a write.\n\nAn approval is claimed in storage before the call is sent, which is what stops two concurrent\n`applyAction` calls from both reaching the server. Once the call returns, the record is settled in\nits own small write *before* the result is attached, so nothing about handling a server-controlled\npayload — normalizing it, encoding it, or finding it too large for the Durable Object to store — can\nlose the fact that the write already happened.\n\nFailures are split by what the server is known to have done, because that is not something the\ncaller can work out afterwards. Only a `401` or `403` proves the tool was refused before dispatch.\nGeneric HTTP and JSON-RPC errors, dropped connections, malformed replies, and oversized bodies all\nleave the outcome unknown; those are closed as failed and **not** retryable, since the request may\nalready have been carried out. The classification (`callMayHaveTakenEffect`) fails safe: anything it\ncannot positively identify as declined counts as possibly performed.\n\nThe same rule covers an activation dying between sending the call and recording the reply. The claim\nis not released for another attempt: after `APPLY_CLAIM_TIMEOUT_MS` the action is closed the same\nway, saying it may or may not have taken effect.\n\nSo the cost of a network blip is a call someone has to stage again, deliberately. That is the trade\nthis module makes everywhere: an approval is never spent twice without a person saying so.\n\nA failed call can always be discarded. The Workshop keeps a failed approval pending until the user\nretries or discards it, and a call the agent awaits blocks its chat until then, so `reject` accepts a\n`failed` record instead of refusing it. Discarding keeps the failure on record for the Gadget to\ncollect, so an unknown outcome is still reported as unknown, and rules out any further attempt.\n\n## Limits\n\nFixed rather than configurable.\n\n| Limit | Value | Where | Why |\n| --- | --- | --- | --- |\n| Described or individually granted tools per server | 200 | `tools.ts` | Bounds the picker and generated `.d.ts`; a server-wide grant can discover additional tools later |\n| Catalog size | 96 KiB UTF-8 | `client.ts` | Leaves room below Durable Object's 128 KiB per-value limit for the cache wrapper and serialization overhead |\n| Filtered discovery scan | 5,000 tools / 4 MiB | `client.ts` | Bounds work spent skipping unrelated tools while searching a large endpoint |\n| Search query / results | 200 chars / 20 tools | `tool-search.ts` | Bounds agent-supplied matching work and the summaries returned to it |\n| Hydrated definitions per facet | 200 tools / 1 MiB | `catalog.ts` | Bounds definitions fetched individually beyond the described catalog |\n| Tool description | 4 KB | `client.ts` | As above, per tool, before it reaches storage |\n| Tool input schema | 20 KB | `client.ts` | Dropped rather than clipped; half a schema is not a schema |\n| `tools/list` pages | 50 | `client.ts` | Stops a cursor that never ends; exhaustion truncates catalogs and fails exact/search discovery as a scan limit |\n| Response body | 1 MiB | `fetch.ts` | Every response is buffered whole before it can be parsed, and a `tools/call` result is otherwise unbounded |\n| Bounded outbound operation | 30 seconds | `fetch.ts` | OAuth and discovery callers opt into one deadline covering redirects, pagination, body streaming, and session retry |\n| Retained result | 128 KB | `action-store.ts` | Held until the Gadget collects it; oversized ones are replaced by a note |\n| Retained actions | 100 | `action-store.ts` | Records are for collecting a result, not an audit log |\n| Actions awaiting a decision | 50 | `action-store.ts` | These cannot be pruned, so uncapped they are an unbounded write |\n| Tool description in a prompt | 600 chars | `tools.ts` | Server-controlled text in a security decision |\n| Tool arguments in a prompt | 4000 chars | `tools.ts` | Agent-controlled text in the same decision |\n| Server name | 60 chars | `account.ts` | As above; also stripped of Markdown |\n| Redirect hops | 3 | `fetch.ts` | Each one re-checked; more is a loop, not a deployment |\n| Connect link | 10 min | `connect-nonce.ts` | Single-use, and consumed on success |\n| Unfinished connect | 1 hour | `connect-nonce.ts` | After which a half-built account deletes itself |\n\n## Build & test\n\n```\npnpm --filter @gadgets/mcp-shared build   # tsc\npnpm --filter @gadgets/mcp-shared test:run    # vitest\n```\n","packages/observability/README.md":"# Observability\n\n`@gadgets/observability` is a shared library for code that runs in Cloudflare Workers. It is not a\nstandalone Worker and has no deployable entrypoint or Wrangler project configuration.\n\nThe package's Vitest configuration uses the Workers test pool to exercise runtime-specific APIs.\nConsumers that import `@gadgets/observability/observability-context` must enable `nodejs_als` (or\n`nodejs_compat`); the default `@gadgets/observability/logger` entry point has no such requirement.\n\nAn observability context exposes typed `.with()` and `.get()` methods, and creates loggers that\ninherit its ambient fields.\n\nThe optional `@gadgets/observability/error-reporting` entry point dispatches bounded error events to\na private Reporter bound as `ERROR_REPORTER`; `reportIssue(failureSite, caught, options?)` accepts\nambient fields under `options.attributes`, so callers can spread the context's `.get()` result and\naugment it inline. Reporting is a no-op when the binding is absent.\n\nThe optional `@gadgets/observability/metrics` entry point writes usage metrics to a Workers\nAnalytics Engine dataset bound as `METRICS`, and is likewise a no-op without the binding. Each event\nis written once to its actor's index, the canonical copy to count, and once more per owner or\nworkspace it names, so per-entity queries read their own index. The column layout lives in the import-free `metrics-schema.ts` (also\nexported as `@gadgets/observability/metrics-schema`), so dashboards can name columns rather than\n`blobN`.\n","packages/ui/AGENTS.md":"# Shared UI\n\n`@gadgets/ui` is the shared runtime React UI layer for the Workshop and gatekeeper management apps.\nKumo owns low-level controls and semantic tokens; this package owns reusable Gadgets interaction\npatterns and composed components.\n\n## Ownership\n\n- Add a component here when at least two independent frontends need the same feature-independent\n  behavior or when consistency across those frontends is an explicit product requirement.\n- Keep product data fetching, routing, RPC, permissions, and domain workflows in the consuming app.\n- Check Kumo before adding a primitive. Do not wrap Kumo solely to restyle or rename it.\n- Export source directly. This package has no publish or emitted-build step and is marked private.\n- Keep React and Kumo as peer dependencies so consumers use one runtime and design-system version.\n- A Tailwind consumer must include `packages/ui/src` as an `@source`, because the shared components'\n  utility classes are compiled by the consuming app.\n\n## APIs And Tests\n\n- Prefer a headless behavior primitive plus a Kumo adapter when both are real consumers' needs.\n- Keep the headless model free of presentation policy and styled-only fields where practical.\n- Preserve accessibility and input parity across mouse, keyboard, touch, and hybrid devices.\n- Colocate tests and test observable contracts rather than package boundaries or framework behavior.\n","packages/workshop-frontend/AGENTS.md":"# Workshop Frontend\n\nThis package is the Workshop single-page application. It uses React, TanStack Router, Tailwind,\nand Kumo. Follow the repository-wide guidance in `../../AGENTS.md` in addition to this file.\n\n## Architecture\n\nOrganize new code by product ownership first and implementation type second.\n\n```text\nsrc/\n  routes/       TanStack route declarations and route-level wiring\n  pages/        Substantial route-level screens that compose one or more features\n  features/     Product features and their owned UI, hooks, and logic\n  components/   UI and application primitives shared across unrelated features\n  hooks/        Hooks shared across unrelated features\n  utils/        Feature-independent utilities\n```\n\nThe existing tree predates this convention. Apply it to new code and to code being substantially\nreworked; do not migrate unrelated files opportunistically.\n\n### Features\n\nFeature directories own product behavior. A feature may contain components, hooks, tests, and\nutilities that belong to that behavior.\n\nStart a feature directory flat:\n\n```text\nfeatures/chat/composer/\n  ChatComposer.tsx\n  ComposerAddMenu.tsx\n  ComposerAddMenu.test.tsx\n  useComposerDraft.ts\n  composerTokens.ts\n```\n\nDo not create `components/`, `hooks/`, `helpers/`, or `tests/` subdirectories merely to classify\nfiles by implementation type. File names already communicate those types, and tests should usually\nremain beside their subject.\n\nIntroduce a subdirectory when a coherent internal subsystem has several files or the flat directory\nbecomes difficult to scan. Name it after its responsibility, such as `draft/`, `tokens/`,\n`attachments/`, or `slash-commands/`; avoid generic `helpers/` buckets.\n\nOrganize by the reason files change together. Feature-based organization does not replace the\none-component-per-file model.\n\n### Components\n\nCreate a separate component file when the component has at least one of these properties:\n\n- It owns meaningful state, effects, or interaction behavior.\n- It is independently testable or reusable.\n- It represents a distinct UI responsibility.\n- Keeping it inline makes the parent difficult to understand.\n- It has grown supporting types or logic that obscure the parent's main flow.\n\nKeep small stateless rendering helpers private in their parent's file when they are only used there\nand remain easy to understand. Extract them beside the parent when they develop their own behavior\nor tests.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Prefer direct imports; do not add a barrel file solely to shorten paths.\n\n### Pages And Routes\n\nFiles under `src/routes/` define routes. Keep them focused on route concerns: parameters, search\nvalidation, loaders, navigation, and composing the route's page.\n\nA page is a composition boundary, not necessarily a feature. A page may combine several features,\nand a feature may appear on several pages.\n\nA small page may remain in its route file. Move a substantial page implementation to\n`src/pages/<page>/`, where it can compose feature components. Components and hooks meaningful only\nto that page stay in the page directory rather than global `components/` or `hooks/`. Apply the same\nflat-first and responsibility-based subdivision rules used for features. Keeping page support code\noutside `src/routes/` also keeps it out of TanStack Router's file scanning.\n\nName every route-level screen component under `src/pages/` with a `Page.tsx` suffix, such as\n`HomePage.tsx` or `WorkspacePage.tsx`. Supporting components keep names that describe their own\nresponsibility, such as `HomeTaskSuggestions.tsx`.\n\nDo not move product behavior into a page merely because the page currently consumes it. Behavior\nwith independent product meaning or use across pages belongs to `src/features/<feature>/`.\n\nDefault uncertain code to the page and promote it when ownership broadens. The current Home page is\nan example of code that predates this rule: `components/AppShell/HomeTaskSuggestions.tsx` and\n`components/MeshBackground.tsx` have `routes/index.tsx` as their only production consumer. They are\nHome-specific composition and presentation rather than shared AppShell primitives, so their target\norganization would be:\n\n```text\npages/home/\n  HomePage.tsx\n  HomeTaskSuggestions.tsx\n  MeshBackground.tsx\n```\n\nIf task suggestions later become an independently meaningful workflow used by other pages, promote\nthat code to a feature such as `features/task-suggestions/`. Do not begin there based only on\nspeculative reuse.\n\nDo not edit `src/routeTree.gen.ts`; it is generated.\n\n### Shared Code\n\n`src/components/` and `src/hooks/` are shared application infrastructure, not default destinations.\nCode should begin under the feature that owns its behavior.\n\nReuse alone does not make code generic:\n\n- If multiple parts of one feature use an item, move it to their nearest common feature directory.\n- If another feature consumes a concept still owned by the original feature, keep it exported from\n  that feature.\n- Promote an item to global `components/` or `hooks/` only when unrelated features use the same\n  feature-independent abstraction and its API no longer depends on its original feature.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase important domain behavior.\n\nMove code only as high in the hierarchy as its ownership requires. For example, a skill pill shared\nby the composer and chat history belongs to `features/chat/`, while a generic application icon\nbutton belongs to `components/`.\n\n### Component APIs And Composition\n\nRepresent props that are valid only together as one object or a discriminated union. Do not permit\npartial configurations:\n\n```tsx\n// Avoid: callers can provide a count with no way to dismiss it.\ncount?: number;\nonDismiss?: () => void;\n\n// Prefer: the capability is either complete or absent.\nnotice?: { count: number; onDismiss: () => void };\n```\n\nA controlled value requires a change callback. Otherwise, the component owns the value. Do not copy\na controlled prop into local state with an Effect:\n\n```tsx\n// Controlled\nvalue: string;\nonValueChange: (value: string) => void;\n\n// Uncontrolled\ninitialValue?: string;\n```\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events. For\nexample, expose `onModelChange(modelId)` rather than `setSelectedModel` or `onChange(event)`.\n\nAdd `children`, named slots, variants, `className`, DOM prop passthrough, or imperative refs only\nwhen a current caller needs that control. Do not add them in anticipation of reuse.\n\nUse context for application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props; do not introduce context merely to avoid\npassing them through one or two component levels.\n\nExtract a component or hook when the extracted unit owns a complete concern, such as state and its\ntransitions, an Effect lifecycle and cleanup, an accessible interaction, or a pure transformation\nthat can be tested independently. Keep code together when the child would mostly forward markup or\nwould need the parent's refs, setters, and synchronization callbacks to function.\n\n## Kumo And Styling\n\nUse Kumo components and Kumo semantic design tokens by default. Check Kumo before creating a custom\ncontrol, interaction pattern, or visual primitive. Existing Workshop wrappers may be used when they\nprovide established application behavior that Kumo does not provide directly.\n\nCustom colors and design tokens outside Kumo are prohibited unless the user explicitly requests\nthem. In particular, do not add:\n\n- Hex, RGB, HSL, or OKLCH color literals in component styles.\n- Arbitrary Tailwind color values.\n- New application-specific color variables or token families.\n- Feature-local replacements for Kumo surfaces, borders, text, status, focus, or interaction tokens.\n\nUse Kumo semantic classes such as `bg-kumo-base`, `text-kumo-subtle`, and `border-kumo-line` rather\nthan palette colors. Global Kumo token theming is a deliberate application-level decision and must\nnot be introduced or changed as part of ordinary feature work.\n\nLegacy custom tokens and color declarations in the current codebase are not precedent for new code.\nDo not expand their use. Migrate them only as part of explicitly scoped cleanup.\n\nTailwind remains appropriate for structure: layout, spacing, sizing, positioning, responsive\nbehavior, and typography. Use custom CSS only for technical behavior that Kumo and utilities cannot\nexpress, such as editor integration or measured overlays; custom CSS does not relax the color and\ntoken rules.\n\nWhen a Kumo component is unsuitable, record the concrete behavioral or accessibility gap before\nadding a shared Workshop abstraction. Do not wrap Kumo solely to restyle it.\n\n## React\n\n- Define components and hooks as named `const` arrow functions by default. This keeps declarations\n  before use and avoids relying on function-declaration hoisting. Type props directly rather than\n  using `React.FC`. Ensure components wrapped in `memo`, `forwardRef`, or similar APIs retain an\n  explicit name for React DevTools and stack traces, using `displayName` when inference is unclear.\n- Treat Effects as an escape hatch for synchronizing React with an external system, not as a\n  general state-management tool. Follow React's\n  [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) guidance.\n- Do not use an Effect to derive render data from props or state. Calculate it during render instead.\n- Do not use an Effect for logic caused by a user interaction. Run it in the event handler that\n  knows what happened.\n- Do not use an Effect to keep two pieces of React state synchronized. Prefer a single source of\n  truth, derived values, controlled components, or lifting state.\n- Prefer a component `key` when an identity change should reset the component's state.\n- Prefer `useSyncExternalStore` for external store subscriptions when it fits.\n- Effects that fetch or subscribe must clean up stale work and subscriptions. Components must remain\n  correct when Effects are restarted or mounted again in development.\n- Avoid chains of Effects that update state only to trigger another Effect.\n- Keep state as close as possible to the behavior that owns it.\n- Extract a hook when it represents a coherent behavior or is reused, not simply to shorten a file.\n- Do not add `useMemo` or `useCallback` by default; use them when identity or expensive computation\n  has a concrete effect, and follow existing compiler guidance.\n- Preserve keyboard behavior, focus management, and accessible names when extracting interactive UI.\n- RPC stubs must follow the disposal and React state rules in the repository-wide `AGENTS.md`.\n\n## Comments\n\nPrefer code that communicates its intent through names, types, and structure. Do not add comments\nthat narrate what the next line or block already says.\n\nComments are appropriate for gotchas, non-obvious invariants, external constraints, security or\nperformance reasoning, and deliberate departures from a convention. Explain why the surprising\nchoice is necessary, not the mechanics visible in the code. Remove or update comments when the\nconstraint they describe no longer exists.\n\n## Tests\n\nColocate unit tests with their subject using `*.test.ts` or `*.test.tsx`. Use a feature-level\n`__tests__/` directory only for scenarios that span several modules and have no single owner.\n\nTests should either protect behavior whose regression would be important, or verify real logic\nintroduced by the change. Do not test React, JavaScript, TypeScript, Kumo, or another framework's\nown behavior merely because it appears in the implementation.\n\nTest observable contracts, product rules, state transitions, accessibility behavior, race handling,\nand failure paths. Avoid assertions coupled only to implementation details, trivial passthrough\nmarkup, or behavior already guaranteed by types and the underlying platform. A test should have a\nclear failure whose meaning matters to this application.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage when\nan extraction exposes important previously untested logic; do not add tests solely because a file\nor component boundary now exists.\n\nUseful commands from the public workspace root:\n\n```bash\npnpm --filter @gadgets/workshop-frontend test:run\npnpm exec tsc -p packages/workshop-frontend/tsconfig.json\npnpm exec tsc -p packages/workshop-frontend/tsconfig.vite.json\npnpm exec vp run -F @gadgets/workshop-frontend build\n```\n\nRun `pnpm lint` from the public workspace root before pushing when the broader workspace state\nallows it.\n","packages/workshop-frontend/README.md":"# Gadgets Workshop Frontend\n\nSingle-page app for the Gadgets Workshop UI. Built with React, Kumo, and Vite.\n\n## Development\n\n```sh\npnpm dev                # start dev server on http://localhost:3000\npnpm exec vp run build  # type-check and build for production\npnpm preview            # preview production build locally\n```\n\n`build` is a Vite+ task, not a package.json script, so that it can declare the `VITE_*` flags below\nas fingerprinted env: a cached `vp` run executes each task in a clean environment, which drops any\nambient value, and a flag the fingerprint ignores means a changed flag replays the old bundle.\n\nIt always produces a production bundle, whatever `NODE_ENV` the shell holds, and it deletes `dist/`\nbefore building — a cache hit restores archived files without deleting any, so the previous build's\nsourcemaps would otherwise linger. `vite.config.ts` documents both.\n\n## Gatekeeper user selection\n\nGatekeeper management apps can open the Workshop-owned **Select users** picker through\n`selectUsers(receiver)`. Selections are delivered through the receiver's `receive(selection)` method.\nWith directory search enabled, users select only directory results; empty or failed searches do not\noffer an exact-username/email fallback. With search disabled, users enter an exact identifier\ndirectly. Arrow keys navigate options while focus stays in the input; Tab moves to the next control\nwithout selecting a user. Picking someone does not re-run the current search: people already added\nor being added are excluded when each search is issued and hidden from results returned earlier.\nIf minting or delivery fails, the current query is refreshed so the person can be selected again,\nincluding when the server excluded them from a search made while their selection was pending.\nRefreshed results stay dismissed after blur or Escape until the user focuses, types, or uses an\narrow key to reopen them.\nEach person the app accepts is announced to assistive technology by their snapshotted name.\n\n**Done** and the close button both wait for ticket minting and selection deliveries before closing.\nIf a pending mint or delivery fails, closing is cancelled so the error remains visible and the user\ncan retry or choose **Done** again. Every picker session starts with fresh search and selection state,\nincluding when closing one and opening another are batched in the same render.\n**Stop waiting and close** cancels the picker and disposes its outstanding mint and delivery RPC\npromises as well as its receiver; frame teardown does the same. Closing and cancelling both resolve\n`selectUsers()`; after a cancellation, a delivery already sent to the app may still settle, and\ncancellation cannot undo a selection the app already accepted. `selectUsers()` rejects only when the\npicker cannot be opened or, on teardown, when the app's connection to the host closes. A superseded\nor abandoned directory search is disposed as soon as it is no longer needed.\n\n## Authentication modes\n\nThe frontend supports two authentication modes, selected at build time.\n\n### Password mode (default)\n\nUsers log in with a username and password. Account creation is available via `/signup`.\nThis is the default — no extra configuration needed.\n\n### Cloudflare Access mode\n\nWhen the backend is deployed behind [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/), Access handles identity before the user ever reaches the app. In this mode:\n\n- The password-based login page and signup page are disabled.\n- On load, the app authenticates automatically using the CF Access session that Access has already established (via `authenticateFromCfAccess()` on the server).\n\nTo build in CF Access mode, set `VITE_CF_ACCESS_MODE=true`:\n\n```sh\nVITE_CF_ACCESS_MODE=true pnpm exec vp run build\n```\n\nOr add it to a `.env` file for persistent local configuration:\n\n```sh\n# .env.local\nVITE_CF_ACCESS_MODE=true\n```\n\nThe backend also needs to be configured with the `CF_ACCESS_ISS` and `CF_ACCESS_AUD` environment variables (see the workshop-backend package) for the JWT verification to work.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"This project is building a platform for \"vibe coded\" personal applications and AI agents that run inside a strong sandbox.\n\nThe following files are commonly important to reference:\n\n* packages/workshop-shared/node_modules/capnweb/README.md: Explains how to use Cap'n Web RPC, which is used extensively for client-server communications.\n* packages/workshop-shared/src/api.ts: Defines the RPC API used between the frontend and backend.\n\nThe project structure is:\n\n* packages/workshop-frontend: The Gadgets Workshop UI.\n    * This is a pure single-page app, running entirely client-side.\n    * It speaks to the backend using an RPC API over a persistent WebSocket connection.\n    * Uses React, Kumo UI (https://kumo-ui.com/api/component-registry), Phosphor icons, and Vite.\n* packages/workshop-backend: The Gadgets Workshop server.\n    * Runs on Cloudflare Workers.\n    * This is the **kernel**: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read *every line* of `workshop-backend` and of API changes in `workshop-shared`, so keep diffs here small and elegant. Concretely: doc-comment **every** exported member of the `workshop-shared` public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes \"ambient\" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits so `workshop-backend`/`workshop-shared` can be reviewed apart from UI), since fewer kernel lines = easier review.\n    * Installs the **bundled blueprints** from `@gadgets/bundled-blueprints` on a deployment's first `/api` request, and offers them as the deployment's standard output formats. `scripts/build-bundled-blueprints.ts` generates the gitignored `src/generated/bundled-blueprints.ts` they are installed from, which holds each blueprint's manifest and built files (`BUNDLED_BLUEPRINTS_DIR` overrides the source directory), so `build` and `test` both run the generator first; `pnpm import:bundled-blueprint` updates or adds a blueprint from a Workshop export, of either `.gadget` version. Installing one (`src/bundled-blueprints.ts`) stores it as any published blueprint is stored: a git commit of its files, as a packfile at `<blueprintId>/<commitId>` in R2, named by `metadata.commitId` in KV (see `docs/blueprints.md`). The commit is a *snapshot release* (`buildSnapshotRelease` in `src/blueprint-release.ts`): parentless, with a fixed author, date and message, so it depends on the files alone. Every deployment therefore derives the same commit from the same files, and the installer never reads what was installed before. The cost is that a bundled blueprint's versions are not chained to one another as a published blueprint's releases are. A gadget made from one still takes its updates, merged against the release the gadget last took.\n    * Agent turns emit the Workers trace spans Cloudflare's Agents dashboard reads (`src/agent-tracing.ts`): `invoke_agent` per turn, `chat` per model request, `execute_tool` per tool call, and `tool_approval` when an agent's action waits for a manual decision and when it is approved or denied. They carry token counts, including cache reads and writes, but never prompts, responses or tool data. A span belongs to the invocation that opened it, and once a Durable Object invocation has returned, the next event the object receives ends its trace. So a turn keeps all its spans only while the request that started it (usually the browser session) stays open.\n* packages/bundled-blueprints: The blueprints the deployment ships with, `blueprints/<name>/` holds each blueprint's `blueprint.json` plus its gadget code under `files/`, which may be TypeScript.\n* packages/workshop-shared: Shared API definitions between client and server.\n    * This defines the application's RPC interface.\n    * The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details.\n* packages/configurator-ui: The authoring API (`src/index.ts`, type-only stubs) for optional gatekeeper resource configurator UI modules, the sandbox runtime that renders them (`src/runtime/`: Kumo components on `preact/compat`), and the build that bundles both into each configurator's frame (`build/`).\n    * Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.ts` as part of package builds. Each frame is one self-contained HTML document: the minified runtime, its Tailwind/Kumo CSS, and the module, under a content security policy that admits only those scripts by hash and allows no network access. Kumo and Base UI support only React, so the browser suite (`vitest.browser.config.ts`, Chromium) is what catches an upgrade that stops working on Preact; `build/frame.test.ts` drives a generated frame over the real Cap'n Web host protocol.\n    * `pnpm dev-server` rebuilds a gatekeeper's frames when its `src/configurator/` or the runtime's `src/runtime/` changes; restart it after editing `build/`, which the watcher has already loaded.\n* packages/ui: Shared runtime React UI used across the Workshop and gatekeeper management apps.\n    * Kumo remains the primitive and token foundation. Reusable Gadgets interaction patterns and composed controls belong here rather than being reimplemented in individual apps.\n* packages/gatekeeper-*: Gatekeeper workers for external service integrations.\n    * Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a `gatekeeper-*` package with **no `wrangler.jsonc` is a library, not a worker** (`gatekeeper-kit` here; `gatekeeper-shared` in the internal repo). Deployable discovery is config-gated, not name-gated — `readDeployablePackages` in `scripts/release/manifest-lib.ts` keys solely on the presence of `wrangler.jsonc`, and `run-dev-server.ts` requires it too — so adding one to a library package is what would make it deployable, at which point `workerKind` would classify it a gatekeeper by prefix and the deploy wizard would demand `CLIENT_ID`/`CLIENT_SECRET` for it. `manifest-lib.test.ts` fails first if that ever happens.\n    * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml`, which sends the popup to the Workshop's `/connect/handoff` page, and that page redeems the single-use ticket over the popup's own session together with a per-flow nonce (see `docs/connect-handoff.md`); a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone.\n    * A gatekeeper may declare `VendorDescription.autoProvisionsAccount`: it can mint a connected account with no OAuth flow (via `GatekeeperVendor.createAccount()`, which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — **disabled** / **optional** / **enabled** (default **optional**) — resolved in `provisioning-policy.ts`: `enabled` auto-provisions the account for every user (forced, and hidden from the Connectors list), `optional` lets each user opt in from the Connectors page or during onboarding (where each starts switched on and is provisioned when the user finishes), and `disabled` offers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The **account** (a `GatekeeperUser`) declares in its `AccountDescription` whether it provides an agent **singleton** (`singleton: { tsType }`) and/or a **management UI** (`providesUi`). The Workshop auto-provides the singleton to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's env as a **named chat binding** (named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in overseer.ts) that the agent reads in `executeCode` (`getSession`/`getAgentCatalog`), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it. The UI is hosted at `/gatekeepers/$appId` (the gatekeeper's vendor id, e.g. `/gatekeepers/context`) via `startAppUi({ isAdmin })`. The two are orthogonal — an account can declare either, both, or neither.\n* packages/mcp-shared: Shared implementation behind the two MCP gatekeepers — `gatekeeper-mcp` (endpoints a user pastes) and `gatekeeper-mcp-portal` (one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. See `packages/mcp-shared/README.md` and each connector's README.\n    * The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation, everything else is queued for approval, and auto-*applying* a write additionally requires a `vetted` endpoint — which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`.\n    * OAuth uses the official `@modelcontextprotocol/client`; always give SDK OAuth operations `sdkFetch(...)` so every request and redirect retains endpoint and SSRF checks.\n* packages/gatekeeper-context: The Context Library — a gatekeeper whose account provides a singleton read session + a management UI, for authoring collections of context documents that agents read as observations. Collections have one of two visibilities: **private** (created by one account and shareable with others) and **public** (created/edited only by deployment admins, readable by everyone and auto-enabled for all users; not shareable). It owns its state in three Durable Objects (`ContextCollectionDurableObject` for content and a private collection's grants, `UserLibraryDurableObject` for each account's index of collections it created or was given, `LibraryRegistryDurableObject` for the domain's public set) plus a KV namespace. All data is namespaced by a `sharingDomain` (from the binding's props, see `domain.ts`) so multiple workshops sharing one gatekeeper instance stay isolated.\n    * Sharing is whole-collection. A private collection's own grants (`viewer` / `editor` / `owner`, keyed by Context account) and its `everyoneCanView` flag are the sole authority, interpreted only by `collection-access.ts`; every listing, agent read, and observer verification re-checks them live, so a library index entry is discovery and never access. A collection always keeps an owner except when its last owner's account is disconnected while others still hold access; its editors then keep editor permissions only (nobody can delete it or grant ownership), and it is deleted once its last grant goes, even if everyone can view it. Everyone-viewable only grants read access (so sharing a workspace that used the collection is not blocked); it never adds the collection to anyone's library. Shares arrive through the Workshop user picker: the management API redeems the ticket and takes the recipient's account IDs from their Context verifiers (`getAccountId`), never from the browser, and grants all of that person's accounts together. Losing write access revokes the git mirror tokens that account minted, and tokens from before sharing count as the creator's; the collection serializes grant changes, token minting, and deletion so none can race another, and retries a failed revocation from an alarm. Disconnecting marks the account's library first, so a share racing it is refused rather than left behind. `plans/context-collection-sharing.md` is the design record.\n    * Its `GatekeeperVendor` entrypoint (bound as `GATEKEEPER_CONTEXT`) declares `autoProvisionsAccount` and mints a `ContextAccount` via `createAccount()` (no user identity is passed in; the account keys its private data by its own generated `accountId`). The account exposes the agent read session (`getSession()`), collection discovery metadata (`getAgentCatalog()`), and a management UI (`startAppUi({ isAdmin })`). The UI is a single-file React SPA in `app/` (Vite + Tailwind + Kumo) bundled by `build-app.ts` into `src/generated/app.txt`.\n* packages/gatekeeper-scheduler: Scheduled Tasks — an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks plus a read-only management UI. One account-scoped `ScheduleDriver` Durable Object stores enabled schedules and delivers them from a shared alarm; hook enablement remains in the Workshop Connections UI.\n* packages/gatekeeper-websearch: Web Search — an auto-provisioned gatekeeper whose account provides an ambient `WEB_SEARCH` singleton that searches the public web through its own Workers AI binding (`websearch()`, through the AI Gateway its `CF_AI_GATEWAY` var names, provider pinned to Ceramic). The deploy service sets that var to the instance's gateway, the backend's; anywhere else it defaults to the account's `default` gateway. Each query is recorded as an observation *before* it is sent. A workspace that has observed restricted data can still search: unlike a `webFetch` URL, the query can only go to the pinned provider. Preinstalled on every deploy like Scheduled Tasks; every search spends the account's AI Gateway credits, outside the model spend limits, unless that gateway holds a Ceramic key, which Ceramic then bills. The README is the design record.\n* packages/gatekeeper-gitlab: GitLab, for gitlab.com and self-hosted instances — a mirror of `gatekeeper-github` (project / issue / merge request resources, the same action queue and read-time simulation, the same worktree contract) in GitLab's own terms. `plans/gitlab-gatekeeper.md` is the design record, and its divergence table lists every place GitLab's API forced a different shape; the README covers instance configuration (`GITLAB_URL`, `GITLAB_API_URL`, and an optional Cloudflare Access service token).\n* packages/gatekeeper-x: X (formerly Twitter) — account / post / List / read-only profile resources over plain `fetch` (the `twitter-api-v2` package breaks on Workers). `plans/x-gatekeeper.md` is the design record. X's API is pay-per-use and bills every resource a read returns, so cost is part of the design and easy to undo by accident:\n    * Every read goes through the gatekeeper's `#read`, which reserves against the connection's daily read limit (`X_DAILY_READ_LIMIT`, counted per UTC day in the `UserAccount` DO across all of the connection's bindings) and settles with what X returned. Listings use `XCursor`, one X request per `next()`, never the kit's `TokenCursor`, which refills a short page with up to ten more billed requests.\n    * Only bookmark, mute and hide are `autoApprovable`: X's developer policy forbids automating likes, follows, reposts and posts. Do not widen that list.\n    * A connection is pinned to the X user it was made as (a reconnect as anyone else is refused and its new grant revoked), and actions are fenced on that X user's ID rather than the connection generation, so a same-user reconnect to widen scopes does not strand pending actions.\n    * Publishing is `claimBeforeApply`, and a send whose outcome X never reported is reconciled inline on the next apply (an attempt marker, then a look at the account's posts from around the attempt, binding only a unique match on text, links, media and reply parent), because the kit's `ActionOutcomeUnknownError` is terminal. Creating a List is reconciled the same way. Rejecting a publish or List creation that may already be on X is refused.\n    * X is renaming \"tweet\" to \"post\" on the wire and both spellings occur; `x-normalize.ts` reads both. The configurators copy URL grammar from `x-urls.ts`, and `__tests__/configurator-url.test.ts` keeps the copies in step.\n* packages/gatekeeper-cloudflare: Cloudflare OAuth, serving three unrelated purposes from one connected account — sign-in (`AUTH_GATEKEEPERS`), AI Gateway billing, and **Workers Observability** read-only telemetry resources. Two resource granularities (whole account, or one Worker) both map to the single indivisible `workers-observability.read` scope, so the capability boundary is the *binding*, not the grant.\n    * Layering: `observability-api.ts` is the only place that talks HTTP; `observability-parse.ts` validates every response (no blind casts); `observability-session.ts` is the agent-facing session, where every read funnels through one `#observe` so no path returns data unaudited; `observability-discovery.ts` derives field names/values from a sampled events query.\n    * Scopes fail closed to `BILLING_SCOPES`, and an omitted `resourceUrlPatterns` (\"all resource types\") is distinct from `[]` (\"none\"): a billing-only connection must not silently acquire telemetry access, and recording a wider grant than was made would make `ensureResources` short-circuit into a binding that 403s with no way to re-prompt.\n    * Three provider behaviours return wrong-but-plausible data with no error, so none is trusted (all verified against a live account): `telemetry/keys`/`values` **ignore** the `filters` they accept, so a constrained discovery call is answered from a filtered `telemetry/query` sample or it would disclose the whole account; an unknown filter key matches nothing, which bites because a log's own fields are returned nested under `source` but indexed under their *bare* name (`{event: \"x\"}` → `source.event`, queried as `event`, hence `observabilityFieldKey` accepting the `source.` alias wherever a caller names a field); and `/accounts` pages at 20 by default, so it must be walked to the end.\n    * Worker-scoped bindings prepend an immutable `$metadata.service` filter *and* re-filter the response, since a filter the provider silently ignored would leak another Worker's telemetry. A dropped event proves the filter was not applied, so the provider's own `count` is then withheld rather than reported (it would count the whole account) and the drop is logged at `error`; `statistics` is kept, since it describes what the query cost rather than how much matched. Pagination cursors come from the provider's raw events rather than the surviving ones, so a fully-foreign page can't stall pagination and hide the caller's own older data. Trace *summaries* are account-only (their shape describes the whole cross-service trace); a Worker binding can still fetch its own events for a known trace id. `calculate()` is the one read with no second line of defence — an aggregate can't be un-mixed — so it rests solely on the injected filter; that is accepted and documented on the method, with the group-by fix left as a follow-up.\n    * A provider error message can quote a caller-supplied filter value back, so only its numeric `codes` are logged — filter *values* stay out of the audit trail (`summarizeFilter`), and the message travels to the caller who caused it.\n    * Tests are two vitest projects: `vitest.config.ts` (Node, pure logic) and `vitest.worker.config.ts` (workerd, for `RpcTarget`/`RpcStub`/Durable Objects). The workerd suite reaches the gatekeeper through a `TestHooks` Durable Object because a `DurableObjectClass` carrying `ctx.props` is only reachable via `ctx.facets` — the way the overseer instantiates it.\n    * `src/configurator/*.tsx` duplicate the resource-URL grammar from `resources.ts` and **must**: `build-gatekeeper-configurator.ts` transpiles each per-file, stripping only `@gadgets/configurator-ui` and type-only imports, so they cannot import runtime helpers. `__tests__/configurator-url.test.ts` keeps the copies in step, and `configurator-fields.test.ts` drives `render` against a mocked runtime — the runtime's `clearFields` only drops an autocomplete's typed query, so a dependent field must *also* be nulled through `setValues` or the stale value silently survives into the resource URL.\n* packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix: `/api/*` and `/blueprint-screenshot/*` to the workshop backend, `/gatekeeper/<name>/*` to whichever gatekeepers are bound (discovered by scanning its own `GATEKEEPER_*` service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (`pnpm dev-server`): with no `ASSETS` binding it proxies frontend requests to the Vite dev server instead.\n\nFrontend conventions (Workshop, gatekeeper management apps, and shared UI):\n\n* IMPORTANT: Load the `frontend-conventions` skill before creating, materially changing, moving, or reviewing React frontend code anywhere under `packages/`. The bullets below are the mandatory summary; the skill contains the complete conventions and examples.\n\n* Organize code by product ownership before implementation type. Keep feature-owned components, hooks, tests, and utilities together; start directories flat and introduce responsibility-named subdirectories only when a subsystem grows.\n* Use PascalCase filenames for components and camelCase filenames for hooks and non-component modules. Colocate `*.test.ts(x)` files with their subject.\n* Keep small stateless render helpers private. Extract a component or hook when it owns meaningful state, effects, interactions, accessibility behavior, or a reusable responsibility.\n* Cross-application UI belongs in `@gadgets/ui`. An app's local `components/` directory is shared only within that app. Product-specific compositions stay with the product even when they use shared primitives.\n* Use Kumo components and semantic Kumo tokens by default. Check Kumo and `@gadgets/ui` before creating a control or interaction pattern. Do not add custom color literals, feature-local token systems, or wrappers that only restyle Kumo.\n* Consult the installed Kumo API before implementing or changing Kumo UI: run `npx @cloudflare/kumo docs <Component>` (for example, `npx @cloudflare/kumo docs Select`).\n* Tailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and typography. Custom CSS is for technical behavior Kumo and utilities cannot express.\n* Represent props that are valid only together as an object or discriminated union. Controlled values require a change callback. Name callbacks `on<Action>` and pass domain values rather than React setters or browser events.\n* Add slots, variants, DOM passthrough, imperative refs, and other customization surface only for current callers. Do not generalize based on speculative reuse.\n* Treat Effects as synchronization with external systems, not as derived-state machinery. Keep state close to its owner, calculate render data during render, clean up subscriptions, and avoid chains of Effects.\n* Prefer named arrow-function components and hooks. Do not add `useMemo` or `useCallback` without a concrete identity or performance need.\n* Preserve keyboard behavior, focus management, accessible names, and announcements when building or extracting interactions.\n* Tests should protect observable behavior, product rules, accessibility, state transitions, races, and failure paths. Do not test framework behavior or implementation details merely for coverage.\n* Prefer code that communicates intent through names and types. Comments should explain non-obvious constraints and reasons, not narrate the next line.\n\nDeployment admin settings (the `/admin` panel) follow a few conventions worth knowing when extending them:\n\n* `packages/workshop-backend/src/storage-schema/admin-settings-storage.ts` defines `AdminConfig` (its helpers live in `src/admin-config.ts`) — the deployment's \"soft\" customizations: agent instructions, banners/theme, and which gatekeeper connectors/resources are offered (plus the three-state mode for auto-provisioning gatekeepers, see `provisioning-policy.ts`). Connectors/resources default to enabled and the admin UI opts them *out*; auto-provisioning gatekeepers default to *optional*. **Authentication/authorization config (sign-in providers via `AUTH_GATEKEEPERS`, password login via `DISABLE_PASSWORD_AUTH`) is deliberately NOT here** — it stays env-var driven (`auth/config.ts`) so it can't be changed by a compromised admin session.\n* The `AdminSettings` durable object owns the authoritative `AdminConfig` and mirrors it to a single reserved KV key (`.adminConfig`, see `isReservedBlueprintKey()`), so hot-path code (connect/agent) reads it with one cheap KV get via `readAdminConfig(env)`. The DO is the only writer (`updateAdminConfig(patch)`).\n* Admin operations are exposed as an `AdminApi` capability obtained via `AuthenticatedApi.getAdminApi()` (returns null for non-admins). The `#isAdmin()` check happens once when the capability is minted, so the individual methods don't re-check.\n* `user.ts:#assertResourceEnabled()` is the single core chokepoint where disabled gatekeepers/resources are enforced before a capability is minted. Both mints call it: `getGatekeeperClassFor()` (gadget/agent code can't reach it directly) and `createResourceGatekeeper()` (reached only through the agent's `createExternalResource` tool).\n* In AI Gateway mode the deployment's models are admin settings too (`AdminConfig.modelModes`, `addedModels`, `modelSettings`, `addedProviders`, `userModelsEnabled`). `GatewayModels` in `ai-gateway.ts` is the only place that merges them with `SUGGESTED_MODELS`, and *disabled* is enforced where a model is about to run: `user.ts:getChatContext()` and `LanguageModelGatekeeper.startSession()`.\n\nWorker configs: each Worker's `cloudflare.config.ts` (`@cloudflare/config` format, built from the shared factory in `scripts/worker-config.ts`) is the source of truth. The `wrangler.jsonc` beside it is committed but generated by `pnpm configs:generate` (`scripts/generate-worker-configs.ts`) and never edited by hand, because Wrangler's new-config support cannot drive the multi-worker dev server, `wrangler types`, the vitest pools or the integration harness. Settings `@cloudflare/config` has no field for (`build`, module `rules`, KV `preview_id`, `assets.directory`) go in the config's `wrangler` export, and Durable Object migrations stay tagged in its `migrations` export, because the release manifest ships them to the deploy service verbatim. `pnpm configs:check` (part of `pnpm lint` and CI) fails on a stale or hand-written file; `pnpm dev-server` regenerates before starting.\n\nRelease pipeline (`scripts/release/`) — how customer instances get deployed:\n\n* `build-release.ts` bundles every deployable worker byte-identically (wrangler dry-run with the pinned wrangler), builds the Access-mode frontend asset build, and generates the release manifest — the contract between this repo's CI and the deploy service, produced by `manifest-lib.ts` from each package's wrangler.jsonc with account-specific values replaced by placeholders (`$ACCOUNT_ID`, `$WORKER_NAME(...)`, `$SECRET(...)`, `$PUBLIC_BASE_URL`, ...).\n* `upload-release.ts` mirrors the release to R2 content-addressed, manifest last; with `--candidate` the manifest lands under `candidates/<id>/` (invisible to the deploy service) so e2e can verify it, and `promote-release.ts` then copies it to `releases/<id>/` — publishing is that single all-or-nothing manifest copy. The copy is not isolated against concurrent promotions, so CI serializes promote runs (a GitLab resource group) and the script's newer-release guard skips candidates that a later release has already superseded.\n* The manifest is covered by a golden-file test; after an intentional manifest change, regenerate with `UPDATE_GOLDEN=1 node --test scripts/release/manifest-lib.test.ts` and review the golden diff.\n* Running the flow by hand (upload and promote need `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`):\n    * `node scripts/release/build-release.ts --out release-out` — build everything into `release-out/` (id defaults to `r<CI_PIPELINE_IID>-<sha7>` in CI, `dev-<timestamp>` locally; override with `--release-id <id>`).\n    * `node scripts/release/upload-release.ts --release release-out --candidate` — mirror to R2; omit `--candidate` to publish directly (bypasses the gate — CI never does this).\n    * `node scripts/release/promote-release.ts --release-id <id>` — copy the verified candidate's manifest into `releases/<id>/`.\n* Deploy-wizard configuration: an installable gatekeeper's user-supplied inputs default to OAuth `CLIENT_ID`/`CLIENT_SECRET` secrets; a per-package `deploy-inputs.json` overrides them, and `NO_DEFAULT_CRED_INPUTS` in `manifest-lib.ts` opts out gatekeepers that take no third-party OAuth app credentials (the wizard blocks Install on unfilled secret inputs, so a spurious default makes a gatekeeper uninstallable). Backend instance-state vars (`ADMINS`, `CLOUDFLARE_OS_DEPLOYMENT`, ...) are injected by the deploy service at PUT time, never manifest-templated.\n\nDevelopment checks:\n\n- Run `pnpm lint` before pushing; it includes script types, generated-config checks, and the workspace build.\n- `pnpm build` runs type-checks and codegen. For one package use `pnpm exec vp run -F <package> build`; most builds are tasks, not package.json scripts.\n- Use `pnpm --filter <package> test:run` while iterating and `pnpm test` for workspace verification. Cached tasks may replay results; cold-checkout tests must generate their own prerequisites on both entrypoints.\n- Cached Vite+ tasks strip undeclared environment variables. Declare output-affecting variables in `cache.env`; a path value does not fingerprint the external files it names.\n- For slow CI/container runs, keep the local watchdog defaults and explicitly choose longer limits. See [testing guidance](docs/testing.md) for commands and timeout policy.\n- Read [build-system guidance](docs/build-system.md) when changing task graphs, codegen, caching, or dev-server startup; [integration testing](docs/integration-testing.md) covers the workerd harness.\n\nIMPORTANT: This repository uses pnpm, not npm. Always use pnpm.\n\nIMPORTANT: Remember when using RPC to use promise pipelining whenever possible. Cap'n Web implements promise pipelining (similar to Cap'n Proto). This means that if an RPC returns a stub, it's not necessary to await the RPC -- the promise itself can be used in place of the stub. Also, Cap'n Web lets you use the promise for a future result (even if it isn't a stub) in the arguments for another call; the promise will be replaced with its resolution on the server side before delivering the arguments. See the Cap'n Web README.md for more details.\n\nIMPORTANT: When using React's useState(), the state value cannot be an RPC stub. At runtime, all stubs appear to be callable (because the system doesn't actually know if the stub points to a function on the server side or not). But the setter returned by useState() has different behavior if passed a function (including any callable object): it calls the function in order to get the state. In order to avoid this problem, whenever a useState() state will contain an RpcStub, it's important to wrap the stub in an object, and set the state to that object instead.\n\nIMPORTANT: RPC stubs must be disposed to prevent resource leaks on the server side. Call `stub[Symbol.dispose]()` when the stub is no longer needed (or use a `using` declaration where possible). In particular, when a React component obtains a stub in a useEffect, the cleanup function should dispose the stub.\n\nIMPORTANT: All RPC interface implementations should use the annotation `@validateRpc()` to apply capnweb-validate, which installs auto-generated runtime type validation matching the interface's TypeScript signatures. Do not write redundant validation code that duplicates the checks capnweb-validate already covers.\n\nIMPORTANT: Server-side logging uses `@gadgets/observability/logger` (frontend browser `console.*` is out of scope):\n- Define a package-owned field type and module-scoped logger with a stable dot-separated `component`\n  and, for gatekeepers, `vendorId`:\n  `const logger = createLogger<GitHubLogFields>({ component: \"gatekeeper.github\", vendorId: VENDOR_ID });`.\n- Emit concrete event names and relevant typed fields, for example:\n  `logger.warn(\"failed to notify credential expiry\", { event: \"credentials.expiry.notify.failed\", error: err });`.\n  Each call emits one indexed object; module/child fields such as `vendorId` are inherited.\n- Use immutable `logger.with(fields)` for object-owned or nearby context. Prefer module/object loggers\n  over logger parameters, and do not replace a shallow child logger with ambient context just to\n  remove a local variable.\n- For bounded operation context needed by deep helpers, independent loggers, or other observability\n  consumers, use `createObservabilityContext` from `@gadgets/observability/observability-context`.\n  Re-establish it per operation;\n  it does not cross RPC, hibernation, or restart, and requires `nodejs_als` or `nodejs_compat`.\n- Pass caught values as `error`. The helper stringifies `Error` instances and primitives, uses an\n  own string `message` for plain objects, omits `undefined`, and adds stacks to all `Error` logs.\n  Keep this normalization deliberately small; do not traverse causes or copy arbitrary properties.\n- Extend field vocabularies locally. Levels: `error` needs attention, `warn` continues best-effort,\n  `info` is notable lifecycle, and `debug` is noisy breadcrumbs. Never log secrets, prompts, headers,\n  tokens, or request/response bodies.\n- To also dispatch a failure to the optional external issue Reporter (in addition to logging it),\n  call `reportIssue(failureSite, caught, options?)` from\n  `@gadgets/observability/error-reporting`. Attach ambient fields from the package's observability\n  context and augment them with capture-site fields:\n  `reportIssue(\"overseer.catalog-fallback\", err, { handled: true, attributes: { ...obsContext.get(), gatekeeperId } });`.\n  It is a no-op when the `ERROR_REPORTER` binding is absent (local dev / deployments without an issue\n  destination). Only bounded scalars are retained as attributes; reported context obeys the same\n  no-secrets rules as log fields.\n- Usage metrics go to the optional `METRICS` Analytics Engine dataset through\n  `@gadgets/observability/metrics`, whose column layout (`metrics-schema.ts`) is also what\n  dashboards query; Workshop product events reach it through `recordAnalytics`.\n\nIMPORTANT: Frontend error reporting is a separate, opt-in path:\n- `@gadgets/error-reporting` owns the vendor-neutral browser/Worker event contract and tolerant,\n  bounded normalization. `VITE_FRONTEND_ERROR_REPORTING=true` enables trusted frontend producers\n  and their hidden source maps at build time; deployments without reporting should leave it unset.\n- The Workshop browser sends best-effort reports to the same-origin `POST /api/client-errors`\n  endpoint. The backend dispatches only when both `FRONTEND_ERROR_REPORTER` and\n  `FRONTEND_ERROR_RATE_LIMITER` are bound; otherwise the endpoint is an intentional no-op.\n- Gatekeeper management/configurator UIs run as Workshop-owned opaque-origin `srcDoc` frames. They\n  send bounded reports with `postMessage`; the host accepts them only from the known frame window\n  with origin `null`, adds host-owned surface/vendor context, and performs the same-origin POST.\n  Do not add direct cross-origin reporting from a gatekeeper Worker domain.\n- Frontend reports never convey authority. `reportedUserId` is supplied by the client and unverified\n  — the name records that it is a report, not a finding — so it is a diagnostic label only and must\n  never be read to make a decision. `pageLocation` is origin and pathname only, rebuilt by\n  `normalizePageLocation` rather than trusted from producers, because a share link's fragment is a\n  bearer capability and an `href` also retains credentials; non-`http(s)` URLs are dropped entirely.\n- Install automatic capture only in trusted first-party surfaces, never gadget/user-authored code.\n  Exception messages and stacks reach the external Reporter, so never intentionally put secrets,\n  prompts, tokens, headers, or request/response bodies in thrown errors or report metadata.\n","isInternal":false,"tokens":8012,"sizeBytes":36827},{"name":"SKILL.md","path":".agents/skills/frontend-conventions/SKILL.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/.agents/skills/frontend-conventions/SKILL.md","title":"frontend-conventions","category":"anthropic-skill","format":"markdown","content":"---\nname: frontend-conventions\ndescription: Use for creating, modifying, moving, or reviewing React frontend code anywhere in packages/*, including Workshop pages, gatekeeper management apps, shared UI, components, hooks, forms, interactions, styling, accessibility, and frontend tests.\n---\n\n# Frontend Conventions\n\nApply these conventions to the Workshop SPA, gatekeeper management SPAs, and `@gadgets/ui`.\nPackage-level `AGENTS.md` files add product-specific rules but do not replace this guidance.\n\n## Ownership And Organization\n\nOrganize code by product ownership before implementation type. Feature directories own product\nbehavior and may contain components, hooks, tests, and utilities that change for the same reason.\n\nStart directories flat. Do not introduce `components/`, `hooks/`, `helpers/`, or `tests/`\nsubdirectories merely to classify files. Introduce a responsibility-named subsystem directory only\nwhen several files form a coherent unit or the flat directory becomes difficult to scan.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Colocate `*.test.ts(x)` files with their subject. Feature organization does not replace\nthe one-component-per-file model.\n\nKeep product behavior with its product even when another feature consumes it. Promote code only as\nhigh as its ownership requires:\n\n- Code shared within one feature belongs at the nearest common feature directory.\n- Feature-independent code shared across unrelated areas of one app may live in that app's\n  `components/` or `hooks/` directory.\n- Runtime UI shared by independent frontends belongs in `@gadgets/ui`.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase domain behavior.\n\n## Components And Hooks\n\nCreate a separate component when it owns meaningful state, effects, interactions, accessibility\nbehavior, or reusable responsibility; represents a distinct UI concern; or obscures its parent's\nmain flow. Keep small stateless render helpers private until they develop an independent concern.\n\nExtract a hook when it owns a coherent behavior or external synchronization lifecycle, not simply\nto shorten a file. Keep code together when an extracted child would mostly forward markup or depend\non the parent's refs, setters, and synchronization callbacks.\n\nPrefer named arrow-function components and hooks. Type props directly rather than using `React.FC`.\nGive wrappers such as `memo` and `forwardRef` stable DevTools names.\n\n## Component APIs\n\nRepresent props that are valid only together as an object or discriminated union. A controlled\nvalue requires a change callback; otherwise expose an uncontrolled initial value. Do not copy a\ncontrolled prop into local state with an Effect.\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events.\nAdd `children`, slots, variants, `className`, DOM passthrough, and imperative refs only for current\ncallers, not speculative reuse.\n\nUse context for genuinely application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props.\n\n## Kumo And Styling\n\nUse Kumo components and semantic tokens by default. Check Kumo and `@gadgets/ui` before creating a\ncontrol or interaction pattern. A shared Gadgets component should compose Kumo behavior, not merely\nrename or restyle a primitive.\n\nDo not add custom color literals, arbitrary Tailwind colors, feature-local token systems, or local\nreplacements for Kumo surfaces, borders, text, status, focus, and interaction tokens unless the user\nexplicitly requests them. Existing legacy colors are not precedent.\n\nTailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and\ntypography. Use custom CSS only for technical behavior Kumo and utilities cannot express. Global\nKumo token theming is an application-level decision and must not be changed during ordinary feature\nwork.\n\nWhen Kumo is unsuitable, identify the concrete behavioral or accessibility gap before introducing\na shared abstraction.\n\n## React\n\nTreat Effects as synchronization with external systems, not as derived-state machinery or a way to\nsequence user interactions. Calculate render data during render, keep state near its owner, prefer a\ncomponent `key` for identity resets, and use `useSyncExternalStore` for suitable external stores.\n\nEffects that fetch or subscribe must clean up stale work and remain correct when restarted. Avoid\nchains of Effects and do not synchronize two pieces of React state when one can be derived.\n\nDo not add `useMemo` or `useCallback` without a concrete identity or performance need. Preserve\nkeyboard behavior, focus management, accessible names, announcements, and mouse/touch/hybrid input\nparity. RPC stubs must follow the disposal and React state rules in the root `AGENTS.md`.\n\n## Comments\n\nPrefer names and types that communicate intent. Comments should explain non-obvious constraints,\nsecurity or performance reasons, and deliberate departures from conventions. Do not narrate the\nnext line. Remove or update comments when their constraint changes.\n\n## Tests\n\nTests should protect observable behavior, product rules, accessibility, state transitions, races,\nand failure paths. Do not test React, JavaScript, Kumo, or another framework's own behavior merely\nfor coverage. Avoid assertions coupled only to implementation details or trivial passthrough.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage only\nwhen an extraction exposes important previously untested logic.\n","frontmatter":{"name":"frontend-conventions","description":"Use for creating, modifying, moving, or reviewing React frontend code anywhere in packages/*, including Workshop pages, gatekeeper management apps, shared UI, components, hooks, forms, interactions, styling, accessibility, and frontend tests."},"isInternal":false,"tokens":1103,"sizeBytes":5635},{"name":"SKELETON.md","path":".agents/skills/write-gatekeeper/SKELETON.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/.agents/skills/write-gatekeeper/SKELETON.md","title":"Write-gatekeeper Skill","category":"anthropic-skill","format":"markdown","content":"# Gatekeeper Implementation Skeleton\n\nReplace all `My`/`my`/`MY` prefixes with the service name.\n\n## Main implementation (`src/<name>.ts`)\n\n```typescript\nimport { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from \"cloudflare:workers\";\nimport {\n  GatekeeperUser,\n  GatekeeperUserVerifier,\n  GatekeeperVendor as GatekeeperVendorIface,\n  Gatekeeper,\n  HookController,        // Remove if no hooks\n  HookInitiator,         // Remove if no hooks\n  HookTargetMetadata,    // Remove if no hooks\n  ResourceDescription,\n  ApprovalQueue,\n  ObservationDescription,\n  VendorDescription,\n  GatekeeperConnectCallback,\n  AccountDescription,\n  SupportedResource,\n  ResourceConfiguratorFrame,\n} from '@gadgets/workshop-shared/gatekeeper';\nimport { MySession, MyHook as MyHookIface } from \"./types\";  // Remove MyHook if no hooks\nimport TYPES_CODE from \"./types.txt\";\nimport MY_CONFIGURATOR_HTML from \"./generated/my-configurator-ui.txt\";\n\nconst NONCE_BYTES = 32;\nconst NONCE_LIFETIME_MS = 10 * 60 * 1000;  // 10 minutes\n\nfunction hexEncode(bytes: Uint8Array): string {\n  return [...bytes].map(b => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction generateNonce(): string {\n  return hexEncode(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n  let encoder = new TextEncoder();\n  let bufA = encoder.encode(a);\n  let bufB = encoder.encode(b);\n  if (bufA.byteLength !== bufB.byteLength) return false;\n  return crypto.subtle.timingSafeEqual(bufA, bufB);\n}\n\ntype Env = Cloudflare.Env & {\n  BASE_URL?: string,\n};\n\nfunction getBaseUrl(env: Env) {\n  return (env.BASE_URL || \"http://localhost:8787/gatekeeper/<name>\").replace(/\\/+$/, \"\");\n}\n\nfunction getBasePath(env: Env) {\n  const path = new URL(getBaseUrl(env)).pathname;\n  return path === \"/\" ? \"\" : path;\n}\n\nconst SELF_CLOSING_HTML = `<!DOCTYPE html>\n<html lang=\"en\">\n  <body>\n    <script type=\"text/javascript\">window.close();</script>\n    <p>Authorization complete. You may close this tab and return to the Gadgets Workshop.\n  </body>\n</html>`;\n\nconst MY_RESOURCE: SupportedResource = {\n  urlPattern: \"https://example.com/*\",\n  title: \"My Resource\",\n  description: \"Access a specific resource.\",\n};\n\n// ---------------------------------------------------------------------------\n// HTTP handler — serves the browser-based auth flow.\n// For a complete OAuth example, see gatekeeper-google/src/google.ts.\n\nexport default {\n  async fetch(req: Request, env: Env, ctx: ExecutionContext) {\n    let url = new URL(req.url);\n    let basePath = getBasePath(env);\n    if (!url.pathname.startsWith(basePath + \"/\") && url.pathname !== basePath) {\n      throw new Error(`Request path ${url.pathname} does not match BASE_URL path ${basePath}`);\n    }\n    let relPath = url.pathname.slice(basePath.length);\n    let path = relPath.slice(1).split(\"/\");\n\n    if (path.length === 2 && path[0].length === 64 && path[1].length === NONCE_BYTES * 2) {\n      // Auth initiation: the user has visited the connect URL.\n      let doId = path[0];\n      let nonce = path[1];\n      let stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId));\n      if (!await stub.verifyNonce(nonce)) {\n        // Show a friendly error page for expired/replayed links.\n        return new Response(\"TODO: error HTML\", {\n          headers: { \"Content-Type\": \"text/html; charset=utf-8\" }\n        });\n      }\n\n      // TODO: Redirect to external auth provider, or present an auth form.\n      // For OAuth, generate a second nonce for the `state` parameter here —\n      // see gatekeeper-google for the full pattern.\n      throw new Error(\"TODO: implement auth initiation\");\n    } else {\n      return new Response(\"Not Found\", {status: 404});\n    }\n  }\n};\n\n// ---------------------------------------------------------------------------\n// Vendor — top-level API exposed to the Workshop\n\nexport class GatekeeperVendor extends WorkerEntrypoint<Env> implements GatekeeperVendorIface {\n  async describe(): Promise<VendorDescription> {\n    return {\n      displayName: \"My Service\",\n      url: \"https://example.com\",\n      logo: { url: \"TODO: data URL or hosted image URL\" },\n      color: \"#f0f4ff\",\n      tagline: \"Short summary of what this connector enables\",\n      description:\n          \"Explain what Gadgets can do with this connector in plain language.\",\n    };\n  }\n\n  // This skeleton has no independently grantable resources, so it ignores\n  // `options.resourceUrlPatterns` and stores an empty requested-resource list. Gatekeepers with\n  // grantable resources should follow gatekeeper-google.\n  async connectAccount(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      _options?: {resourceUrlPatterns?: string[]}): Promise<{url: string}> {\n    let userObjectId = this.ctx.exports.UserAccount.newUniqueId();\n    let nonce = generateNonce();\n    await this.ctx.exports.UserAccount.get(userObjectId).setCallback(callback, nonce, []);\n    return { url: `${getBaseUrl(this.env)}/${userObjectId.toString()}/${nonce}` };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserAccount DO — stores per-user credentials (tokens, API keys, etc.).\n// For a full OAuth implementation with two-phase nonces and reconnect support,\n// see gatekeeper-google.\nexport class UserAccount extends DurableObject<Env> {\n  async setCallback(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      nonce: string,\n      requestedResourceUrlPatterns: string[]) {\n    // Self-destruct if the connect flow is never completed.\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.setAlarm(Date.now() + 3600 * 1000);\n    }\n    this.ctx.storage.kv.put(\"callback\", callback);\n    this.ctx.storage.kv.put(\"nonce\", { value: nonce, expiresAt: Date.now() + NONCE_LIFETIME_MS });\n    this.ctx.storage.kv.put(\"requestedResourceUrlPatterns\", requestedResourceUrlPatterns);\n  }\n\n  // Verify and consume the nonce from the initiation URL. Prevents replay.\n  // Returns false if the nonce is invalid or expired.\n  async verifyNonce(nonce: string): Promise<boolean> {\n    let stored = this.ctx.storage.kv.get<{value: string, expiresAt: number}>(\"nonce\");\n    if (!stored || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, nonce)) {\n      return false;\n    }\n    this.ctx.storage.kv.delete(\"nonce\");\n    return true;\n  }\n\n  // Called when the user completes authorization. Store credentials and notify the workshop.\n  async completeConnection(/* auth result params */) {\n    let callback = this.ctx.storage.kv.get<Fetcher<GatekeeperConnectCallback>>(\"callback\");\n    if (!callback) {\n      throw new Error(\"Authorization timed out. Please try again.\");\n    }\n\n    // TODO: Store credentials obtained from the auth flow\n    this.ctx.storage.kv.put<string>(\"credentials\", \"TODO\");\n\n    let props: MyUserImplProps = { userObjectId: this.ctx.id.toString() };\n    try {\n      await callback.complete(this.ctx.exports.MyUserImpl({ props }));\n    } catch (err) {\n      this.ctx.storage.kv.delete(\"credentials\");\n      throw err;\n    }\n  }\n\n  async getAccessToken(): Promise<string> {\n    // TODO: Return a cached short-lived token, refreshing credentials if needed.\n    let token = this.ctx.storage.kv.get<string>(\"credentials\");\n    if (!token) throw new Error(\"No credentials set.\");\n    return token;\n  }\n\n  async alarm() {\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.deleteAll();\n    }\n  }\n\n  async revoke() {\n    // TODO: Revoke credentials with the external service\n    this.ctx.storage.deleteAlarm();\n    this.ctx.storage.deleteAll();\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserImpl — maps resource URLs to gatekeeper DO classes\n\ntype MyUserImplProps = {\n  userObjectId: string;\n};\n\nexport class MyUserImpl extends WorkerEntrypoint<Env, MyUserImplProps>\n                         implements GatekeeperUser {\n  async describe(): Promise<AccountDescription> {\n    // TODO: Fetch account info from external service using stored credentials\n    return {\n      displayName: \"TODO\",\n      avatar: { url: \"\" },\n    };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async startResourceConfigurator(resourceUrlPattern: string): Promise<ResourceConfiguratorFrame> {\n    if (resourceUrlPattern !== MY_RESOURCE.urlPattern) {\n      throw new Error(`Unsupported resource configurator type: ${resourceUrlPattern}`);\n    }\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    let userAccount = this.ctx.exports.UserAccount.get(id);\n    let ui = new MyConfiguratorUI(async () => {\n      // TODO: Return a short-lived token or construct a read-only API client.\n      // Keep this helper private; do not expose it as an RPC method.\n      return await userAccount.getAccessToken();\n    });\n    return {\n      iframeHtml: MY_CONFIGURATOR_HTML,\n      ui: new RpcStub(ui),\n    };\n  }\n\n  async getGatekeeperClassFor(url: string): Promise<{\n    class: DurableObjectClass<Gatekeeper<any>>;\n    resource: SupportedResource;\n  }> {\n    // Parse URL to determine resource type and extract identifiers.\n    // Return a DO class with props baked in via ctx.exports.<ClassName>({props}).\n    // The Overseer will instantiate this class as a facet.\n    let props: MyGatekeeperImplProps = {\n      userObjectId: this.ctx.props.userObjectId,\n      // TODO: resource-specific fields extracted from URL\n    };\n    return {\n      class: this.ctx.exports.MyGatekeeperImpl({ props }),\n      resource: MY_RESOURCE,\n    };\n  }\n\n  async revoke(): Promise<void> {\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    await this.ctx.exports.UserAccount.get(id).revoke();\n  }\n\n  async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> {\n    return {};\n  }\n\n  // Mint a verifier representing this account (see Observers in SKILL.md). The overseer only ever\n  // hands it back to a gatekeeper of THIS vendor, so MyGatekeeperImpl.addObserver may trust it.\n  // For a strategy-D (low-stakes) gatekeeper, MyVerifier has no methods and this still works.\n  async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n    let props: MyVerifierProps = { userObjectId: this.ctx.props.userObjectId };\n    return this.ctx.exports.MyVerifier({ props });\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Verifier — answers \"can this observer access X?\" against the OBSERVER's own credentials.\n// For strategy D, replace this with a no-op public method; an empty WorkerEntrypoint is not registered\n// in ctx.exports. Keep/extend it for strategy B (single-unit ACL) or C (data-set tracking). See SKILL.md.\n\ntype MyVerifierProps = {\n  userObjectId: string;\n};\n\n// A vendor-specific interface adding non-standard methods to the opaque GatekeeperUserVerifier.\n// addObserver casts the Fetcher back to this; the overseer's same-vendor guarantee makes that safe.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    let account = this.ctx.exports.UserAccount.get(\n        this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId));\n    void account; void resourceId;\n    // TODO: query the service with the observer's own token. Return true on success; return false\n    // for access errors (e.g. 401/403/404); rethrow anything else so the open fails loudly rather\n    // than silently denying.\n    return false;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Resource configurator — narrow iframe helper API\n\nconst configuratorTokenGetters = new WeakMap<object, () => Promise<string>>();\n\nclass MyConfiguratorUI extends RpcTarget {\n  constructor(getToken: () => Promise<string>) {\n    super();\n    configuratorTokenGetters.set(this, getToken);\n  }\n\n  async listResources(query: string): Promise<{ value: string; title: string }[]> {\n    let getToken = configuratorTokenGetters.get(this);\n    if (!getToken) throw new Error(\"Configurator is not initialized.\");\n    let token = await getToken();\n    void token;\n    // TODO: Call read-only external APIs and return bounded search results.\n    return [{ value: query || \"example\", title: query || \"Example\" }];\n  }\n\n}\n\n// ---------------------------------------------------------------------------\n// Hook type — remove this section (and the HookController/HookInitiator/HookTargetMetadata\n// imports, the `subscribe` method, the `hookTsType` field, and MyHookControllerImpl) if the\n// gatekeeper doesn't push events.\n//\n// The hook interface from types.d.ts is implemented by the Gadget as an RpcTarget. Intersect it\n// with RpcTarget so it satisfies the HookController/HookInitiator generic constraints (Hook\n// extends RpcTarget).\n//\n// Note that you can also use a plain function as a hook type, e.g. `RpcStub<() => Promise<void>>`.\n// In that case you would not need to merge the type with `RpcTarget`.\n\ntype MyHook = RpcTarget & MyHookIface;\n\n// ---------------------------------------------------------------------------\n// GatekeeperImpl DO — per-resource instance, runs as a facet of the Overseer\n\ntype MyGatekeeperImplProps = {\n  userObjectId: string;\n  // ... resource-specific fields (e.g., documentId, repoOwner)\n};\n\nexport class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>\n    implements Gatekeeper<MySession> {\n\n  async describe(): Promise<ResourceDescription> {\n    return {\n      url: \"TODO: canonical resource URL\",\n      title: \"TODO\",\n      snippet: \"TODO\",\n      suggestedBindingName: \"MY_RESOURCE\",  // Based on type, not instance\n      tsType: \"MySession\",\n      hookTsType: \"MyHook\",  // Remove if hooks are not supported\n    };\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n\n  async startSession(approvalQueue: RpcStub<ApprovalQueue>): Promise<MySession> {\n    return new MySessionImpl(\n      approvalQueue.dup(),  // Always dup() before storing\n      this.ctx,\n      // ... API client, props, etc.\n    );\n  }\n\n  async applyAction(actionId: number): Promise<void> {\n    // Look up the action by ID from this gatekeeper's own storage, then perform it\n    // against the external service.\n    // TODO: Implement action lookup and execution.\n    throw new Error(`Unknown action: ${actionId}`);\n  }\n\n  async rejectAction(actionId: number): Promise<void | { restart?: boolean }> {\n    // Clean up simulation state for this action.\n    // Return { restart: true } if the session can't recover from rejection.\n  }\n\n  revertAction(actionId: number):\n      Promise<void | { message?: string; canRetry?: boolean; restart?: boolean }> {\n    // TODO: Undo the action (look up what was done from own storage)\n    throw new Error(\"Revert not implemented\");\n  }\n\n  // Observers (see SKILL.md \"Observer verification\"). This skeleton shows strategy B — ACL check on\n  // a single atomic resource. The overseer calls addObserver on EVERY open by every observer, so it\n  // re-verifies live access; throw to deny.\n  //   - Strategy A (private-only): `async addObserver() { throw new Error(\"...not shareable...\"); }`\n  //   - Strategy D (low-stakes):   make both methods no-ops.\n  //   - Strategy C (data-set tracking): record observed sets + store verifiers, and route every read\n  //     through an authorizeSetObservation helper that sets `excludeObservers` (see SKILL.md).\n  async addObserver(_id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void> {\n    let verifier = user as unknown as Fetcher<MyVerifierApi>;\n    if (!(await verifier.hasResourceAccess(/* this.ctx.props.resourceId */ \"TODO\"))) {\n      throw new Error(\n        \"This collaborator does not have access to the bound resource, so they cannot observe \" +\n        \"data the Gadget read from it.\");\n    }\n  }\n\n  // Idempotent: ignore unknown ids. A no-op for strategy A/B (nothing is tracked).\n  async removeObserver(_id: string): Promise<void> {}\n}\n\n// ---------------------------------------------------------------------------\n// HookController — remove if the gatekeeper doesn't push events.\n//\n// A WorkerEntrypoint the overseer uses to enable/disable the hook after the user approves it.\n// It is constructed with `props` carrying the specifics of that particular registration, so it\n// needs no other state. If your gatekeeper offers several kinds of hooks, give each its own\n// controller class.\n\n// Bind-time details for a single hook registration, baked into the controller's props.\ntype MyHookProps = {\n  // TODO: e.g. an event kind, a filter, a sub-resource id, etc. — whatever the registration\n  // method received and the controller/event source will need later.\n  filter?: string;\n};\n\ntype MyHookControllerImplProps = MyGatekeeperImplProps & MyHookProps;\n\nexport class MyHookControllerImpl extends WorkerEntrypoint<Env, MyHookControllerImplProps>\n    implements HookController<MyHook> {\n  // Called when the user enables the hook. Store `initiator` somewhere it can be reached when an\n  // event arrives — typically an event-source DO. Don't store other state until now; everything\n  // else is already in `this.ctx.props`. If already enabled, replace the previous initiator.\n  //\n  // `target` identifies where the hook delivers (workspace, and gadget when pinned to one). Store\n  // it too if you display or link to the target; otherwise ignore it, but keep the parameter\n  // declared — RPC argument validation rejects arguments the receiver doesn't declare.\n  async enable(initiator: Fetcher<HookInitiator<MyHook>>, target: HookTargetMetadata): Promise<void> {\n    // TODO: persist `initiator` (e.g. forward it to an event-source DO keyed by props).\n  }\n\n  // Called when the hook is disabled or deleted. Forget the stored initiator and clean up all\n  // related state. May never be called again, though the overseer may later call enable() afresh.\n  async disable(): Promise<void> {\n    // TODO: forget the stored initiator.\n  }\n}\n\n// Event delivery (sketch). When the external event arrives — e.g. in the event-source DO that\n// holds the `initiator` — invoke the hook like so:\n//\n//   async onEvent(initiator: Fetcher<HookInitiator<MyHook>>, event: MyEvent) {\n//     // startHook() begins a fresh session and returns the callback (re-bound to it) plus an\n//     // ApprovalQueue. `using` disposes the result (and its stubs) at end of scope.\n//     using result = initiator.startHook();\n//\n//     // A hook event is almost always an observation. (Register actions too if invoking the\n//     // callback can cause side effects.) Pipeline through the not-yet-resolved promise.\n//     await result.approvalQueue.authorizeObservation({\n//       title: \"TODO: short event summary\",\n//       description: \"TODO: details about the event being delivered\",\n//     });\n//\n//     // Deliver the event to the Gadget's callback.\n//     await result.callback.onMyEvent(event);\n//   }\n\n// ---------------------------------------------------------------------------\n// SessionImpl — the RPC interface exposed to the Gadget\n\nclass MySessionImpl extends RpcTarget implements MySession {\n  #approvalQueue: RpcStub<ApprovalQueue>;\n  #ctx: DurableObjectState<MyGatekeeperImplProps>;\n\n  constructor(\n      approvalQueue: RpcStub<ApprovalQueue>,\n      ctx: DurableObjectState<MyGatekeeperImplProps>) {\n    super();\n    this.#approvalQueue = approvalQueue;\n    this.#ctx = ctx;\n  }\n\n  [Symbol.dispose]() {\n    this.#approvalQueue[Symbol.dispose]();\n  }\n\n  // Example: observation (read). Fetch data, then authorize before returning.\n  async getData(): Promise<string> {\n    let result = \"TODO: fetch from service or cache\";\n\n    await this.#approvalQueue.authorizeObservation({\n      title: \"Read data\",\n      description: \"Fetched data from the service.\",\n    });\n\n    return result;\n  }\n\n  // Example: action (side effect). Submit for approval; do NOT perform here.\n  // Assign a sequential action ID, store the action details in the gatekeeper's\n  // own storage, then submit the ID to the approval queue. If you read current state to build\n  // the description or capture revert info, do NOT log it as an observation: nothing is\n  // returned to the caller.\n  async updateData(newValue: string): Promise<void> {\n    let actionId = /* assign next sequential ID and store action details */ 0;\n\n    await this.#approvalQueue.submitAction(actionId, {\n      title: \"Update data\",\n      description: `Update value to: ${newValue}`,\n      implementsRevert: true,\n    });\n\n    // TODO: Update cache/simulation state so subsequent reads reflect this\n  }\n\n  // Example: hook registration — remove if the gatekeeper doesn't push events.\n  // `callback` is a persistent stub the Gadget created with ctx.restore(). Construct a controller\n  // whose props capture the specifics of THIS registration (e.g. `filter`), then hand it, the\n  // callback, and a user-facing description to the overseer via bindHook(). For multiple hook\n  // kinds, pick the appropriate controller class here. Do NOT store the callback yourself — it is\n  // bound to this session and would be revoked when the session ends.\n  async subscribe(callback: RpcStub<MyHook>, filter?: string): Promise<void> {\n    let controller = this.#ctx.exports.MyHookControllerImpl({\n      props: { ...this.#ctx.props, filter },\n    });\n    await this.#approvalQueue.bindHook(controller, callback, {\n      title: \"TODO: short hook title\",\n      description: `TODO: what events this hook delivers${filter ? ` (filter: ${filter})` : \"\"}`,\n    });\n  }\n}\n```\n\n## `cloudflare.config.ts`\n\n```typescript\nimport {\n  DEFAULT_GATEKEEPER_WRANGLER, OBSERVABILITY, defineGadgetsWorker, type DurableObjectMigration,\n} from \"@gadgets/scripts/worker-config\";\n\nexport default defineGadgetsWorker({\n  name: \"gatekeeper-<name>\",\n  entrypoint: \".wrangler/validate/src/<name>.ts\",\n  compatibilityFlags: [\"allow_irrevocable_stub_storage\"],\n  observability: OBSERVABILITY,\n});\n\nexport const wrangler = DEFAULT_GATEKEEPER_WRANGLER;\n\nexport const migrations: DurableObjectMigration[] = [\n  { tag: \"v0\", new_sqlite_classes: [\"UserAccount\", \"MyGatekeeperImpl\"] },\n];\n```\n\nRun `pnpm configs:generate`; the generated `wrangler.jsonc` is committed and never edited by hand.\n\nOnly Durable Object classes go in `new_sqlite_classes`. `MyVerifier` and `MyHookControllerImpl` are `WorkerEntrypoint`s, so they need no `migrations` entry — but, like all entrypoints, they must be `export`ed from the worker's main module (so `ctx.exports.MyVerifier(...)` resolves). If your hook uses a dedicated event-source DO to hold the `initiator`, add that DO here too.\n\n## Creating the `types.txt` symlink\n\n```bash\ncd packages/gatekeeper-<name>/src\nln -s types.d.ts types.txt\n```\n\n## Resource configurator files\n\nAdd per-resource configurator types and UI modules:\n\n```\nsrc/configurator/my-configurator-types.d.ts\nsrc/configurator/my-configurator-types.txt -> my-configurator-types.d.ts\nsrc/configurator/my-configurator-ui.tsx\n```\n\n`my-configurator-types.d.ts` should describe the iframe helper API. The UI module's own `resourceUrl()` hook returns the resource URL to Workshop.\n\n```typescript\nexport interface MyConfiguratorUI {\n  listResources(query: string): Promise<{ value: string; title: string }[]>;\n}\n```\n\nAdd package scripts:\n\n```json\n{\n  \"scripts\": {\n    \"deploy\": \"vp run --no-cache build:configurator && wrangler deploy\"\n  },\n  \"dependencies\": {\n    \"@gadgets/configurator-ui\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"@gadgets/scripts\": \"workspace:*\"\n  }\n}\n```\n\n`@gadgets/scripts` has to be declared, not just imported. The shared task configs and the\n`gadgets-*` bins that run them belong to that package, and the workspace root depends on it too —\nso its bins are already on `PATH` and a `vite.config.ts` importing it resolves even with no\ndeclaration here. That is a phantom dependency: it works until this package is built somewhere that\ndoes not happen to hoist it.\n\nNo `build` script: `build` is one of the shared Vite+ tasks below, and vp forbids a task and a\nscript sharing a name. Build this one package with `pnpm exec vp run -F <package-name> build` —\n`pnpm --filter` cannot see a task, so `pnpm --filter <package-name> build` reports nothing to run;\nthe workspace-wide `pnpm build` picks it up as usual. `deploy` goes through the task rather than\ncalling\n`build-gatekeeper-configurator.ts` itself, so the codegen command lives in one place and cannot\ndrift from the task that declares its env — `wrangler deploy` stays outside vp, since it has side\neffects and needs real credentials.\n\nAlso add a `vite.config.ts` re-exporting the shared `build` and `build:configurator` Vite+ tasks —\nthe latter is what the `pnpm dev-server` pre-flight builds (`vp run -r --cache build:configurator\n--dev`). Both are tasks rather than package.json scripts so `VITE_FRONTEND_ERROR_REPORTING` is\npassed through and cache-fingerprinted; a `build` script running the builder with `&&` would bypass\nthat and silently bake the wrong value into the shipped HTML (do not add either script back):\n\n```typescript\n// Vite+ per-package settings. The build:configurator task definition is shared by all gatekeepers\n// with a configurator UI and ships as an export of `@gadgets/scripts`, alongside the builder it\n// runs.\nexport { default } from '@gadgets/scripts/gatekeeper-configurator'\n\n// ...or, if the gatekeeper has tests, `withTests` instead: the same settings plus the shared vitest\n// `test` task. Add a `\"test:run\": \"vitest run\"` script too, for iterating without the cache.\nexport { withTests as default } from '@gadgets/scripts/gatekeeper-configurator'\n```\n\nKeep tokens and broad API clients out of public `RpcTarget` properties; use closures, `#private`, or `WeakMap` state and expose only narrow read-only helper methods.\n","isInternal":false,"tokens":5979,"sizeBytes":26192},{"name":"SKILL.md","path":".agents/skills/write-gatekeeper/SKILL.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/.agents/skills/write-gatekeeper/SKILL.md","title":"write-gatekeeper","category":"anthropic-skill","format":"markdown","content":"---\nname: write-gatekeeper\ndescription: Guides implementation of Gatekeeper Workers that bridge Gadgets to external services. Covers auth, capability-based API design, approval queue integration, caching, and action simulation. Load when creating, modifying, or reviewing the implementation of a gatekeeper.\n---\n\n# Writing a Gatekeeper\n\nA Gatekeeper is a Cloudflare Worker that mediates all access between a Gadget and an external service. It implements a three-tier hierarchy:\n\n- **Vendor** (`GatekeeperVendor`, a `WorkerEntrypoint`) — top-level entry for the service. One per service.\n- **User** (`GatekeeperUser`, a `WorkerEntrypoint` with `ctx.props`) — a human user's authenticated connection.\n- **Instance** (`Gatekeeper<Session>`, a DO facet of the Overseer) — per-resource, per-Gadget binding that provides the Session API.\n\nRead `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces and detailed JSDoc.\n\n## Seven responsibilities\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\n4. **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. 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)).\n\n5. **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.\n\n6. **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.\n\n7. **Observer verification** — When a Gadget is shared, collaborators may \"observe\" data the Gadget previously read through the gatekeeper. The gatekeeper must ensure each collaborator could access that data themselves, via `getVerifier()` / `addObserver()` / `removeObserver()`. The interface methods are mandatory — a gatekeeper won't type-check without them — so include at least minimal versions in Phase 1; but *choosing and implementing the right strategy* is a Phase 2 security concern, like logging/approvals. See [Observer verification](#observer-verification).\n\n## Phase 1: Core implementation\n\nIn the first phase, focus only on responsibilities 1 - 3, though keeping in mind that 4 - 7 will need to be implemented later. Note the observer methods of responsibility 7 (`getVerifier`/`addObserver`/`removeObserver`) are required for the code to type-check, so the skeleton includes minimal versions; you flesh out the actual strategy in Phase 2.\n\n### Step 1: Understand the external service\n\nStudy the service's API docs. Identify:\n- Auth model (OAuth 2.0, API keys, etc.)\n- Resources to expose and what access granularities make sense\n- Which operations are observations (read-only) vs. actions (side effects)\n\n### Step 2: Design the Session types\n\nCreate `src/types.d.ts` defining the Session interface (and Hook interface if the service pushes events — see [Hooks](#hooks-push-notifications)).\n\nBefore 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.\n\nDesign principles:\n- One interface per logical resource type, not a god-object\n- Methods return structured data, not raw API responses\n- 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\n- Simplify API complexities that are not likely to matter to agents and gadgets; design for a more novice user and common use cases\n- Consider what URL patterns `getGatekeeperClassFor()` should match — each pattern maps to a resource granularity\n- Include JSDoc comments; these types serve as the agent's API documentation. See [Documenting the API](#documenting-the-api-typesdts) below.\n\n#### Documenting the API (`types.d.ts`)\n\nThis 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.\n\nDo 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`.\n\nThe 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.\n\n### Step 3: STOP — Present API for review\n\n**Do not proceed without operator approval.**\n\nPresent 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.\n\nAlso 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.\n\n### Step 4: Implement\n\nSee [SKELETON.md](SKELETON.md) for a complete implementation template.\n\nPackage structure:\n```\npackages/gatekeeper-<name>/\n├── src/\n│   ├── configurator/         # Optional resource-picker UI modules and UI-facing types\n│   ├── <name>.ts              # Vendor, UserAccount, UserImpl, GatekeeperImpl, SessionImpl\n│   ├── types.d.ts             # Session/Hook types (compile-time)\n│   ├── types.txt -> types.d.ts  # Symlink (runtime, for getTypeScriptTypes())\n│   └── <name>-api.ts          # (optional) Helper wrapping the service's HTTP API\n├── cloudflare.config.ts      # wrangler.jsonc is generated from it\n├── package.json\n└── tsconfig.json\n```\n\n### Step 5: Register\n\nThere 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`).\n\n### Step 6: Add resource selection UI\n\nAdd 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.\n\n- 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.\n- Return `iframeHtml` of the selection UI and `ui` for any RPCs that UI needs.\n- When the user selects \"Add connection\", Workshop asks the iframe for the selected resource URL.\n- Group types (`SupportedResource.groupedWith`) when they are one choice at different granularities, such as a whole drive, a folder or a file. Each type is still granted separately, and a grouped card asks for the missing ones in one consent request, so group only types that need the same authorization. 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.\n\nKeep the iframe-facing capability narrow, only what's necessary to provide desired interface to help user find and select the resource.\n\n#### Optional helper: `@gadgets/configurator-ui`\n\nFor 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`.\n\nComponents: `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; breadcrumbs, optional search, paging and `loadItem`, which pre-filling anything but the root needs; nodes name icons the configurator supplies in `icons`; 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), `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). The runtime strips control and bidi-formatting characters from titles, subtitles and meta it shows, since those come from third parties.\n\nIf you use this:\n\n- UI modules live in `src/configurator/*-ui.tsx`.\n- `resourceUrl()` returns the selected resource URL.\n- 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.\n- `src/configurator/*-types.d.ts` describes the iframe-facing `ui` API.\n- 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.\n- 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`.\n- `scripts/build-gatekeeper-configurator.ts` generates `src/generated/*.txt`, using the runtime and build in `packages/configurator-ui` (`src/runtime/`, `build/`).\n- Nothing invokes `build-gatekeeper-configurator.ts` by hand. `vite.config.ts` re-exports the\n  shared `build` and `build:configurator` Vite+ tasks from\n  `scripts/gatekeeper-configurator-vite-config.ts`; `build` is just `tsc` and depends on\n  `build:configurator`, which carries `VITE_FRONTEND_ERROR_REPORTING` in its fingerprint, and\n  `deploy` runs `vp run --no-cache build:configurator && wrangler deploy` — deploys never replay a\n  cached artifact. There is no `build` script\n  and no direct builder call, because a script running the builder gets vp's stripped environment\n  and bakes the wrong flag into the shipped HTML.\n\n##### Pre-filling the form from a known resource URL\n\nWhen 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:\n\n- 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.\n- 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`):\n\n  ```ts\n  initialValuesFromResourceUrl({ resourceUrl }) {\n    const [owner, repo] = new URL(resourceUrl).pathname.split(\"/\").filter(Boolean);\n    return owner && repo ? { repoFullName: `${owner}/${repo}` } : {};\n  },\n  ```\n\n  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.\n\n### Step 7: STOP — Ask operator whether to proceed to phase 2\n\nThe operator may prefer to implement phase 2 later, perhaps in a new context. Stop here and ask the operator whether to proceed.\n\n## Phase 2: Logging, approvals, caching, simulation, and observers\n\nIn this phase, we focus on responsibilities 4-7. These are typically added as a second pass, after the core gatekeeper works. They may be implemented in a separate session.\n\n### Logging and approvals\n\nGo through all the API methods and decide where to insert calls to the `ApprovalQueue`.\n\n- Any operation which reads external data and returns it to the caller (but with no side effects) must call authorizeObservation().\n- Any operation which has visible side effects on the world must call submitAction(), and must not actually apply the action until approved.\n\nStudy the `ApprovalQueue` API in `gatekeeper.ts` for details.\n\n#### What counts as an observation\n\nThe 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.\n\nDo **not** call `authorizeObservation()` for:\n\n- **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\".\n- **Session setup.** `startSession()` resolving the account, a bound label, and so on returns only a session stub.\n- **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.\n- **Information that leaks only through a validation error** (\"label already exists\", \"branch is not a fast-forward\").\n\n**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.\n\nReturning 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.\n\n**Exception: observer tracking.** Under observer strategy C (below), `authorizeObservation()` does more than log. Its `excludeObservers` and set-tracking side effects gate which collaborators may see the workspace, and `containsRestrictedData` / `ownerInvitesOnly` change workspace state. A call that carries any of these is a security control, and removing it is a security decision, not a logging cleanup (see the Drive session's \"Check Google Drive folder\" fences).\n\nIt's critically important that you add `ApprovalQueue` to all API operations that interact with the outside world, otherwise the gatekeeper security model is broken.\n\n### Caching\n\nStore fetched data in the gatekeeper's DO storage (`this.ctx.storage`) to avoid redundant API calls. The cache also enables a better API shape when the service's native data model is awkward — e.g., Gmail's list API returns thread IDs without metadata, but with caching the Session can return richer summaries directly.\n\n- Use TTLs or revision IDs to keep the cache fresh.\n- Cache transformed data (e.g., Markdown) rather than raw API responses when the transformation is expensive.\n\nPROTIP: The relatively new API `this.ctx.storage.kv` provides synchronous versions of the traditional Durable Object storage API, e.g. `get` and `put`. Use these instead of the old asynchronous methods. (Note that the synchronous API does not provide \"batch\" versions of `get()` and `put()`, but you don't really need them since simply making multiple calls is efficient.)\n\nPROTIP: `this.ctx.storage.sql` gives you access to a full, private SQLite database. Use this when the full power of SQL is useful, but prefer KV for simple things.\n\nThe full Durable Objects storage API (including synchronous KV and SQLite) is documented at: https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/\n\n### Simulation\n\nWhen `submitAction()` has been called but `applyAction()` hasn't, reads should reflect the pending action. This allows the calling agent or gadget to be unaware of the approvals mechanism, and proceed with follow-on work immediately. The end user is able to approve a whole batch of changes at once, later on.\n\nTwo possible implementation approaches include:\n\n1. **Mutate the cache** — Apply the action's effects to cached data on submit. On `rejectAction()`, invalidate or rebuild the cache. Simple; works well when the cache is already a transformed view. Don't forget to re-apply any queued actions when updating the cache.\n\n2. **Overlay at read time** — Store pending actions separately; merge them into read results on demand. Cleaner separation; better when the overlay logic is straightforward.\n\nChoose based on the service's data model and the complexity of simulating each action type.\n\nKeep in mind that the agent calling the API (or the agent writing a gadget to call it) is generally not aware that actions do not take place immediately. If the simulation is correct, the agent doesn't need to be aware. If the simulation has gaps, you may want to mention it in your API's doc comments, so that the calling agent knows to work around them — but ideally there are no gaps and the calling agent does not need to think about it.\n\nFor concrete examples, see the Google gatekeeper's Google Docs simulation/cache handling and BigQuery dry-run scope enforcement.\n\n### Observer verification\n\nThis is responsibility 7. When a Gadget is shared, each non-owner collaborator becomes an **observer** of every gatekeeper bound to the Gadget, and may see data the Gadget previously read. The gatekeeper's job is to refuse — or forward-restrict — observers who couldn't access that data themselves.\n\nThree methods implement this (full JSDoc in `gatekeeper.ts`):\n\n- `GatekeeperUser.getVerifier()` — mints a `GatekeeperUserVerifier` (a persistent service stub) representing *this* user's account. The overseer mints one per open and **only ever passes it back to a gatekeeper of the same vendor**, so the gatekeeper may trust whatever it learns from it.\n- `Gatekeeper.addObserver(id, verifier)` — must **throw** if the user represented by `verifier` is not allowed to observe everything read through this gatekeeper so far. The overseer calls it on **every open by every authorized observer** (re-verification, so revoked access is caught at the next open); cache as needed if the check is expensive. `id` is an opaque, stable per-(user,gadget) string.\n- `Gatekeeper.removeObserver(id)` — idempotent; drop a tracked observer.\n\n#### The verifier \"non-standard method\" pattern\n\n`GatekeeperUserVerifier` has no methods of its own — it's an opaque token. To actually answer \"can this observer access X?\", define a vendor-specific interface that **extends `GatekeeperUserVerifier`** with your own methods, implement it on a `WorkerEntrypoint` that queries the service using the **observer's own token**, and cast the `Fetcher` back to that interface inside `addObserver`. The overseer's same-vendor guarantee is what makes the cast safe.\n\n```typescript\n// In types/impl: a verifier interface with non-standard methods.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\ntype MyVerifierProps = { userObjectId: string };\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    // Query the service with the OBSERVER's own token (this.ctx.props.userObjectId).\n    try {\n      await myApiForObserver(this.ctx).getResource(resourceId);\n      return true;\n    } catch (error) {\n      // Distinguish \"no access\" from \"transient failure\":\n      //   - auth/permission/not-found (401/403/404) → false (they can't see it)\n      //   - anything else → rethrow, so the open fails loudly rather than silently denying\n      if (isNoAccessStatus(statusOf(error))) return false;\n      throw error;\n    }\n  }\n}\n\n// In GatekeeperUser:\nasync getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n  return this.ctx.exports.MyVerifier({ props: { userObjectId: this.ctx.props.userObjectId } });\n}\n```\n\n`MyVerifier` is a `WorkerEntrypoint`, so it needs **no migration entry**, but (like all entrypoints) it must be `export`ed from the worker's main module so `ctx.exports.MyVerifier(...)` resolves.\n\n#### Choosing a strategy (per resource type / binding)\n\nStrategy is chosen **per `Gatekeeper` DO class / binding**, not per package — one package may use several (e.g. Google: Gmail=A, Doc=B, BigQuery=C).\n\n- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox). For truly sensitive data, also mark each observation with `ObservationDescription.containsRestrictedData: true`: because nobody can open the workspace without passing `addObserver()`, and with strategy A nobody ever does, the first such observation makes the workspace effectively unshareable — and it permanently sets the workspace's `containsRestrictedData` flag, whose restricted mode blocks all actions and public web fetches, so the data cannot leak back out through other gatekeepers.\n- **B — ACL check (single unit).** The binding is one atomic resource; sub-resources inherit its ACL. `addObserver()` calls a verifier method to confirm the observer can access it and throws otherwise; `removeObserver()` is a no-op; nothing is tracked and no `excludeObservers` is ever needed. Use for repo / document / page / team / single-project bindings.\n- **C — Data-set tracking.** The binding spans sub-resources with **distinct ACLs**, and there is a **per-observer access oracle** for each. The DO logs the data sets actually observed and the current observers; `addObserver()` verifies the observer against **every** logged set (plus a coarse membership baseline) and **stores their verifier**; each later observation that first touches a **new** set re-checks all stored observers and sets `excludeObservers` for any who fail. Use for workspace / organization / dataset-spanning bindings.\n- **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier with a no-op public method such as `verify(): void {}` (an empty `WorkerEntrypoint` is not registered in `ctx.exports`). Use when any collaborator may observe (personal, low-stakes services).\n\nThe **B-vs-C decision** (the \"broad binding\" lens): use C only when **both** (1) the binding spans sub-resources with distinct ACLs *and* (2) there's a per-observer oracle to check each against. If one ACL covers everything → B. If there's no oracle → A or D.\n\n#### Implementing strategy C\n\nRoute **every data-revealing observation** through a helper that takes the set id(s) the observation reveals, instead of calling `authorizeObservation()` directly:\n\n```typescript\n// On the Gatekeeper DO. `setIds` are the data sets this observation reveals.\nasync authorizeSetObservation(\n    queue: RpcStub<ApprovalQueue>, setIds: string[], description: ObservationDescription) {\n  const check = setIds.length > 0\n      ? await this.#prepareSetObservation(setIds)\n      : { pendingSets: [], excludeObservers: undefined };\n  await queue.authorizeObservation({ ...description, excludeObservers: check.excludeObservers });\n  for (const setId of check.pendingSets) this.#markSetObserved(setId);\n}\n\nasync #prepareSetObservation(setIds: string[]) {\n  const pendingSets = [...new Set(setIds)].filter(id => !this.#isSetObserved(id));\n  if (pendingSets.length === 0) return { pendingSets, excludeObservers: undefined };\n  // This synchronous state change is visible to addObserver() before verifier RPCs can interleave.\n  for (const setId of pendingSets) this.#markSetPendingIfUnknown(setId);\n  const excluded = new Set<string>();\n  for (const [id, verifier] of this.#listObservers()) {\n    for (const setId of pendingSets) {\n      if (!(await verifier.hasSetAccess(setId))) { excluded.add(id); break; }\n    }\n  }\n  return {\n    pendingSets,\n    excludeObservers: excluded.size > 0 ? [...excluded] : undefined,\n  };\n}\n```\n\nKey points for C:\n\n- **Use two durable states: pending and observed.** Mark unknown sets pending before the first await, recheck pending sets on every retry, and promote them only after `authorizeObservation()` succeeds. A failed authorization leaves them pending. `addObserver()` must check both states and loop until no unchecked sets remain before synchronously storing the verifier; this also closes admission races in either request ordering.\n- **The session impls must route through this helper**, not `approvalQueue.authorizeObservation()`. If sessions hold the raw queue (not the DO), thread a small prepare hook/callback into each session and any sub-sessions it spawns, and expose a completion step that promotes its pending sets after authorization. For a single broad binding the hook is active; for the narrow (B) sibling binding it is absent (passthrough). See Linear/Notion for the shared-session-impl case and Supabase for the context-object case.\n- **One observation may reveal several sets** (e.g. a workspace-wide list whose rows belong to different sub-resources). Pass all of them; union the exclusions over the newly-seen ones. Reads that reveal *no* set (workspace name, member directory, a bare \"open\") pass an empty list and rely on the membership baseline.\n- **`addObserver` baseline:** verify the coarse membership (e.g. same org/workspace) that gates the set-independent reads, then verify each already-observed set, then store the verifier. Fail closed if a needed identity is unknown (e.g. an account connected before you began persisting the workspace id → force a reconnect).\n\n#### `excludeObservers` semantics (why conservative is safe)\n\nWhen `authorizeObservation()` is given `excludeObservers`, the overseer **blocks the observation** if any named observer is still authorized, and only lets it proceed (tearing down the observer) if they've already lost access. So erring toward listing an observer is never a leak — at worst it blocks an observation that could in principle have been allowed. The leak-relevant gate is always the live sharing graph, so stale observer state self-heals on the next open.\n\n## Hooks (push notifications)\n\nSome services can push events to the Gadget (inbound email, webhooks, chat messages, etc.). A gatekeeper exposes this as a **hook**: the Gadget registers a callback, and the gatekeeper later invokes it when an event arrives. Hooks are persistent — they survive across sessions and server restarts — and are subject to the same observation/action approval model as everything else.\n\n`gatekeeper-email` is the canonical reference implementation. Read it alongside the `HookController`, `HookInitiator`, and `ApprovalQueue.bindHook()` JSDoc in `gatekeeper.ts`.\n\n### The pieces\n\n- **Hook interface** (in `types.d.ts`): the methods the Gadget implements to receive events, e.g. `EmailHook.receiveEmail(email)`. It is implemented by the Gadget as an **`RpcTarget`** (or a plain function), *not* a `WorkerEntrypoint`. Reference it from `describe()` via `hookTsType`.\n- **Session method**: a method like `subscribe(callback)` that the Gadget (or, more commonly, an agent in a one-off `executeCode` call) uses to register interest. The `callback` is a **persistent stub** (created by the Gadget with `ctx.restore()`), so it can be stored and re-invoked long after the session ends.\n- **`HookController`** (a `WorkerEntrypoint` you implement): lets the overseer `enable()` / `disable()` the hook. All the state it needs must live in its `props`, so it is constructed via `this.ctx.exports.MyHookControllerImpl({props})` **at bind time**, immediately before calling `bindHook()` — see below.\n- **`HookInitiator`** (provided to you by the overseer): you call `startHook()` on it when an event arrives.\n\n### Lifecycle\n\n1. **Register.** The Gadget calls your Session method (e.g. `subscribe(callback, filter)`). Inside it, construct a `HookController` whose `props` capture the specifics of *this* registration, then call `approvalQueue.bindHook(controller, callback, description)`. The overseer stores the callback and records the hook (initially **disabled**). Do **not** store the callback yourself — it is bound to the current session and would be revoked when the session ends.\n2. **Enable.** When the user approves the hook in the Workshop UI, the overseer calls `controller.enable(initiator, target)`. Store the `initiator` Fetcher somewhere it can be reached when events arrive (e.g. an event-source DO). `target` identifies where the hook delivers (workspace, plus gadget when the hook is pinned to one); persist it alongside the initiator if you display or link to the target — the IDs are fixed when the hook is bound, so there is nothing to refresh. A gatekeeper that doesn't need it still has to declare the parameter, since RPC argument validation rejects arguments the receiver doesn't declare. Avoid storing any other state until enabled; everything else should already be in the controller's `props`.\n3. **Deliver.** When the event occurs, call `initiator.startHook()`. This returns `{callback, approvalQueue}` bound to a fresh session. Call `authorizeObservation()` (a hook event is almost always an observation; register actions too if the callback's return value triggers side effects), then invoke the `callback` to deliver the event to the Gadget.\n4. **Disable / delete.** The overseer calls `controller.disable()`. Forget the stored `initiator` and clean up all related state — `disable()` may never be called again, though the overseer may later call `enable()` afresh.\n\nBecause the callback is a persistent stub tied to a session, the gatekeeper never stores it directly; the overseer hands it back (re-bound to a new session) each time you call `startHook()`. See the SKELETON for the full code shape.\n\n### Documentation\n\nWhen defining a session interface with hooks, it's important to include comments that clearly state when a method expects to be passed a *persistent* stub created with `ctx.restore()`, as opposed to a regular RpcStub. The caller needs to do extra work to make sure the stub they provide you is persistent.\n\n## Reviewing a gatekeeper\n\nReviews tend to stop at general correctness. The most important review is of the agent-facing API, so pressure-test it:\n\n- **Shape.** Read `types.d.ts` as the agent does, as its only documentation. Does the shape make sense? What would you do differently?\n- **User stories.** List the most common things a user would ask an agent to do with this service and walk each through the API. Flag any that are impossible, need sequential round trips that promise pipelining can't remove, or need knowledge the JSDoc doesn't give. Flag dead ends too: anything the grant includes that the agent can't navigate to, such as child pages it can list but not open.\n- **Seams.** Check that resource types, bindings and sessions partition authority along the lines users actually grant:\n  - Each `getSupportedResources()` granularity is a grant a user would make, and together they cover the common ones (broad and narrow).\n  - A narrow binding's session reaches nothing outside its resource: sub-resources come from stubs on their parent, and no method or option field (`parentId`, `projectId`) takes an ID that escapes the binding.\n  - Each method sits on the object whose authority it needs. A cross-resource operation (move, copy, link) must not quietly require, or grant, a broader binding than the user picked.\n  - Each binding's types expose only what that binding can do and stay small, and its observer strategy fits how the binding is partitioned (see [Choosing a strategy](#choosing-a-strategy-per-resource-type--binding)).\n\n## Tips\n\n- `types.txt` must be a **symlink** to `types.d.ts`, never a copy. Because it is delivered verbatim, `types.d.ts` must not import from other modules — inline any shared types (see [Documenting the API](#documenting-the-api-typesdts)).\n- Call `.dup()` on `approvalQueue` stubs before storing in a session, since Cap'n Web automatically disposes all stubs in parameters to an RPC call when the call returns.\n- `suggestedBindingName` in `describe()` reflects the resource **type** (e.g. `\"GMAIL_INBOX\"`), not the specific instance.\n- For read-only or push-only gatekeepers, `applyAction()` / `rejectAction()` / `revertAction()` can simply throw (they'll never be called since the gatekeeper never submits actions).\n- For `WorkerEntrypoint` and `DurableObject` subclasses, pass credentials and resource IDs via `ctx.props`, not constructor arguments. RPC stubs pointing to these types can be stored in long-term storage and restored later, creating a new instance based on the same `props`.\n- If the gatekeeper implements multiple unrelated resource types with disjoint APIs, each may have its own `.d.ts` file, so that the `getTypeScriptTypes()` method of the specific `Gatekeeper` implementation only returns the types that matter for it. The `getTypeScriptTypes()` method on the top-level `GatekeeperVendor` should return the concatenation of all of these.\n- All DO classes must appear in `cloudflare.config.ts`'s `migrations` export under `new_sqlite_classes`.\n- Set a self-destruct alarm in `UserAccount.setCallback()` in case the OAuth flow is never completed.\n- `authorizeObservation()` may be called *after* fetching data (so the description can include details about what was fetched) but must be awaited *before* returning anything to the caller.\n- `getVerifier()` / `addObserver()` / `removeObserver()` are **mandatory** — the gatekeeper won't type-check without them. Even a read-only or push-only gatekeeper needs them (sharing is independent of whether the gatekeeper has actions). Pick a strategy per [Observers](#observer-verification): a low-stakes one can be A or D; otherwise B/C.\n\n## Reference implementations\n\n- `packages/gatekeeper-google/` — OAuth, multiple resource types (Gmail, Google Docs, BigQuery), actions, caching/simulation examples, multiple Session types. **Observers:** all three strategies in one package — Gmail=A (always throw), Doc=B (single-unit ACL via `GoogleVerifier.hasDocAccess`), BigQuery=C (dataset tracking via `hasDatasetAccess`).\n- `packages/gatekeeper-email/` — Hook-based push notifications, no actions, email address claiming. **Observers:** strategy D (low-stakes no-ops + trivial verifier).\n- `packages/gatekeeper-github/` — **Observers:** clean strategy B example — `GitHubVerifier.hasRepoAccess` plus a one-method `addObserver`.\n- `packages/gatekeeper-gitlab/` — A **mirror** of gatekeeper-github for a provider with instance configuration (self-hosted URLs, an optional Cloudflare Access service token on upstream requests) and **rotating credentials** (expiring access tokens, single-use refresh tokens refreshed under a mutex). Reads the same `plans/worktrees.md` §3 git contract through `gatekeeper-kit`'s `git-transport`/`git-objects`/`git-diff`. **Observers:** strategy B with a role-aware probe (`membershipGrantsFullRead`): Reporter membership whatever the visibility, since a public project can still hold confidential issues and internal notes, read from `members/all?user_ids[]=` because the project's `permissions` object omits group-inherited access. Its `plans/gitlab-gatekeeper.md` divergence table is a worked example of porting one gatekeeper's API to another provider's vocabulary.\n- `packages/gatekeeper-supabase/` — **Observers:** strategy C with a per-session context object (`authorizeProjectObservation`) — good when sessions already hold a shared context.\n- `packages/gatekeeper-linear/` & `packages/gatekeeper-notion/` — **Observers:** strategy C where the page/team session impls are shared between the narrow (B) and broad (C) bindings, threading an `observe` hook through sub-sessions; both also handle one observation revealing multiple sets.\n- `packages/workshop-shared/src/gatekeeper.ts` — Canonical interfaces with detailed JSDoc (`getVerifier`, `addObserver`, `removeObserver`, `GatekeeperUserVerifier`, `ObservationDescription.excludeObservers`).\n","frontmatter":{"name":"write-gatekeeper","description":"Guides implementation of Gatekeeper Workers that bridge Gadgets to external services. Covers auth, capability-based API design, approval queue integration, caching, and action simulation. Load when creating, modifying, or reviewing the implementation of a gatekeeper."},"isInternal":false,"tokens":9240,"sizeBytes":41898},{"name":"AGENTS.md","path":"packages/gatekeeper-kit/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-kit/AGENTS.md","title":"Gatekeeper-kit Skill","category":"anthropic-skill","format":"markdown","content":"# Gatekeeper Kit contributor notes\n\n`@gadgets/gatekeeper-kit` is a library, not a deployable Worker. Do not add a `cloudflare.config.ts`:\nthe `wrangler.jsonc` generated from it is what makes release tooling treat this package as a\ngatekeeper deployment.\n\n## Package boundaries\n\n- Only Layer 1 leaf modules are shipped. Layer 2 remains a proposal in\n  [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md).\n- Keep leaf modules independently usable. Do not make one depend on a future assembly layer.\n- Add a leaf, or a leaf option, only for behavior several gatekeepers share under one contract.\n  Behavior only one gatekeeper has belongs in that gatekeeper. Support it with a plain TypeScript\n  hook or callback on the leaf, or by implementing the canonical interface by hand. Do not add a\n  variant for a single provider.\n- Accept the narrowest structural KV surface a module needs. Pass stable `ctx.storage.kv` objects to\n  modules that coordinate work by storage identity.\n- Treat shipped storage keys and prefixes as compatibility. Use existing key and prefix options when\n  porting a gatekeeper with a different layout.\n- Public modules are explicit subpath exports in [`package.json`](package.json). Document new public\n  symbols with JSDoc and add the subpath to the README inventory.\n\n## Documentation ownership\n\n- [`README.md`](README.md) maps the package and its modules.\n- [`USAGE.md`](USAGE.md) owns cross-module integration guidance and operational sharp edges.\n- Exported-symbol JSDoc owns the exact API contract visible in editors.\n- [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md) is the design record, including the\n  unshipped Layer 2 proposal. Do not duplicate that proposal into current usage documentation.\n\n## Verification\n\nUse Node tests for pure logic. Use the workerd project for persisted RPC stubs, `RpcTarget`,\nDurable Object behavior, and `crypto.subtle.timingSafeEqual`.\n\nFrom the repository root:\n\n```sh\npnpm --filter @gadgets/gatekeeper-kit test:run\nvp run -F @gadgets/gatekeeper-kit build\n```\n","isInternal":false,"tokens":466,"sizeBytes":2054},{"name":"AGENTS.md","path":"packages/ui/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/ui/AGENTS.md","title":"Ui Skill","category":"anthropic-skill","format":"markdown","content":"# Shared UI\n\n`@gadgets/ui` is the shared runtime React UI layer for the Workshop and gatekeeper management apps.\nKumo owns low-level controls and semantic tokens; this package owns reusable Gadgets interaction\npatterns and composed components.\n\n## Ownership\n\n- Add a component here when at least two independent frontends need the same feature-independent\n  behavior or when consistency across those frontends is an explicit product requirement.\n- Keep product data fetching, routing, RPC, permissions, and domain workflows in the consuming app.\n- Check Kumo before adding a primitive. Do not wrap Kumo solely to restyle or rename it.\n- Export source directly. This package has no publish or emitted-build step and is marked private.\n- Keep React and Kumo as peer dependencies so consumers use one runtime and design-system version.\n- A Tailwind consumer must include `packages/ui/src` as an `@source`, because the shared components'\n  utility classes are compiled by the consuming app.\n\n## APIs And Tests\n\n- Prefer a headless behavior primitive plus a Kumo adapter when both are real consumers' needs.\n- Keep the headless model free of presentation policy and styled-only fields where practical.\n- Preserve accessibility and input parity across mouse, keyboard, touch, and hybrid devices.\n- Colocate tests and test observable contracts rather than package boundaries or framework behavior.\n","isInternal":false,"tokens":274,"sizeBytes":1391},{"name":"AGENTS.md","path":"packages/workshop-frontend/AGENTS.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/workshop-frontend/AGENTS.md","title":"Workshop-frontend Skill","category":"anthropic-skill","format":"markdown","content":"# Workshop Frontend\n\nThis package is the Workshop single-page application. It uses React, TanStack Router, Tailwind,\nand Kumo. Follow the repository-wide guidance in `../../AGENTS.md` in addition to this file.\n\n## Architecture\n\nOrganize new code by product ownership first and implementation type second.\n\n```text\nsrc/\n  routes/       TanStack route declarations and route-level wiring\n  pages/        Substantial route-level screens that compose one or more features\n  features/     Product features and their owned UI, hooks, and logic\n  components/   UI and application primitives shared across unrelated features\n  hooks/        Hooks shared across unrelated features\n  utils/        Feature-independent utilities\n```\n\nThe existing tree predates this convention. Apply it to new code and to code being substantially\nreworked; do not migrate unrelated files opportunistically.\n\n### Features\n\nFeature directories own product behavior. A feature may contain components, hooks, tests, and\nutilities that belong to that behavior.\n\nStart a feature directory flat:\n\n```text\nfeatures/chat/composer/\n  ChatComposer.tsx\n  ComposerAddMenu.tsx\n  ComposerAddMenu.test.tsx\n  useComposerDraft.ts\n  composerTokens.ts\n```\n\nDo not create `components/`, `hooks/`, `helpers/`, or `tests/` subdirectories merely to classify\nfiles by implementation type. File names already communicate those types, and tests should usually\nremain beside their subject.\n\nIntroduce a subdirectory when a coherent internal subsystem has several files or the flat directory\nbecomes difficult to scan. Name it after its responsibility, such as `draft/`, `tokens/`,\n`attachments/`, or `slash-commands/`; avoid generic `helpers/` buckets.\n\nOrganize by the reason files change together. Feature-based organization does not replace the\none-component-per-file model.\n\n### Components\n\nCreate a separate component file when the component has at least one of these properties:\n\n- It owns meaningful state, effects, or interaction behavior.\n- It is independently testable or reusable.\n- It represents a distinct UI responsibility.\n- Keeping it inline makes the parent difficult to understand.\n- It has grown supporting types or logic that obscure the parent's main flow.\n\nKeep small stateless rendering helpers private in their parent's file when they are only used there\nand remain easy to understand. Extract them beside the parent when they develop their own behavior\nor tests.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Prefer direct imports; do not add a barrel file solely to shorten paths.\n\n### Pages And Routes\n\nFiles under `src/routes/` define routes. Keep them focused on route concerns: parameters, search\nvalidation, loaders, navigation, and composing the route's page.\n\nA page is a composition boundary, not necessarily a feature. A page may combine several features,\nand a feature may appear on several pages.\n\nA small page may remain in its route file. Move a substantial page implementation to\n`src/pages/<page>/`, where it can compose feature components. Components and hooks meaningful only\nto that page stay in the page directory rather than global `components/` or `hooks/`. Apply the same\nflat-first and responsibility-based subdivision rules used for features. Keeping page support code\noutside `src/routes/` also keeps it out of TanStack Router's file scanning.\n\nName every route-level screen component under `src/pages/` with a `Page.tsx` suffix, such as\n`HomePage.tsx` or `WorkspacePage.tsx`. Supporting components keep names that describe their own\nresponsibility, such as `HomeTaskSuggestions.tsx`.\n\nDo not move product behavior into a page merely because the page currently consumes it. Behavior\nwith independent product meaning or use across pages belongs to `src/features/<feature>/`.\n\nDefault uncertain code to the page and promote it when ownership broadens. The current Home page is\nan example of code that predates this rule: `components/AppShell/HomeTaskSuggestions.tsx` and\n`components/MeshBackground.tsx` have `routes/index.tsx` as their only production consumer. They are\nHome-specific composition and presentation rather than shared AppShell primitives, so their target\norganization would be:\n\n```text\npages/home/\n  HomePage.tsx\n  HomeTaskSuggestions.tsx\n  MeshBackground.tsx\n```\n\nIf task suggestions later become an independently meaningful workflow used by other pages, promote\nthat code to a feature such as `features/task-suggestions/`. Do not begin there based only on\nspeculative reuse.\n\nDo not edit `src/routeTree.gen.ts`; it is generated.\n\n### Shared Code\n\n`src/components/` and `src/hooks/` are shared application infrastructure, not default destinations.\nCode should begin under the feature that owns its behavior.\n\nReuse alone does not make code generic:\n\n- If multiple parts of one feature use an item, move it to their nearest common feature directory.\n- If another feature consumes a concept still owned by the original feature, keep it exported from\n  that feature.\n- Promote an item to global `components/` or `hooks/` only when unrelated features use the same\n  feature-independent abstraction and its API no longer depends on its original feature.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase important domain behavior.\n\nMove code only as high in the hierarchy as its ownership requires. For example, a skill pill shared\nby the composer and chat history belongs to `features/chat/`, while a generic application icon\nbutton belongs to `components/`.\n\n### Component APIs And Composition\n\nRepresent props that are valid only together as one object or a discriminated union. Do not permit\npartial configurations:\n\n```tsx\n// Avoid: callers can provide a count with no way to dismiss it.\ncount?: number;\nonDismiss?: () => void;\n\n// Prefer: the capability is either complete or absent.\nnotice?: { count: number; onDismiss: () => void };\n```\n\nA controlled value requires a change callback. Otherwise, the component owns the value. Do not copy\na controlled prop into local state with an Effect:\n\n```tsx\n// Controlled\nvalue: string;\nonValueChange: (value: string) => void;\n\n// Uncontrolled\ninitialValue?: string;\n```\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events. For\nexample, expose `onModelChange(modelId)` rather than `setSelectedModel` or `onChange(event)`.\n\nAdd `children`, named slots, variants, `className`, DOM prop passthrough, or imperative refs only\nwhen a current caller needs that control. Do not add them in anticipation of reuse.\n\nUse context for application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props; do not introduce context merely to avoid\npassing them through one or two component levels.\n\nExtract a component or hook when the extracted unit owns a complete concern, such as state and its\ntransitions, an Effect lifecycle and cleanup, an accessible interaction, or a pure transformation\nthat can be tested independently. Keep code together when the child would mostly forward markup or\nwould need the parent's refs, setters, and synchronization callbacks to function.\n\n## Kumo And Styling\n\nUse Kumo components and Kumo semantic design tokens by default. Check Kumo before creating a custom\ncontrol, interaction pattern, or visual primitive. Existing Workshop wrappers may be used when they\nprovide established application behavior that Kumo does not provide directly.\n\nCustom colors and design tokens outside Kumo are prohibited unless the user explicitly requests\nthem. In particular, do not add:\n\n- Hex, RGB, HSL, or OKLCH color literals in component styles.\n- Arbitrary Tailwind color values.\n- New application-specific color variables or token families.\n- Feature-local replacements for Kumo surfaces, borders, text, status, focus, or interaction tokens.\n\nUse Kumo semantic classes such as `bg-kumo-base`, `text-kumo-subtle`, and `border-kumo-line` rather\nthan palette colors. Global Kumo token theming is a deliberate application-level decision and must\nnot be introduced or changed as part of ordinary feature work.\n\nLegacy custom tokens and color declarations in the current codebase are not precedent for new code.\nDo not expand their use. Migrate them only as part of explicitly scoped cleanup.\n\nTailwind remains appropriate for structure: layout, spacing, sizing, positioning, responsive\nbehavior, and typography. Use custom CSS only for technical behavior that Kumo and utilities cannot\nexpress, such as editor integration or measured overlays; custom CSS does not relax the color and\ntoken rules.\n\nWhen a Kumo component is unsuitable, record the concrete behavioral or accessibility gap before\nadding a shared Workshop abstraction. Do not wrap Kumo solely to restyle it.\n\n## React\n\n- Define components and hooks as named `const` arrow functions by default. This keeps declarations\n  before use and avoids relying on function-declaration hoisting. Type props directly rather than\n  using `React.FC`. Ensure components wrapped in `memo`, `forwardRef`, or similar APIs retain an\n  explicit name for React DevTools and stack traces, using `displayName` when inference is unclear.\n- Treat Effects as an escape hatch for synchronizing React with an external system, not as a\n  general state-management tool. Follow React's\n  [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) guidance.\n- Do not use an Effect to derive render data from props or state. Calculate it during render instead.\n- Do not use an Effect for logic caused by a user interaction. Run it in the event handler that\n  knows what happened.\n- Do not use an Effect to keep two pieces of React state synchronized. Prefer a single source of\n  truth, derived values, controlled components, or lifting state.\n- Prefer a component `key` when an identity change should reset the component's state.\n- Prefer `useSyncExternalStore` for external store subscriptions when it fits.\n- Effects that fetch or subscribe must clean up stale work and subscriptions. Components must remain\n  correct when Effects are restarted or mounted again in development.\n- Avoid chains of Effects that update state only to trigger another Effect.\n- Keep state as close as possible to the behavior that owns it.\n- Extract a hook when it represents a coherent behavior or is reused, not simply to shorten a file.\n- Do not add `useMemo` or `useCallback` by default; use them when identity or expensive computation\n  has a concrete effect, and follow existing compiler guidance.\n- Preserve keyboard behavior, focus management, and accessible names when extracting interactive UI.\n- RPC stubs must follow the disposal and React state rules in the repository-wide `AGENTS.md`.\n\n## Comments\n\nPrefer code that communicates its intent through names, types, and structure. Do not add comments\nthat narrate what the next line or block already says.\n\nComments are appropriate for gotchas, non-obvious invariants, external constraints, security or\nperformance reasoning, and deliberate departures from a convention. Explain why the surprising\nchoice is necessary, not the mechanics visible in the code. Remove or update comments when the\nconstraint they describe no longer exists.\n\n## Tests\n\nColocate unit tests with their subject using `*.test.ts` or `*.test.tsx`. Use a feature-level\n`__tests__/` directory only for scenarios that span several modules and have no single owner.\n\nTests should either protect behavior whose regression would be important, or verify real logic\nintroduced by the change. Do not test React, JavaScript, TypeScript, Kumo, or another framework's\nown behavior merely because it appears in the implementation.\n\nTest observable contracts, product rules, state transitions, accessibility behavior, race handling,\nand failure paths. Avoid assertions coupled only to implementation details, trivial passthrough\nmarkup, or behavior already guaranteed by types and the underlying platform. A test should have a\nclear failure whose meaning matters to this application.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage when\nan extraction exposes important previously untested logic; do not add tests solely because a file\nor component boundary now exists.\n\nUseful commands from the public workspace root:\n\n```bash\npnpm --filter @gadgets/workshop-frontend test:run\npnpm exec tsc -p packages/workshop-frontend/tsconfig.json\npnpm exec tsc -p packages/workshop-frontend/tsconfig.vite.json\npnpm exec vp run -F @gadgets/workshop-frontend build\n```\n\nRun `pnpm lint` from the public workspace root before pushing when the broader workspace state\nallows it.\n","isInternal":false,"tokens":2629,"sizeBytes":12745},{"name":"README.md","path":"packages/bundled-blueprints/blueprints/workspace-docs/files/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/bundled-blueprints/blueprints/workspace-docs/files/README.md","title":"files Documentation","category":"plugin-manifest","format":"markdown","content":"# Docs — real-time collaborative rich-text Gadget\n\nA single-document, Google-Docs-style rich-text editor with Durable Object persistence, live block updates, collaborator presence, and conflict-safe concurrent editing.\n\n## Shared libraries\n\nThis blueprint is built on the shared gadget libraries in `packages/bundled-blueprints/libraries`\n(see that directory's README). The build inlines what it imports into the `client.js` and `server.js` it ships,\nso a gadget created from it carries its own copy of the libraries as of its creation.\n\n- `@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the `el` element builder, the shared icon table, the\n  toolbar's buttons, groups, colour pickers and dropdown, the in-page prompt that stands in for the\n  sandbox's blocked `window.prompt`, the save-status dot, and the reading and downscaling of a\n  picked, dropped or pasted image.\n- `@gadgets/bundled-blueprints/libraries/sync/client` runs the browser's half of the collaboration loop: the debounced,\n  serialized, retrying save scheduler, the presence roster and its throttled reporter, and the\n  `RpcTarget` the server calls back.\n- `@gadgets/bundled-blueprints/libraries/sync/server` runs the object's half: the mutation queue, the subscriber registry with its\n  presence announcements, and the per-block optimistic concurrency.\n\nEverything on top of them is this gadget's own: the editing commands, the paste sanitizer, the block\nmodel and its ordering rule, where a remote caret is drawn, and the export formats.\n\nIn the repository the source is TypeScript under `blueprints/workspace-docs/files/` (`client.ts`,\n`server.ts`, the shared `lib/protocol.ts` contract and the `lib/markdown.ts` export), which the build\nbundles into the `client.js` and `server.js` this gadget runs.\n\n## Architecture\n\n- **server.js** is the authoritative collaboration coordinator. It stores one atomic `document:v2` snapshot containing the title, global revision, ordered blocks, per-block versions, and modification time.\n- **client.js** builds the entire UI around a `contenteditable` surface. Every top-level document element has a stable `data-block-id`. Local typing is optimistic and changed blocks are sent after a short ~220 ms debounce.\n- Clients subscribe with a Cap'n Web `RpcTarget`. The server broadcasts accepted block operations and ephemeral presence events to every connected client.\n\n## Real-time data model\n\nThe persisted document resembles:\n\n```js\n{\n  revision: 42,\n  title: \"Project plan\",\n  blocks: [\n    { id: \"b_…\", html: \"<h1 data-block-id=\\\"b_…\\\">Project plan</h1>\", version: 3 },\n    { id: \"b_…\", html: \"<p data-block-id=\\\"b_…\\\">Draft…</p>\", version: 8 }\n  ],\n  lastModified: 1700000000000\n}\n```\n\nClients send compact operation batches containing only changed/upserted blocks, version-checked deletions, the current block order, and title. The Durable Object:\n\n1. checks each changed block's `baseVersion`;\n2. accepts non-conflicting changes;\n3. increments accepted block versions and the document revision;\n4. persists the resulting authoritative snapshot; and\n5. broadcasts only the accepted operation.\n\nA remote operation patches only affected DOM blocks. The whole editor is not replaced, so typing and selection in unrelated paragraphs remain stable. Ordering is last-writer-wins, while content and deletion changes use per-block optimistic concurrency.\n\n### Same-block concurrency\n\nA remote update to the block currently being edited is queued rather than replacing the user's caret and draft. If the local block is clean on blur, the remote version is applied. If it is locally dirty, the draft is rebased onto the latest server block version and saved as the next version. Server conflicts return the authoritative block/version, and the client automatically retries its visible local draft—no local text is silently discarded.\n\nThis is block-granularity collaboration rather than a character-level CRDT. It provides smooth simultaneous editing across paragraphs with much less complexity. A CRDT/OT layer would still be required for merged character-by-character edits by multiple people inside the exact same paragraph.\n\n## Presence\n\nSelection changes are throttled to ~70 ms and sent as ephemeral presence messages containing collaborator ID, name, color, and anchor/focus block-and-text offsets. Presence is never persisted. A collaborator appears as a slim named caret; selected text is shown with a subtle translucent highlight, including selections spanning multiple blocks. Both are rendered in a fixed overlay without modifying document HTML. There are no header avatars or active-block outlines. Page close uses a best-effort leave message, RPC disconnects broadcast leave events, and client heartbeats expire stale cursors when browsers do not complete unload networking.\n\nRemote carets and selection highlights live in a separate overlay, so presence decoration never enters the editable DOM or persisted document HTML. Serialization also strips transient local image-selection decoration.\n\n## Programmatic population and backward compatibility\n\nAgents and importers should call `setDocument({ title, blocks, senderId })` to populate or replace the document atomically. It works whether the editor has already been opened or not, persists the full snapshot, increments the revision, and broadcasts the result to connected clients. This avoids requiring callers to choose between initialization and incremental operations.\n\n`initializeBlocks()` remains the idempotent bootstrap/migration API. It also handles the startup race where a newly opened browser creates an empty revision-1 shell just before generated content arrives: that exact untouched shell can be replaced by a non-empty initialization payload. It will not overwrite later empty documents, which may represent an intentional user edit.\n\nIf only the former `content`, `title`, and `lastModified` KV keys exist, the first v2 client loads that HTML, normalizes its top-level nodes, assigns stable block IDs, and calls `initializeBlocks()`. Existing documents therefore migrate without data loss.\n\n## Formatting and media\n\nThe toolbar supports paragraph styles, font family/size, bold, italic, underline, strikethrough, text/highlight color, alignment, lists, indentation, links, images, horizontal rules, clear formatting, and undo/redo. Font sizes are in points, as in Google Docs and Word: body text is 12 pt, in the editor and in the HTML and PDF exports alike, so a sized run keeps its proportion to the text around it.\n\nPasted Google Docs/Word HTML is rebuilt into clean semantic markup. Images from file selection, drag/drop, and clipboard are downscaled and embedded as data URLs. Because collaboration transmits changed blocks, an embedded image is no longer resent when unrelated paragraphs change. A future asset store could replace data URLs with image IDs if very large media-heavy documents are needed.\n\n## Implementation notes\n\n- Durable Object storage is the source of truth; in-memory subscriber and presence maps are intentionally ephemeral.\n- Structural DOM normalization handles root text and `<br>` nodes and repairs duplicate/missing block IDs.\n- Remote ordering moves only blocks that are out of place, avoiding unnecessary selection disruption.\n- `execCommand` is deprecated but remains the compact, broadly compatible formatting mechanism for this sandboxed editor.\n\n## Export formats\n\n- **Markdown** exports the persisted rich-text blocks as Markdown, preserving headings, bold,\n  italic, strikethrough (whether written as tags or as the inline styles the toolbar applies, with\n  an inline style overriding its tag and its ancestors, as on the page), links, images, lists,\n  block quotes, code, horizontal rules and line breaks. A paragraph whose emphasis changes inside\n  a word, where Markdown's asterisks could pair differently than written, writes its emphasis as\n  inline `<strong>`, `<em>` and `<del>` tags instead. Markdown has no syntax for underline,\n  colour, size, font or alignment, so those runs export as plain text. An image exports with its\n  `src`, which for an embedded image is its whole data URL.\n- **HTML** and **PDF** use the editor's print layout and omit editing chrome and presence UI.\n","isInternal":false,"tokens":1707,"sizeBytes":8251},{"name":"README.md","path":"packages/bundled-blueprints/blueprints/workspace-sheets/files/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/bundled-blueprints/blueprints/workspace-sheets/files/README.md","title":"files Documentation","category":"plugin-manifest","format":"markdown","content":"# Sheets\n\nA lightweight, persistent spreadsheet Gadget with a familiar grid interface, formulas, formatting, multiple sheets, and live synchronization.\n\n## Features\n\n- Editable spreadsheet title and multiple sheet tabs\n- Add, rename, duplicate, and delete sheets\n- Cell and range selection with keyboard navigation\n- Formula bar and A1-style references\n- Cross-sheet references such as `Sheet2!A1` and `'Sales Data'!B4`\n- 100+ spreadsheet functions across math, statistics, logic, text, lookup, date/time, and information categories\n- Number, currency, percent, scientific, date, and time formatting\n- Bold, italic, underline, strikethrough, text color, fill color, alignment, and wrapping\n- Row and column insertion, deletion, and resizing\n- Range sorting, AutoSum, copy/paste via TSV, and local undo/redo for cell edits\n- Automatic persistent saving with optimistic per-cell conflict detection\n- Real-time operation and presence synchronization in the server architecture\n- Excel workbook and per-sheet CSV export\n\n## Using the spreadsheet\n\n- Click a cell to select it.\n- Double-click a cell, press **F2**, or begin typing to edit it.\n- Start formulas with `=`, for example:\n  - `=SUM(A1:A10)`\n  - `=IF(B2>100,\"High\",\"Low\")`\n  - `=VLOOKUP(E2,A2:C20,3,FALSE)`\n  - `=Sheet2!A1*2`\n- Use the name box to jump to a cell or range such as `D12` or `A1:C8`.\n- Right-click the grid for cut/copy/paste and row or column actions.\n- Double-click a sheet tab to rename it; right-click it to duplicate or delete it.\n\n## Keyboard shortcuts\n\n| Shortcut | Action |\n| --- | --- |\n| Arrow keys | Move the active cell |\n| Shift + Arrow | Extend the selection |\n| Enter / F2 | Edit the active cell |\n| Tab / Shift + Tab | Move right / left |\n| Delete / Backspace | Clear selected cells |\n| Ctrl/Cmd + C, X, V | Copy, cut, paste |\n| Ctrl/Cmd + Z | Undo |\n| Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z | Redo |\n| Ctrl/Cmd + B | Bold |\n| Ctrl/Cmd + I | Italic |\n| Ctrl/Cmd + U | Underline |\n| Ctrl/Cmd + A | Select the whole sheet |\n| Ctrl/Cmd + Arrow | Jump across populated or empty regions |\n| Alt + Enter | Insert a line break while editing |\n\n## Programmatic population\n\nThe spreadsheet can be read and populated through the Gadget's server RPC methods. This is useful for scripts, agents, migrations, or seeding a workbook without using the grid UI.\n\n### Read the workbook\n\nCall `getDocument()` to retrieve the complete persisted workbook:\n\n```js\nconst doc = await gadget.getDocument();\n```\n\nThe returned object contains `revision`, `title`, `sheetOrder`, `sheets`, `cells`, and `lastModified`. Cells are grouped by sheet ID and keyed by A1 reference:\n\n```js\nconst firstSheetId = doc.sheetOrder[0];\nconst a1 = doc.cells[firstSheetId].A1;\n// { value: \"Revenue\", fmt: { b: true }, version: 1 }\n```\n\n### Write cells\n\nUse `applyOperation()` with per-cell operations. For a new cell, use `baseVersion: 0`; when replacing or deleting an existing cell, use its current `version` from `getDocument()`.\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\nconst cells = doc.cells[sheetId];\n\nconst result = await gadget.applyOperation({\n  senderId: \"seed-script\",\n  cellOps: [\n    {\n      sheetId,\n      ref: \"A1\",\n      value: \"Revenue\",\n      fmt: { b: true, bg: \"#fff3a3\" },\n      baseVersion: cells.A1?.version ?? 0,\n    },\n    {\n      sheetId,\n      ref: \"B1\",\n      value: \"=SUM(B2:B10)\",\n      fmt: { nf: \"currency\", d: 2 },\n      baseVersion: cells.B1?.version ?? 0,\n    },\n  ],\n});\n```\n\nA cell's `value` is always stored as text. Begin a value with `=` to create a formula. To delete a cell completely, send both `value: null` and `fmt: null` with the current `baseVersion`.\n\nCheck `result.status` after writing. It is `applied`, `unchanged`, or `conflict`. On a conflict, inspect `result.conflicts`, re-read the document, and retry against the latest cell versions rather than overwriting blindly.\n\n### Replace a whole sheet\n\nFor bulk imports, `sheetReplacements` is more efficient than sending many individual cell operations:\n\n```js\nawait gadget.applyOperation({\n  senderId: \"csv-import\",\n  sheetReplacements: [{\n    sheetId,\n    cells: {\n      A1: { value: \"Name\", fmt: { b: true }, version: 1 },\n      B1: { value: \"Total\", fmt: { b: true }, version: 1 },\n      A2: { value: \"Example\", fmt: null, version: 1 },\n      B2: { value: \"1250\", fmt: { nf: \"currency\", d: 2 }, version: 1 },\n    },\n  }],\n});\n```\n\nA whole-sheet replacement overwrites that sheet's complete cell map, so merge with existing data first if it must be preserved. It does not use per-cell conflict checks.\n\n### Change workbook structure\n\nPass a complete structure snapshot to change the title, sheet order, names, dimensions, or sizing metadata. Because structure uses last-writer-wins semantics, start with the latest document and preserve every sheet that should remain:\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\n\nawait gadget.applyOperation({\n  senderId: \"setup-script\",\n  structure: {\n    title: \"Quarterly plan\",\n    sheetOrder: doc.sheetOrder,\n    sheets: {\n      ...doc.sheets,\n      [sheetId]: {\n        ...doc.sheets[sheetId],\n        name: \"Summary\",\n        colWidths: { ...doc.sheets[sheetId].colWidths, 0: 180 },\n      },\n    },\n  },\n});\n```\n\nOmitting an existing sheet from `sheetOrder` deletes it and its stored cells. Adding a new sheet ID creates empty storage for it. When adding a sheet and its initial data together, include both `structure` and a matching `sheetReplacements` entry in the same operation.\n\nFormatting keys accepted by the server are `b` (bold), `i` (italic), `u` (underline), `s` (strikethrough), `c` (text color), `bg` (fill color), `a` (`l`, `c`, or `r` alignment), `nf` (number format), `d` (decimal places), `fs` (font size), and `wrap`. Colors must be hexadecimal strings such as `#1d1d20`.\n\n## Architecture\n\nBoth sides are built on the shared gadget libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the `client.js` and `server.js` it ships.\n\n`@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the element builder, the shared\ntoolbar icons and controls (icon and segment buttons, groups, colour pickers, the dropdown behind the\nnumber-format menu), the in-page prompt that stands in for the `window.prompt` the sandbox blocks,\nand the save-status dot. `@gadgets/bundled-blueprints/libraries/sync/client` and `.../sync/server` are the\ncollaboration loop: the debounced, serialized, retrying save scheduler, the presence roster and\nheartbeat, the subscriber object the server calls back on, and in the Durable Object the mutation\nqueue and the subscriber registry with its presence announcements (whose broadcasts are never\nawaited, so a callback may re-enter the queue). `@gadgets/bundled-blueprints/libraries/zip/server`\nprovides the streaming ZIP writer used by XLSX export. The cell model, the formula engine, the grid,\nsheet tabs and the export-specific code are this gadget's own.\n\nIn the repository the source is TypeScript under `blueprints/workspace-sheets/files/`\n(`client.ts`, `server.ts`, `lib/protocol.ts`, `lib/formula.ts`, `lib/xlsx.ts`), which the build bundles\ninto the `client.js` and `server.js` shipped here.\n\n### `client.js`\n\nBuilds the entire browser interface in JavaScript. It contains:\n\n- Grid rendering and selection behavior\n- Cell editing, formatting, sorting, and structural operations\n- Formula tokenization, parsing, evaluation, and display formatting\n- Clipboard and keyboard support\n- A local model whose saves are queued per cell and flushed by the library's scheduler\n- RPC callbacks for live server operations and presence events\n\nFormula evaluation happens in the browser. The engine caches computed cells, detects circular references, supports ranges and cross-sheet references, and displays standard errors including `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#N/A`, `#NUM!`, and `#CYCLE!`.\n\n### `server.js`\n\nExports the Durable Object class `Gadget`, which is the authoritative persistence and synchronization layer. It:\n\n- Stores spreadsheet metadata and each sheet's cells in Durable Object storage\n- Serializes mutations and document snapshots through the library's mutation queue\n- Applies per-cell optimistic concurrency using cell versions\n- Uses last-writer-wins semantics for document structure\n- Broadcasts operations and presence events through the library's subscriber registry after the queue releases, best-effort and without awaiting them, so a callback may itself read or write the document and a hung subscriber holds up only its own client\n- Sanitizes titles, dimensions, cell contents, references, and formatting\n- Advertises and produces the server-side workbook and CSV exports\n\n### XLSX and ZIP modules (bundled into `server.js`)\n\nThe XLSX module converts a complete document snapshot into a streaming XLSX workbook: sparse\nworksheet XML with inline strings, deduplicated styles, and frozen panes. The ZIP module is a\ndependency-free streaming ZIP writer (raw DEFLATE via `CompressionStream`, incremental CRC32, data\ndescriptors). Both are bundled into `server.js`.\n\n## Storage model\n\nThe server stores:\n\n- `meta`: revision, title, sheet order, sheet metadata, and modification time\n- `cells:<sheetId>`: a map of A1 references to `{ value, fmt, version }`\n\nDefault sheets have 100 rows and 26 columns. Server validation permits up to 50,000 rows and 702 columns per sheet. Cell values are limited to 8,192 characters, and titles are limited to 200 characters.\n\n## Collaboration notes\n\nThe server and client synchronization code support multiple connected clients and live updates. Each\ntab introduces itself with the guest name and colour the sync library derives from its client ID, and\nreports its selected range on the library's throttle and heartbeat. Remote collaborator badges and\nselection overlays are currently disabled in the UI, so the app presents as a single-user spreadsheet\neven though remote operations still synchronize.\n\n## Current limitations\n\n- There is no file import workflow; clipboard operations use tab-separated text.\n- Structural edits and sorting clear local undo/redo history.\n- Formula reference adjustment during row/column changes is limited to references on the current sheet.\n- Formula support is broad but is not intended to be fully compatible with Excel or Google Sheets.\n- Frozen row/column metadata exists in the model, but the current UI does not expose controls for it.\n\n## CSV export\n\nUp to 31 worksheets are exposed as individual **CSV** export options, leaving one of the platform's\n32 format slots for XLSX. CSV files contain the stored cell values through the worksheet's used\nrange. Formula cells are exported as their raw formulas (for example, `=SUM(A1:A10)`), not as\nbrowser-computed display values. Fields use standard CSV quoting and CRLF line endings.\n\n## XLSX export\n\n**Excel Workbook** exports one XLSX file with the worksheets in workbook order. Cells keep their\nfont, color, fill, alignment, number format, decimal places and wrapping; sheets keep column widths,\nrow heights and frozen panes. Pixel sizes are converted to points (rows, fonts) or approximate\ncharacter widths (columns), so layout is close but not pixel-identical.\n\nCell values follow the grid's own literal rules: a leading apostrophe forces text, a leading `=` is\na formula (whatever the number format), `TRUE`/`FALSE` are booleans, and numbers may carry a sign,\ncommas, a leading `$` or a trailing `%`. Everything else is text; date-looking text is not parsed.\n\nFormulas are written without cached results and the workbook requests a full recalculation on open,\nso Excel evaluates them itself. To keep them valid there, the exporter rewrites cross-sheet\nreferences to the exported worksheet names, prefixes OOXML \"future functions\" (`IFS`, `CONCAT`, ...)\nwith `_xlfn.`, renames `ERRORTYPE()` to `ERROR.TYPE()`, and drops whitespace between a function\nname and its `(`. A formula that is empty, structurally unbalanced (unterminated string or quoted\nname, mismatched parentheses or brackets — the grid's parser tolerates these) or that would exceed\nExcel's 8,192-character limit after rewriting is exported as text, since one such formula makes\nExcel report the whole workbook as damaged. Formula semantics are otherwise not translated (for\nexample `^` associativity differs, and a reference outside the grid such as `XFE1` is empty here\nbut `#NAME?` in Excel), and compatibility with Excel is not claimed beyond this.\n\nWorksheet names are made Excel-safe: invalid characters become `_`, blank names become `Sheet`,\nnames are cut to 31 characters, and case-insensitive collisions get ` (2)`, ` (3)`, ... suffixes.\nReferences resolve to the first worksheet with a matching source name, as in the grid.\n\nFilters, charts, comments and pivot tables are not exported; cells already materialized from them\nexport as ordinary values.\n","isInternal":false,"tokens":3067,"sizeBytes":12923},{"name":"README.md","path":"packages/bundled-blueprints/blueprints/workspace-slides/files/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/bundled-blueprints/blueprints/workspace-slides/files/README.md","title":"files Documentation","category":"plugin-manifest","format":"markdown","content":"# Workspace Slides\n\nA composable, multiplayer slide deck builder. The deck itself is data —\nslides are arrays of typed blocks — and the editor lets anyone add, remove,\nreorder, and edit slides in real time. The visual language (tokens,\ncomponents, typography) is shared across every block. Edit mode uses light,\nneutral sidebars so the slide canvas remains the visual focus.\n\n## Files\n\n- `server.js` — Durable Object that owns the deck document and broadcasts\n  every mutation to connected viewers. The server is a generic store; it\n  doesn't know what any component does.\n- `client.js` — Design tokens, the component registry, slide renderer,\n  block interactions (drag, resize, inline text edit), and the builder\n  shell (slide list, inspector, palette, control bar, present mode).\n\nIn the repository these two modules are TypeScript (`client.ts`, `server.ts`\nand the shared `lib/protocol.ts` types under\n`blueprints/workspace-slides/files/`), which the build bundles into the\n`client.js` and `server.js` installed here.\n\n## Gadget libraries\n\nThree pieces of this deck are not its own: the element builder, the two\nsteps that read an uploaded image, and the object the Durable Object calls\nback. They are shared with the other document-style gadgets and are\nimported from the libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the two files installed here:\n\n```ts\nimport { el, loadImage, readFileAsDataURL } from \"@gadgets/bundled-blueprints/libraries/ui/client\";\nimport { createSubscriber } from \"@gadgets/bundled-blueprints/libraries/sync/client\"; // client.ts\nimport { SubscriberRegistry } from \"@gadgets/bundled-blueprints/libraries/sync/server\"; // server.ts\n```\n\n- **`ui`** — `el(tag, props, children)` builds every element in\n  `client.js`; `readFileAsDataURL` and `loadImage` are the two halves of\n  `fileToImageDataURL`, whose encoding rule (SVG verbatim, PNG kept as PNG,\n  the original on any canvas failure) stays here because it is the deck's.\n  The library's own icons, toolbar controls and prompt are for the gadgets\n  with a formatting bar; this deck draws its own `ICONS` as complete SVGs\n  and has no toolbar of that shape, so it imports none of them.\n- **`sync`** — `createSubscriber(RpcTarget, { deckChanged })` builds the\n  callback target (the RPC layer only calls prototype methods, which is\n  what the library gets right), and `SubscriberRegistry` holds the\n  connected browsers in the Durable Object. A deck has no presence — every\n  viewer sees the same slides and no cursors are shared — so the registry\n  is built with no presence hooks and is a plain fan-out.\n\nEverything else here — the tokens, the `COMPONENTS` registry, the layout and\nsnapping code, the deck document and its undo history — is this blueprint's.\n\n## Slide formats\n\nThe deck is built in a clean, **branded corporate style** (an orange accent\npalette on white slides) and supports two ready-made slide formats. Both live on the fixed\n`1200 × 675` reference canvas and are made entirely of normal editable\nblocks, so anything a template drops in can be moved, restyled, or deleted\nafterward.\n\n### 1. Title / cover slide (`makeTitleSlide()` in `client.js`)\n\nThe orange opening slide.\n\n- Full-bleed orange cover artwork (`background.coverOrange`).\n- White brand **logo** (a clean editable wordmark with an optional accent\n  dot) at `x 36, y 56`, width `267`.\n- **Title** — Inter, white, ~58px, weight 700, at `x 33, y 197`, width\n  `687`. Kept in the left portion of the slide, max three lines.\n- **Subtitle** — Inter, white, ~17px, weight 600, at `x 36, y 533`, width\n  `553`, max two lines. Delete the block if no subtitle is needed.\n\nThis is also what the seed deck's first slide and the server's\n`newBlankSlide()` produce.\n\n### 2. Basic content slide (`makeContentSlide()` in `client.js`)\n\nThe standard white content shell for every non-cover idea.\n\n- Pure white background, no inset surface, no shadow, no border.\n- **Eyebrow** (`sectionLabel`) — Ruby `#FF6633`, uppercase, SemiBold, at\n  `x 36, y 35`. Optional; delete when not useful. Eyebrows are always Ruby.\n- **Headline** (`title`) — black `#000000`, ~28px, weight 600, at\n  `x 35, y 76`, width `984`, max two lines, written as an assertion.\n- **Logo** — the brand wordmark (the `logo` component in its `dark`\n  variant: black wordmark + orange accent dot), at `x 1013, y 40`, sized\n  via `scale: 0.62`.\n- **Content region** — body starts at `x 36, y 204`. The default narrative\n  uses a constrained `760px` reading measure at 19px, weight 400,\n  line-height 1.6. The add-slide controls also provide exact-grid\n  **2-column** (`x 36/609`, `w 553`) and **4-column**\n  (`x 34/328/622/916`, `w 260`) compliant structures.\n- **Bottom brand bar** — full-width 12px Tangerine `shape` at `y 663`, touching the bottom edge.\n\n### Creating slides in either format\n\n- **In the editor:** enter edit mode (**E**). The left panel footer has\n  two buttons — **Title** and **Content** — that insert the matching\n  template after the current slide. Each calls\n  `addSlide(makeTitleSlide())` / `addSlide(makeContentSlide())`.\n- **Programmatically (over the `GADGET` binding):** call\n  `addSlide(atIndex, slideObject)` with a `{ background, blocks }` object.\n  The block/prop shapes in `makeTitleSlide` / `makeContentSlide` are the\n  canonical reference for the two styles; the server mints block ids on\n  insert.\n\nBrand style tokens worth reusing: Ruby `#FF6633`, Tangerine `#F6821F`,\nMango `#FBAD41`, black `#000000`, muted gray `#747474`, Inter throughout.\nSlide-facing component controls are intentionally limited to this palette\nand the approved 400/500/600/700 weights. Cards and diagram boxes default\nto flat white surfaces, thin `#E5E5E5` rules, minimal corner radii, and no\nshadows. Generic colored tones and the pill component are not offered in\nthe component library. The `bulletList` component implements the corporate\nhanging-indent treatment: one item per source line, 6px Tangerine dots,\n12px dot-to-copy gap, 19px primary or 17px compact Inter Regular text, and\ncontrolled 10px/8px item spacing.\n\n## Initial blueprint\n\nNew Gadget instances start from the current four-slide overview of the\nbuilder: the orange “Compose your deck or build with agent” cover, a six-part\nfeature overview, a connected-charts example showing how an agent can use an\napproved internal system of record as a data source, and a two-path get-started\nslide for editing directly or asking the agent. The complete blueprint\nis defined by `INITIAL_DECK` plus `KEY_TAKEAWAYS_SLIDE` in `server.js`; both\nfirst-time initialization and `resetAll()` clone that blueprint. The immutable default\nobjects in `server.js` are cloned whenever storage is first seeded and by\n`resetAll()`, so later edits cannot mutate the blueprint.\n\n## Data model\n\nThe deck is one JSON document:\n\n```js\n{\n  slides: [\n    {\n      id: \"abcd1234\",\n      background: { color: \"#f5f1eb\", inset: true, dotGrid: 0.45 },\n      blocks: [\n        { id: \"...\", type: \"title\", x: 48, y: 78, w: 1040,\n          props: { text: \"Hello\", fontSize: 46, highlight: \"Hello\" } },\n        ...\n      ],\n    },\n    ...\n  ],\n}\n```\n\nEvery block is `{ id, type, x, y, w?, h?, props }`. Position is in the\nfixed `1200 × 675` slide coordinate system; the stage scales uniformly to\nfit the viewport.\n\n## Component registry\n\n`COMPONENTS` in `client.js` is the catalog. Each entry declares:\n\n| field            | purpose                                                 |\n| ---------------- | ------------------------------------------------------- |\n| `name`           | Display name (palette + inspector header).              |\n| `defaultBlock()` | Returns `{ x, y, w?, h?, props }` for a freshly added block. |\n| `fields`         | Inspector schema (`text`, `multiline`, `number`, `select`, `checkbox`, `color`, `image`, `svg`). Set `advanced: true` on a field to tuck it under the inspector's \"Advanced\" disclosure (along with position/size). |\n| `resizableW/H`   | Whether the corner handle adjusts that dimension.       |\n| `fullBleed`      | If true (arrow), the block ignores wrapper position and renders an absolutely-positioned overlay. |\n| `render(props, ctx)` | Returns the DOM. `ctx.inlineText(elem, propKey, transform?)` wires up contentEditable in edit mode. |\n\nComponents exposed in the palette (generic, reusable):\n\n`title`, `subtitle`, `text`, `sectionLabel`, `card`, `box`, `tonePill`,\n`image`, `svg`, `divider`, `shape`, `arrow`.\n\nComponents defined in `COMPONENTS` but intentionally **not** in the\npalette — they're brand marks used by the seed deck only:\n\n`gadgetsMark`, `logo`. The `logo` component renders a clean editable\nwordmark (`text` prop, default `Workspace`) with an optional orange accent\ndot (`accentDot` prop); change the wordmark to rebrand the deck. They still\nrender anywhere a block of that type appears; they just aren't offered as\nbuilding blocks.\n\n### Adding a new component\n\n1. Add a new entry to `COMPONENTS` with a `render`, `defaultBlock`, and\n   `fields`. Use `ctx.inlineText(elem, \"propKey\")` for any text the user\n   should be able to edit directly in the slide.\n2. Add the type to `PALETTE_ORDER` so it shows up in the library.\n3. Add the type to one of the `COMPONENT_CATEGORIES` groups, and add a\n   short description to `COMP_DESC` plus a 14×14 currentColor SVG icon\n   to `COMP_ICON`. (The library renders the icon + name + description as\n   one row per component.)\n\nThat's it — no other code needs to change. The inspector is auto-generated\nfrom `fields`, drag/resize/selection all work automatically.\n\n## Editing\n\nPress **E** (or click the pencil) to enter edit mode. Edit mode reveals:\n\n- **Left panel** — a light, neutral sidebar with a **slide search** (matches\n  any string value in any block's props), a scrollable thumbnail list,\n  and a split **Add slide** control. The main action inserts the standard\n  narrative slide; its caret menu offers title, narrative, 2-column, and\n  4-column layouts (see \"Slide formats\" above). Each row\n  shows its slide\n  number, the thumbnail, and a one-line label derived from the slide's\n  first title (or subtitle / text). Click to jump, drag to reorder\n  (disabled while filtering), hover to reveal duplicate/delete. Press\n  Enter in the search box to jump to the first match; Escape clears.\n- **Right panel** — three stacked sections, in order:\n  1. **Component library** — a contained, filterable card. The search\n     input doubles as the section title; type to filter (matches on\n     name, description, and type id), Enter inserts the first match,\n     Escape clears. Without a query, components are grouped by category\n     (Text / Containers / Decoration / Diagram); with a query, results\n     are flat. The list has its own internal scroll so the library\n     never grows past ~280px tall.\n  2. **Slide** — compact background settings for the current slide\n     (page color, dot grid opacity, inset toggle).\n  3. **Selected block inspector** — icon + name header, schema fields\n     (label-on-the-left rows, multiline + color stack vertically),\n     a 4-button icon row (duplicate, bring forward, send backward,\n     delete), and an \"Advanced\" disclosure that holds position/size\n     and any fields tagged `advanced: true`. When nothing is selected\n     this is replaced by a quiet \"Nothing selected\" hint.\n- **Stage** — every block is selectable (hover dashes, click to select);\n  drag the body to move, the bottom-right handle to resize. Text inside a\n  block is contentEditable; press Enter or click away to save. While\n  dragging, the block softly snaps when its left / right / center edges\n  line up with another block's edges or center, or with the slide bounds\n  / center; a thin orange guide line shows which alignment is locked.\n  Hold **Alt / Option** while dragging to disable snapping entirely.\n\nPress **F** to enter presentation mode (chrome hidden, stage fills the\nviewport, no rounded corners). **Esc** or **F** exits.\n\n## Realtime\n\nEvery mutation is sent through RPC to the Durable Object and re-broadcast\nas a full deck snapshot via the subscriber callback. The callback signature\nis `deckChanged(deck, meta)` where `meta = { canUndo, canRedo }` reflects\nthe current server-side history state. The client preserves the currently-\nselected slide & block across updates when possible.\n\nThe registry behind `#broadcast` isolates subscribers from each other: one\nwhose call fails is dropped and its stub released rather than failing the\nmutation being broadcast, and so is one whose connection breaks.\n\n## Undo / redo\n\nThe undo stack lives on the server (in `Gadget`, in memory only — lost on\nDO restart, capped at `MAX_UNDO` entries). It's **global**: there's one\nshared history for the deck, and pressing undo rolls back the most recent\nmutation made by any connected client. Every call to `#save` snapshots\nthe previous deck onto the undo stack and clears the redo stack;\n`undo()` / `redo()` bypass `#save` (so they don't recurse) and broadcast\nthe restored deck through `#broadcast`. Clients learn whether undo/redo\nare currently available from the `meta` arg piggy-backed on every\nbroadcast, plus an initial `getUndoState()` call at boot.\n\n## Keyboard\n\n- `←` / `→` / `Space` / `PageUp` / `PageDown` — navigate slides\n- `Home` / `End` — first / last slide\n- `E` — toggle edit mode\n- `F` — toggle present mode\n- `Delete` / `Backspace` (edit mode) — delete the selected block\n- `Ctrl/Cmd + C` / `X` / `V` (edit mode) — copy / cut / paste the selected\n  block. The clipboard is in-memory (per tab); paste inserts onto the\n  *current* slide with a small position offset so the new block doesn't\n  sit perfectly on top of its source, and the new block becomes selected.\n- `Ctrl/Cmd + D` (edit mode) — duplicate the selected block in place\n  (same as the inspector's duplicate icon, doesn't touch the clipboard).\n- `Ctrl/Cmd + Z` — undo; `Ctrl/Cmd + Shift + Z` (or `Ctrl + Y`) — redo.\n  Also available as two icon buttons in the floating control bar (only\n  shown while in edit mode; the keyboard shortcuts work anywhere).\n- `Esc` — close jump menu, then deselect, then exit present mode\n\n## Motion\n\nAll UI motion uses a single `--ease` token\n(`cubic-bezier(0.23, 1, 0.32, 1)` — a strong ease-out) and a small\n`--ease-in-out`. Press feedback on buttons is handled with two CSS\nselectors registered in `mountShell`:\n\n- `[data-press]:active { transform: scale(0.97); }` — generic buttons.\n- `[data-icon-btn]:active { transform: scale(0.92); }` — pill icon\n  buttons in the control bar.\n\nAdd either attribute to a button to get the right press feel for free.\nAvoid `transition: all`; always specify the exact properties so unrelated\nproperty changes don't accidentally animate.\n\n## Inspector form controls\n\nAll inspector inputs (text, number, select, textarea, checkbox, color)\nshare a small set of CSS classes defined in `mountShell`:\n\n- `.field-input` — the base look (subtle dark fill, transparent border,\n  orange ring on focus). Applied to `<input>`, `<select>` and\n  `<textarea>`.\n- `.field-textarea` — adds the multiline sizing on top of `.field-input`.\n- `.field-select` — adds an inline SVG chevron and resets the native\n  `appearance` so the select matches text inputs.\n- `.field-check` — a custom-painted checkbox (orange when checked).\n\n`fieldRow(label, control, { stacked })` lays out a label + control as a\ntwo-column grid by default; pass `{ stacked: true }` for full-width\ncontrols (multiline only). All field factories (`textField`,\n`numberField`, etc.) use this and the classes above, so you almost never\nneed to touch styles when adding a new field type.\n\n## Notes\n\n- The brand logo and gadgets mark are inlined as SVG; they live in\n  the `logo` / `gadgetsMark` components and are used only by the seed\n  deck. The palette doesn't expose them as reusable blocks. To rebrand,\n  edit the `logo` block's `Wordmark` field (and toggle its accent dot).\n- **Image** blocks store their data inside `props.src` as either an\n  external URL (loaded normally by `<img>` — note this is one of the few\n  network paths the sandbox still allows) or as an inlined `data:` URI.\n  Uploads are read with the `ui` library's `readFileAsDataURL`, and raster\n  images larger than `MAX_IMAGE_DIM` (1600px on the longest side) are\n  downscaled on canvas before being inlined, so the deck JSON doesn't\n  balloon. SVG files are passed through verbatim. The custom `image` field\n  type in `renderField` renders the preview + upload/clear buttons + URL\n  input.\n- **SVG** blocks store raw markup in `props.markup`. `render()` parses\n  it with the DOM parser, strips `<script>` elements and `on*` attributes,\n  then forces `width=\"100%\" height=\"100%\"` and a `preserveAspectRatio`\n  derived from the `fit` prop so the SVG scales to the block bounds.\n  Agents can author / replace SVG blocks programmatically over the\n  `GADGET` binding: call `addBlock(slideId, { type: \"svg\", x, y, w, h,\n  props: { markup: \"<svg ...>...</svg>\" } })` or `updateBlock(...,\n  { props: { markup: \"...\" } })`.\n- Arrows are special-cased: they render a full 1200×675 SVG overlay and\n  store endpoints in `props.x1/y1/x2/y2`. Endpoint handles appear when an\n  arrow is selected. **Important**: full-bleed blocks must NOT make their\n  root element pointer-clickable, or they'll eat clicks for everything\n  rendered before them on the slide. `renderBlock` keeps the SVG itself\n  `pointer-events: none` and adds an invisible thick hit-line on top of\n  the visible arrow stroke so clicking near the line still selects it.\n- Alignment snaps while dragging are implemented in `attachBlockInteractions`\n  via three helpers: `computeSnapTargets` (gathers x/y edges + centers from\n  every other non-arrow block on the slide, plus slide bounds + center),\n  `applySnap` (independently snaps each axis to the nearest target within\n  `SNAP_THRESHOLD` slide-units), and `drawSnapGuides` (renders thin orange\n  guide lines into a `[data-snap-layer]` div inside the slide frame). The\n  snap layer is torn down on `pointerup`. Blocks without explicit `w`/`h`\n  (titles, pills, etc.) are measured via `offsetWidth/Height` so their\n  rendered bounding box still participates correctly.\n- Slide thumbnails in the left panel are produced by `renderSlideThumbnail`,\n  which calls the normal `renderSlide` with `editMode` temporarily forced\n  off and wraps the resulting 1200×675 frame in an `overflow:hidden`\n  container scaled with `transform: scale(width / 1200)`.\n- The slide search at the top of the left panel is built once at shell\n  mount (`buildSlideSearchHeader`) and persists across re-renders, so\n  typing into it never steals focus. `renderSlideList` reads\n  `slideSearch` and walks `filteredSlideIndices()` to decide what to\n  show. Drag-to-reorder is disabled when a filter is active since the\n  visible positions don't map 1:1 to absolute deck indices.\n- Reordering uses native HTML5 drag-and-drop (`draggable=\"true\"` +\n  `dragstart`/`dragover`/`drop`). A floating 2px orange line shows the\n  drop position; the actual move is done optimistically on the client and\n  then sent to the server via `gadget.moveSlide`.\n- The sandbox prohibits modal dialogs and external fetches; all asset\n  data must be inlined.\n- The server still has a `resetAll` RPC method — it's no longer exposed\n  in the UI but remains for use from the `GADGET` binding if you ever\n  need to nuke the deck.\n\n## Export formats\n\nThe deck supports **HTML**, **PDF**, and **PowerPoint (`.pptx`)** exports. HTML and PDF use the\nbrowser print renderer. PowerPoint export runs on the server and creates a conventional OPC /\nPresentationML package through `@gadgets/bundled-blueprints/libraries/pptx/server`. The build inlines\nthat library into `server.js`; there is no separately shipped `pptx.js` or runtime package dependency.\n\nText, cards, boxes, pills, basic shapes, dividers, arrows, and the two brand marks become editable\nnative PowerPoint objects rather than a screenshot. `ExportHandler` passes the original deck to\n`deckToPptx(deck, adaptBlock)`, with an adapter that expands each `logo` into a text wordmark and\nellipse accent dot. Other repository-authored TypeScript blueprints can import the same renderer\nand supply their own adapters. Source block order remains the shape z-order.\n\nAuthored quotas are checked before adaptation: 500 slides, 1,000 blocks per slide, and 10,000 blocks\nper deck. Adapted output has separate bounds of 2,000 blocks per slide and 20,000 per deck, so a\nlogo's accent dot does not consume another authored block. Text and image budgets still apply.\n\nThe starter chart, Edit/agent icons, and bottom brand bars use native blocks and survive export.\nThe orange cover treatment exports as native objects; SVG blocks and dot-grid backgrounds are omitted.\n\nThe 1200 x 675 canvas maps to standard widescreen PowerPoint at 12,192,000 x 6,858,000 EMU. The\nrenderer asks consumers to grow auto-height blocks. Cards budget text boxes and gaps within their\nauthored padded height, then ask consumers to shrink the text to fit rather than match browser\nclipping. PNG and JPEG data URLs are embedded after signature, dimension, and resource limit checks.\nImage corner radii apply when the picture fills its block, including matching-aspect `contain`\nimages; letterboxed pictures remain rectangular. Remote raster images become visible placeholders.\nSVG image data URLs and `svg` blocks are omitted entirely.\n\n","isInternal":false,"tokens":5331,"sizeBytes":21475},{"name":"README.md","path":"packages/bundled-blueprints/libraries/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/bundled-blueprints/libraries/README.md","title":"libraries Documentation","category":"plugin-manifest","format":"markdown","content":"# Gadget libraries\n\nShared code the bundled blueprints import instead of carrying their own copies. A blueprint's\n`client.ts` and `server.ts` import a library by this package's name and the subpath its\n`package.json` exports, `@gadgets/bundled-blueprints/libraries/<name>/client` and\n`.../<name>/server`; the blueprint build (`../src/files.ts`) resolves each to the library's entry\nhere and inlines what the entry uses into the `client.js` / `server.js` the blueprint ships, the way\nit inlines a blueprint's own `lib/` modules. The blueprint stays self-contained, and a gadget created from\nit carries its own copy of the library as of its creation; nothing resolves the package name\nat runtime, and a gadget the agent writes cannot import one.\n\nNothing here is deployed on its own. The package name is how tsc, vitest and an editor resolve the\nimport, through `exports`, with no alias or `paths` block; the build resolves the same name to this\ndirectory itself, so a blueprint tree with no `node_modules` above it builds too.\n\n## Layout\n\n```\n<name>/\n  client.ts        entry of .../libraries/<name>/client, inlined into a blueprint's client.js\n  server.ts        entry of .../libraries/<name>/server, inlined into a blueprint's server.js\n  src/**           the modules the two entries import; never shipped on their own\n  __tests__/**     vitest, jsdom by default, `// @vitest-environment node` for a pure module;\n                   `server.test.ts` / `<topic>.server.test.ts` for a test of the server side\n  README.md        what the library does and how it is put together\n```\n\nA library may import another (`@gadgets/bundled-blueprints/libraries/ui/client` from a library built\non it, say); the blueprint build resolves that import the same way. A client entry may not import a\nlibrary's server side, and the build rejects a blueprint that does: it would drag a Durable Object\ninto the iframe. A blueprint imports its own files by relative path and a library by package\nsubpath; everything else the bundle inlines fails the build -- a relative or absolute path into\n`libraries/` (`../../../libraries/ui/src/dom.ts`), a library's `src/` module by package path, the\npackage root, or a bare specifier some `node_modules` above the blueprint happens to satisfy -- so\nthe exported subpath is the only door.\n\n## Rules for a library\n\n- **es2022.** A blueprint's bundles target `es2022`, and the gadget loader's `compatibilityDate` is\n  `\"2026-02-01\"` (`loadGadgetWorker` in overseer.ts). A library that needs a newer runtime feature\n  bumps that date for every gadget, and says so in its README.\n- **No npm.** What a library imports is inlined into a blueprint's files, which nothing audits\n  afterwards, so the blueprint build rejects an input from `node_modules`. A library is written\n  against the platform alone, like a gadget.\n- **Readable.** The inlined code is what the agent reads and edits in an instantiated gadget. The\n  bundle is not minified, so a library's names and structure survive into it and should read well\n  on their own; its comments do not survive, because esbuild drops ordinary comments whatever the\n  minify settings, so the doc comments in `src/` are for this repository's readers.\n- **Lint applies.** Unlike a blueprint's `files/`, which the repo's lint ignores as user-authored\n  gadget source, this is platform code and is checked like the rest of the repo.\n\n## Tests\n\n```\npnpm --filter @gadgets/bundled-blueprints test:run   # the libraries' suites, with the blueprints'\nvp run -F @gadgets/bundled-blueprints build          # the type-check programs\n```\n\nThe package's `tsconfig.client.json` checks each client entry under the DOM lib,\n`tsconfig.server.json` each server entry under the Workers types, `tsconfig.tests.json` the tests\nunder DOM and Node types and `tsconfig.server-tests.json` the server-side tests under Workers and\nNode types; a `src/` module is checked under whichever of those imports it, which is what keeps a\nDurable Object from seeing `document` and iframe code from importing `cloudflare:workers`. A server\ntest runs against a stub of `cloudflare:workers` that the package's vitest config aliases in.\n\n## Libraries\n\n- [`ui`](ui/README.md) -- DOM helpers for document-style gadgets: the `el` builder, SVG icons and\n  toolbar controls, the in-page prompt, the save-status dot, relative time, and image reading and\n  downscaling. Client-only.\n- [`sync`](sync/README.md) -- collaboration plumbing: on the server a mutation queue, a subscriber\n  registry with presence seeding and broadcast, and versioned upserts; on the client a save scheduler\n  with retry backoff, a presence roster and a subscribe helper. The Docs, Sheets and Slides\n  blueprints build on it.\n- `zip` -- a server-only streaming ZIP32 writer shared by the Sheets XLSX exporter and PPTX library.\n- `pptx` -- a server-only PresentationML renderer for block-based slide decks, inlined into each\n  importing blueprint's `server.js`, not shipped as a separate `pptx.js`. `deckToPptx(deck, adaptBlock)`\n  validates authored quotas before a per-block adapter replaces brand-specific blocks; adapted\n  output is bounded separately. Installed gadgets cannot resolve these package imports at runtime.\n","isInternal":false,"tokens":1238,"sizeBytes":5207},{"name":"README.md","path":"packages/bundled-blueprints/libraries/sync/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/bundled-blueprints/libraries/sync/README.md","title":"sync Documentation","category":"plugin-manifest","format":"markdown","content":"# `sync` — collaboration plumbing\n\nEvery collaborative gadget here has the same skeleton: one Durable Object owns the authoritative\nstate and many browsers edit it live -- a mutation queue in the object, a set of subscriber stubs\nit broadcasts to, presence seeded on join and dropped on leave, per-item optimistic concurrency,\nand in the browser a debounced save loop with retry, a throttled presence heartbeat and an\n`RpcTarget` for the callbacks. This library is that skeleton once, with none of the flesh: it knows\nnothing about blocks, cells or slides, and a gadget keeps its own model, protocol, DOM and export\nformats on top of it.\n\n## Server (`@gadgets/bundled-blueprints/libraries/sync/server`)\n\n- **`MutationQueue`** -- `run(work)` chains mutations so that each loads the state the previous\n  one stored, whatever order their RPCs interleave in; a rejection reaches its caller alone.\n- **`SubscriberRegistry<Callbacks, Info>`** -- `add(subscriber, info)` `dup()`s the stub the RPC\n  layer delivered, registers `onRpcBroken` cleanup and returns the kept handle; `broadcast(send)`\n  delivers to everyone at once and never waits: a failing subscriber is dropped and disposed\n  (never failing the mutation), a hung one holds up nothing but itself, and a callback may call back\n  into the object -- read the document, queue a mutation -- without deadlocking on the mutation that\n  is telling it about the last one; deliveries are issued synchronously in call order, so each\n  subscriber hears events in order. `remove(handle)`, `has(handle)`, `members()`, `size`. Given\n  `PresenceHooks` -- `join(subscriber, who)` and `leave(subscriber, who)` in the gadget's own\n  callback vocabulary -- it seeds a newcomer with everyone already here (all at once; one that\n  fails a seed is dropped, and its leave announced, since it was a member from the moment it was\n  added), announces the newcomer to all, and announces whoever drops out. A gadget with no presence passes none and gets a fan-out.\n- **`applyVersioned(items, batch, options?)`** -- the per-item concurrency rule over any\n  `{ id, version }`: an upsert whose `baseVersion` is stale is rejected with the authoritative\n  item, one for a deleted item is rejected as `missing` (the client may re-create it with\n  `baseVersion` 0), an identical upsert is no change, deletions are version-checked, and accepted\n  items take the next version. Returns the new item map, `accepted`, `deletedIds`, `conflicts`,\n  `changed` and a `status`; `operationStatus(changed, conflicts)` recomputes the status for a\n  gadget whose batch also carries ordering or a title. `normalizeBaseVersion` reads a base version\n  off the wire.\n- **`normalizeCollaborator`** bounds what a client says about itself before it is repeated.\n\nThe entry imports nothing from `cloudflare:workers`: the stubs are whatever the gadget's own\n`subscribe` receives, seen through the structural `SubscriberStub` (`dup`, `onRpcBroken`, the\ndisposer).\n\n## Client (`@gadgets/bundled-blueprints/libraries/sync/client`)\n\n- **`SaveScheduler`** -- `schedule(delay?)` debounces (`DEBOUNCE_MS`), `flush()` serializes and\n  a flush during a save folds into one more. The gadget supplies `save()`, which sends what is\n  dirty and answers `\"saved\"`, `\"conflict\"` (rebased, re-sent at once) or `\"pending\"` (a draft\n  waits on something else; the gadget schedules again), and `isDirty()`. A failed save is retried\n  after `retryDelay(failures)`: `RETRY_BASE_MS` doubling to `RETRY_MAX_MS`, reset by a success,\n  with the failure left on the status line until the retry starts. Statuses (`SaveStatus`) are\n  `saving`, `saved`, `conflict`, `offline`, with `synced` left for the gadget to report on a live\n  update. `readOnly` makes it a no-op.\n- **`PresenceRoster<Cursor>`** -- `apply(event)` takes join, cursor and leave events (ignoring the\n  client's own), keeping a position through a re-join; `people()`, `entries()`, `get(clientId)`;\n  `expire(now?)` drops anyone silent for `STALE_MS`. `Cursor` is the gadget's position type,\n  carried unread.\n- **`PresenceReporter<Update>`** -- `schedule()` sends at most once per `THROTTLE_MS`, `sendNow()`\n  sends at once, `startHeartbeat(onBeat?)` re-sends every `HEARTBEAT_MS` and then runs the\n  gadget's tick (expire the roster, redraw), returning the stop function.\n- **`createSubscriber(RpcTarget, callbacks)`** -- an instance of the host's `RpcTarget` whose\n  prototype carries each callback, since the RPC layer exposes prototype methods and never own\n  properties. The class comes from the gadget's bootstrap, which a library cannot import, so the\n  gadget passes it in the way it passes its `gadget` stub.\n- **`collaboratorFor(clientId)`** -- the guest identity a tab declares for itself.\n\nNothing on the client side touches the DOM; every module runs in Node under its tests.\n\n## Using it\n\nA gadget keeps its RPC surface and its stored shape; the library implements the loop. On the\nserver, every mutation runs through `queue.run`, one registry with hooks that build the gadget's own\n`presence` events holds the subscribers and broadcasts to them, and `applyVersioned` applies the\nupsert/delete rule with the gadget's sanitizer in front and its ordering after. In the browser, a\n`SaveScheduler` whose `save()` is the gadget's payload builder drives saving, a roster holds the\ncollaborators, a reporter sends presence and the heartbeat, and `createSubscriber` builds the\ncallbacks class. Each part stands alone; a gadget may use any subset.\n\n## Tests\n\n`__tests__/` runs in Node (`// @vitest-environment node`): one suite per module, plus\n`server.test.ts`, which assembles the server entry into the smallest gadget and drives it with\nstubs shaped like the RPC layer's.\n","isInternal":false,"tokens":1389,"sizeBytes":5718},{"name":"README.md","path":"packages/bundled-blueprints/libraries/ui/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/bundled-blueprints/libraries/ui/README.md","title":"ui Documentation","category":"plugin-manifest","format":"markdown","content":"# `ui` — DOM helpers for document-style gadgets\n\nThe Docs, Sheets and Slides blueprints share the same few helpers: an element builder, an SVG icon\nfactory, toolbar buttons and a dropdown, an in-page prompt (the sandboxed iframe blocks\n`window.prompt`), a save-status dot, relative timestamps, and the reading and downscaling of a\npasted image. This library is those helpers once, imported as\n`@gadgets/bundled-blueprints/libraries/ui/client`. It is DOM-only: nothing here talks RPC\nor touches storage, and nothing here knows what a click means -- every control takes the gadget's\naction as an argument.\n\n`@gadgets/bundled-blueprints/libraries/ui/server` exists so that every library has both entries and a\nblueprint's server may import any of them uniformly. It exports one flag, `clientOnly`, and nothing\nelse.\n\n## What it exports\n\n| Export | What it is |\n| --- | --- |\n| `el(tag, props?, children?)` | Element builder. `class`, `text`, `html`, a `style` object and `on*` listeners are the conveniences; `null`/`undefined`/`false` props are skipped, `true` is an empty attribute; children are nodes, strings, numbers or nothing, singly or in an array. |\n| `icon(paths)` | The 24-unit stroked SVG the toolbars draw, `aria-hidden`. |\n| `ICONS` | Path markup for the icons at least two gadgets share (undo, redo, bold, italic, underline, strike, textcolor, the three alignments, ul, ol, link, image, clear). A gadget spreads its own over it. |\n| `iconBtn(paths, title, onClick, label?)`, `segBtn(paths, title, onClick)` | `button.icon-btn` / `button.seg-btn`, `type=button`, titled and `aria-label`led, mousedown prevented so the editor's selection survives; `label` shows text instead of an icon. |\n| `group(prio, items, first?)` | `div.tgroup[.p1/.p2/.p3]` led by a `div.tdiv` divider unless `first`. |\n| `colorBtn(paths, title, defaultColor, onChange)` | `div.color-btn`: icon, `span.bar` swatch, native colour input; a pick recolours the bar and calls `onChange(hex)`. |\n| `customSelect({className?, title?, options, value, showUnlistedValue?, onChange})` | The toolbar dropdown: `button.cselect` plus a `div.cmenu` appended to the body while open; options may carry `style` (docs' font preview), `ex` (sheets' format example) or be `{sep: true}`. A value may be a string or a number, matched and reported as a string. Returns `{el, setValue, getValue}`. |\n| `promptInline(message, initial?, placeholder \\| options?)` | The dialog: resolves the text on OK/Enter, `null` on Cancel/Escape/backdrop. Options: `placeholder`, `okLabel`. |\n| `PROMPT_STYLES` | CSS for the dialog's classes, scoped under `.prompt-overlay`/`.prompt-card`, for a gadget whose stylesheet has none. |\n| `statusIndicator({kind?, text?, title?})` | `div.status` > `span.dot.<kind>` + text; `set(kind, text)` is a no-op when nothing changed. |\n| `relativeTime(epochMs, now?)` | `just now`, `3 min ago`, `2 h ago`, `5 d ago`, then the locale date. |\n| `prepareImage(file, options?)` | Data URL within `maxDimension` (1600px) and `maxDataUrlLength` (1.4M chars): a GIF within `maxGifDataUrlLength` (2.7M chars, about 2 MB) is kept animated, everything else becomes WebP (JPEG where WebP cannot be encoded) at falling quality, then falling size. `alt` defaults to `altFromFileName`. |\n| `readFileAsDataURL(file)`, `loadImage(src)` | Its two steps, for a gadget that wants one of them. |\n| `isImageFile`, `imageFilesFrom(transfer)`, `IMAGE_TYPES`, `DEFAULT_IMAGE_LIMITS`, `altFromFileName` | The rest of the image module. |\n\nEvery element is styled by the gadget's own stylesheet through the class names above; the library\nships no CSS but `PROMPT_STYLES`.\n\n## Behaviour worth knowing\n\n- **`el`** has no `data` prop; write a `data-x` attribute. `text` sets `textContent` before the\n  children are appended, so both may be given.\n- **`iconBtn`/`segBtn`** prevent mousedown so the editor's selection survives the click;\n  **`customSelect`**'s button does not, and a gadget whose selection has to survive it adds that\n  listener to the returned element, as Docs does on its colour button. The dropdown's label for a\n  value no option carries is the first choice's, or the value itself with `showUnlistedValue`\n  (Docs' font size, where a heading's size is off the list).\n- **`promptInline`**'s message is text, not markup.\n- **`prepareImage`** tries four encoding qualities before it shrinks the image, and keeps a GIF\n  animated only while its data URL fits `maxGifDataUrlLength`. A gadget with its own conversion\n  (Slides passes SVG through, keeps PNG as PNG and falls back to the original on any failure) uses\n  `readFileAsDataURL` and `loadImage` and does the rest itself.\n- **`relativeTime`** has no caller among the bundled blueprints yet.\n\n## Tests\n\n`__tests__/` covers every export in jsdom (`time.test.ts` in node). jsdom decodes no images and\ndraws on no canvas, so `images.test.ts` stands in an `Image` that reports the size written in its\nsource and a canvas whose data URL is as long as the requested quality and pixel count say.\n","isInternal":false,"tokens":1329,"sizeBytes":5011},{"name":"README.md","path":"packages/bundled-blueprints/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/bundled-blueprints/README.md","title":"bundled-blueprints Documentation","category":"plugin-manifest","format":"markdown","content":"# Bundled blueprints\n\nThis package holds the blueprints that ship with this repo, the gadget libraries they import, and\nthe build that turns a blueprint directory into the files the Workshop backend installs. A fresh\ndeployment installs the blueprints into BLUEPRINTS KV and BLUEPRINT_CONTENT R2 on its first `/api`\nrequest, after which they are ordinary blueprints, and promotes them as its standard output formats.\n\"Bundled\" is what this package holds; \"format\" is a curation state an admin controls at runtime --\na bundled blueprint can be taken out of the formats menu, and an unbundled one promoted into it.\n\nNothing here is deployed on its own. The Workshop backend's `scripts/build-bundled-blueprints.ts`\nimports `src/` to generate the gitignored `src/generated/bundled-blueprints.ts` it compiles the\nblueprints into, and `pnpm import:bundled-blueprint` (the same package) writes into `blueprints/`.\n\n## Layout\n\n```\nblueprints/<name>/     one bundled blueprint, committed as reviewable source\n  blueprint.json       install ID, presentation, provenance, bindings, version, revision\n  files/               the gadget's code; may contain nested directories\n    README.md\n    client.ts          import { el } from \"@gadgets/bundled-blueprints/libraries/ui/client\"\n    server.ts          import { MutationQueue } from \".../libraries/sync/server\"\n    lib/protocol.ts    the document, operation and RPC types both sides share\n  __tests__/           vitest, repo-only; never part of the blueprint\nlibraries/<name>/      a gadget library: client.ts, server.ts, src/**, __tests__/** (see libraries/README.md)\nsrc/                   the build: source and archive reader (files.ts), manifest parser\n                       (manifest.ts), module generator (generate.ts); index.ts is what the backend imports\n__tests__/             the build's own tests\n```\n\n`files/` is the gadget's code. `blueprint.json` contains its install ID, presentation, provenance,\nbindings, blueprint `version`, and bundled `revision`. The build embeds the manifest and the files,\nas built, in the generated Worker module. No binary archive is committed, and none is built.\n\nInstalling a blueprint makes a git commit of its files, as publishing one from a Workshop does, and\nstores it as a packfile. The commit has no parent and a fixed author, date and message, so it\ndepends on the files and nothing else: every deployment that installs the same files installs the\nsame commit, and a blueprint whose files have not changed is the same commit however often it is\ninstalled. A bundled blueprint's versions are not chained to one another the way a published\nblueprint's releases are: each is a commit on its own.\n\n### Libraries\n\nA blueprint may import the shared **gadget libraries** in `libraries/` (see [its\nREADME](libraries/README.md)) by this package's name and the subpath its `package.json` exports:\n`@gadgets/bundled-blueprints/libraries/<name>/client` from its client, `.../<name>/server` from its\nserver. The build resolves each to the library's entry and inlines what the entry uses into the\nshipped `client.js` / `server.js`, as it does a `lib/` module, so the blueprint stays\nself-contained and a gadget created from it carries its own copy of the library as of its\ninstantiation. A client may not import a library's server side (it would drag a Durable Object into\nthe iframe), and a library, reached by that subpath, is the one thing an import may reach outside\n`files/` for. The importer has to be TypeScript: a JavaScript module ships as written, with nothing\nat runtime to resolve the package name against, so it cannot import a library, and the build rejects\nthe attempt. The Docs, Sheets and Slides blueprints are built on the `ui` and `sync` libraries, with\ntheir own domain code in `files/`.\n\n### TypeScript sources\n\n`files/` may be written in TypeScript: `client.ts` and `server.ts` are the entry points, and\n`lib/**/*.ts` holds the modules they import (by on-disk name, `./lib/protocol.ts`). The build bundles\neach entry with its imports into the `client.js` / `server.js` the blueprint ships -- readable\nrather than minified -- so the installed gadget, and the agent that later edits it, see exactly one\nJavaScript file per side, the same as for a blueprint written in plain JavaScript. Those `lib/`\nmodules are build input only and are not stored, and `.d.ts` files are dropped. The bundled Docs,\nSheets and Slides blueprints are written this way, each with a `lib/protocol.ts` holding the types\nits client and server share (imported type-only, so nothing of it ships).\n\nEverything else under `files/` (the README, assets) passes through unchanged, and may be imported\nfor its contents where the bundler can inline it: JSON is; a stylesheet is not, and\n`import \"./styles.css\"` fails the build (a gadget carries its CSS in the module that injects it). A file a bundle inlined is still shipped, because\nonly TypeScript is build input. A blueprint is written in TypeScript or in JavaScript, not both: a\n`.js` module in a tree that holds `.ts` is rejected, since it would ship as written beside bundles\nit cannot share code with. Non-module files pass through either way.\n\n`cloudflare:workers` is the only import left for the runtime to resolve, and only on the server:\nthe client is loaded as an ES module in a sandboxed iframe with nothing to resolve a bare import\nagainst, and the server as a Durable Object whose module map holds the gadget's own files and whose\nloader supplies no other `cloudflare:` module. Everything else a blueprint imports must be a file it\nowns or a library, so `import \"yjs\"` is a build error rather than a module that goes missing inside\nthe sandbox, and tree-shaking annotations are ignored, so a `\"sideEffects\": false` in an enclosing\npackage.json cannot drop a side-effect-only import from the bundle.\n\nThe build rejects a tree that would otherwise ship something other than what was written: a\nJavaScript module in a TypeScript blueprint, a `package.json` in a TypeScript blueprint (its `browser`\nfield or `imports` map would steer the bundler's resolution), an import of one of the blueprint's\nmodules that the bundler resolved to something other than the module TypeScript resolves it to (a\n`package.json` above the blueprint steering it, or an extensionless file the bundler takes first), a\n`.ts` file outside the entry/`lib/`\nlayout, TypeScript spelled `.tsx`/`.mts`/`.cts`, declarations included (neither runtime has a loader\nfor it), a `lib/` module\nno entry imports, an import that reaches outside `files/` by any path other than a library's exported\nsubpath (a relative or absolute path into `libraries/`, a `src/` module, the package root, a bare\nspecifier some `node_modules` resolves), a library import of the wrong side or of a library that does\nnot exist, a\ndynamic `import()` or `require()` of anything but a string literal, refused before the bundler runs\n(it would leave a computed path unchecked, and expand a template literal or concatenation into every\nfile the pattern matches, wherever that reaches), a module that binds the name `require` (the scan\nreads a bare `require(...)` as the module loader, as the bundler does only while the name is\nunbound), a generated `client.js`/`server.js` that collides\nwith a file or directory already in the tree, or a shipped JavaScript module that imports a library,\nwhich only the bundle can inline. Imports are read from each module's syntax tree with the\nTypeScript compiler, so a comment or a string can neither masquerade as one nor hide one.\n\n### Type checks and tests\n\n```\npnpm exec vp run -F @gadgets/bundled-blueprints build   # the five type-check programs\npnpm --filter @gadgets/bundled-blueprints test:run      # the blueprints', libraries' and build's tests\npnpm --filter @gadgets/bundled-blueprints test:watch\n```\n\n`pnpm build` type-checks all of it, through one config per set of globals -- the sets must not see\neach other, since a Durable Object has no `document`, iframe code cannot import\n`cloudflare:workers`, and neither has Node's `fs`:\n\n| config | covers | globals |\n| --- | --- | --- |\n| `tsconfig.client.json` | every `client.ts` (blueprints and libraries) and what it imports | DOM |\n| `tsconfig.server.json` | every `server.ts` and what it imports | Workers |\n| `tsconfig.tests.json` | the blueprints' and libraries' `__tests__/` and what they import | DOM + Node |\n| `tsconfig.server-tests.json` | `__tests__/server.test.ts` and `__tests__/*.server.test.ts`, the tests of a server side | Workers + Node |\n| `tsconfig.node.json` | `src/` and `__tests__/`, the build and its tests | Node |\n\nEach follows its entries' imports, so a `lib/` or `src/` module is checked under the globals of\nwhichever side imports it, and a module both sides import under both -- which is what keeps a\nshared module honest without forcing a server-only one to compile against the DOM. A library import\nis this package referring to itself by name, which tsc resolves through the `exports` in\n`package.json` with no `paths` block, so a blueprint is checked against the real signatures it\nimports.\n\n### What the build does not check\n\nThe bundler and the type check are kept in step by refusing what would let them diverge, not by\nsupporting every way TypeScript can be written. Write a blueprint the plain way -- `.ts` modules\nunder `files/`, relative imports spelled `./name.ts` or `./lib/name.ts`, libraries by the package\nsubpath -- and stay inside these limits, which the build does not enforce:\n\n- A `/// <reference types=\"…\" />`, `lib=\"…\"` or `path=\"…\"` directive loads types the side's tsconfig\n  deliberately leaves out (`types: []`, one `lib` per side), so `Buffer` type-checks in a client and\n  ships as a free global that throws in the iframe. Do not write one: a gadget module sees exactly\n  the globals its runtime supplies, and the type check is only as honest as that isolation.\n- A tree under `BUNDLED_BLUEPRINTS_DIR` is bundled but not type-checked; the programs above cover\n  this package's `blueprints/` and `libraries/` only. Type-check such a tree in its own workspace.\n- The comment naming each inlined library module is rewritten line by line, so the bundle is the\n  same wherever it is built; a template literal whose own line spells exactly such a path would be\n  rewritten with it.\n\nUnit tests of a blueprint's `lib/` modules, or of its server, live in the blueprint's `__tests__/`\nand run under `pnpm test` (jsdom by default; a pure module's or a server's test declares\n`// @vitest-environment node`). They import the module under test by on-disk name, e.g.\n`../files/lib/protocol.ts`, and load a fixture with `readFileSync`. A test of the server side is\nnamed `server.test.ts` or `<topic>.server.test.ts`, which is what puts it under the Workers types\nrather than the DOM's; at run time it gets `cloudflare:workers` as a stub of its base classes\n(`__tests__/stubs/cloudflare-workers.ts`), so a Durable Object can be constructed over in-memory\nstorage. The blueprints the build produces are still installed and inspected inside workerd by the\nWorkshop backend's suite. Only `blueprint.json` and `files/` are read by the build, so `__tests__/`\n(and anything else beside them) is repo-only and never part of the blueprint.\n\n`blueprintId` is the install key. Never change it after deployment: the new ID would install a\nsecond blueprint while the old one remained. (TODO: the bundled IDs keep the historical `format.`\nprefix -- `format.document`, `format.spreadsheet`, `format.slides` -- for that reason; renaming them\nneeds an install-time migration keyed on the old id.) `version` is the blueprint's published content\nversion, a counter for display: the content itself is stored under the commit of its files. The\nbuild fingerprints the files and the rest of the manifest, so direct edits under `files/` reinstall\nautomatically. `revision` remains an explicit reinstall trigger and is bumped by the importer.\n\n## Editing presentation\n\nEdit `blueprint.json` and rebuild. Changes to title, description, output, or author are included in\nthe install fingerprint and do not need a `revision` bump.\n\n## Updating code\n\nBuild the blueprint in a Workshop, export it, then import the export, from `packages/workshop-backend`:\n\n```\npnpm import:bundled-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document\n```\n\nThe importer replaces `files/` and leaves anything beside `blueprint.json` and `files/` alone,\nupdates archive-owned metadata (`created`, `version`, `lastUpdated`, and `bindings`), bumps\n`revision`, rebuilds the backend's `src/generated/bundled-blueprints.ts`, and reports changed files\nand bindings. An export the build rejects is refused before it replaces\nanything. Review the resulting source diff normally.\n\nAn export is a `.gadget` archive in one of two versions, and the importer reads both. Version 1\nholds a snapshot of the blueprint's files. Version 2, which a Workshop writes for anything published\nsince blueprints became git commits, holds a git packfile, and the importer has `git` unpack it.\nOnly the files are taken from either: the bundled blueprint's commit is made from them at install,\nnot copied from the export.\n\nAn export contains the bundled JavaScript, so importing one over a TypeScript blueprint replaces its\nsources with the built `client.js` / `server.js`. Edit a TypeScript blueprint in the repo instead,\nand use the Workshop only to try the result.\n\n## Adding a format\n\n```\npnpm import:bundled-blueprint ~/Downloads/Brief.gadget --new acme-brief\n```\n\nThis extracts the files and writes a valid scaffolded `blueprint.json`. Before deploying, replace\nthe scaffold description and review `output`. Prefer a generic `output.id` such as `document`; the\nOutputs page uses it to group related formats.\n\n## Shipping your own formats\n\n`BUNDLED_BLUEPRINTS_DIR` points the build at another directory in this same extracted layout. It is\nresolved against `packages/workshop-backend`, where the build runs (the variable used to be called\n`FORMAT_BLUEPRINTS_DIR`; that name is no longer read, so a deployment setting it builds the bundled\nset instead):\n\n```\nBUNDLED_BLUEPRINTS_DIR=../../acme-formats pnpm exec vp run build\n```\n\nThe named directory replaces this set rather than extending it. It can be empty to ship no bundled\nformats. The import command honors the same variable. Keeping deployment-owned formats outside this\nrepo avoids modifying it when it is consumed as a submodule.\n\nA `BUNDLED_BLUEPRINTS_DIR` tree may be written in TypeScript like the blueprints here, and the build\nbundles it the same way: a syntax error or an import that does not resolve fails the build, and it\nimports the gadget libraries by the same package name, which the build resolves to this package's\n`libraries/` without the tree having installed anything. It is not type-checked, though. This\npackage's `tsconfig.*.json` programs are static and cover only `blueprints/` and `libraries/`, so a\ntree elsewhere needs its own `tsc` run in the repository that owns it, with\n`@gadgets/bundled-blueprints` linked into that workspace the way `@gadgets/scripts` already must\nbe, so its editor and `tsc` resolve the same `exports`; or it can stay JavaScript.\n\nDirectories using the previous `<name>.gadget` plus `<name>.json` layout remain supported, so an\nexisting deployment can update this repo without coordinating a format conversion. Importing a new\nexport into one of those entries migrates that pair to the extracted layout automatically. That is\nalso the only way to update one: the layout holds version 1 archives, and the build refuses a\nversion 2 export dropped in a `<name>.gadget`'s place.\n\nAdministrators can also publish and promote ordinary blueprints at runtime instead of rebuilding a\ndeployment.\n","isInternal":false,"tokens":3670,"sizeBytes":15735},{"name":"README.md","path":"packages/gatekeeper-cloudflare/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-cloudflare/README.md","title":"gatekeeper-cloudflare Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper Cloudflare\n\nThis package provides Cloudflare OAuth integration for Gadgets. It serves three purposes:\n\n- **Sign-in:** when `cloudflare` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Cloudflare\" appears on the login page. The grant reads the account email (verified by Cloudflare,\n  via the `/user` API), which becomes the user's identity. Cloudflare sign-in also establishes the\n  persistent billing-only connection described below.\n- **AI Gateway billing:** when a user connects Cloudflare or signs in with it, the billing scopes are\n  requested and the connection persists. The Workshop then reads a usable access\n  token from it (`getUsableAccessToken`) to power the [AI Gateway billing](../../docs/ai-gateway-billing.md)\n  flow — reading the credit balance and routing BYOK inference through the account's default AI\n  Gateway.\n- **Workers Observability:** gadgets can receive read-only access to logs, events, invocations,\n  aggregate metrics, and traces either across an account or restricted to one Worker. Every result\n  is authorized as an observation, and Worker bindings inject an immutable service filter and\n  defensively discard foreign-service events. Distributed trace summaries are account-only because\n  their names, timing, services, and counts describe the whole cross-service trace; a Worker binding\n  can still retrieve its own events for a known trace ID.\n\nObservability connections request `workers-observability.read`. The OAuth client must allow that\nscope or Cloudflare will omit/reject it. Existing billing-only connections can add the grant when the\nuser first selects an observability resource. Cloudflare exposes account and Worker resource choices,\nbut both map to this one indivisible OAuth scope; resource bindings provide the finer capability\nboundary after connection.\n\nWorkers telemetry is retained by Cloudflare for at most seven days. Queries default to the last hour,\nand the Worker picker searches the full retention window. Suggested bindings are\n`CLOUDFLARE_OBSERVABILITY` for account access and `WORKER_OBSERVABILITY` for one Worker.\n\n### Sharing a gadget that reads telemetry\n\nA binding is not transferable. When a gadget with an observability binding is shared, each\ncollaborator is admitted only if **their own** connected Cloudflare account can read that resource\n(`addObserver` checks it against their credentials, not the owner's). A collaborator without access is\nrefused, and a verification that fails for any other reason — a 5xx, a transport error — is also a\nrefusal rather than an admission.\n\n### What a Worker binding trusts, and what it re-checks\n\nThe scope filter is prepended to every query, but a filter the provider *accepted* is not evidence it\n*applied* it — three separate behaviours here return wrong-but-plausible data with no error. So events\nare re-filtered on the way out, and a single foreign event is treated as proof the filter was dropped:\nthe provider's `count` is withheld (it would be a count of the whole account's matching telemetry) and\nthe event is logged at `error`. `statistics` is kept, because it describes what our query cost rather\nthan how much matched, and callers are told to read it for cost.\n\nThe re-check is deliberately not a hard failure. The events returned are filtered and therefore safe,\nand this provider has surprised us often enough that turning a hypothetical disclosure into a\nguaranteed outage would be the worse trade.\n\n`calculate()` is the exception, and knowingly so: an aggregate cannot be un-mixed, so there is no\nsecond line of defence to add. It rests entirely on the injected filter. The fix that would work —\ngrouping by `$metadata.service` and keeping this binding's own group, whose value *is* the correctly\nscoped answer even for a median — changes `limit` and `orderBy` semantics for every caller, so it is a\nfollow-up rather than a footnote.\n\n### Why discovery sometimes costs a query\n\nCloudflare's `telemetry/keys` and `telemetry/values` endpoints ignore the `filters` array they accept:\nverified against a live account, they answer for the whole account no matter what is passed. A\nWorker-scoped binding therefore cannot use them — they would disclose every field name and value in\nthe account. Whenever a discovery call has to be constrained (a Worker binding, or any caller-supplied\nfilter), the answer is derived instead from a filtered `telemetry/query` events sample, which the\nprovider does filter correctly. Unconstrained account-wide discovery still uses the cheap endpoints.\n\n> Unresolved: whether the `workers-observability.read` scope alone is accepted, or whether Cloudflare\n> requires the Workers Observability **Write** permission to read telemetry. This has not been\n> verified against a live token; if reads 403 on a correctly-scoped grant, that is the first thing to\n> check.\n\n### Two names for one field\n\nA log's own structured fields are returned nested under `source` but are **indexed under their bare\nname**: `logger.warn(msg, {event: \"x\"})` comes back as `source.event` and is queried as `event`.\nConfirmed both ways — `telemetry/keys` on a live account reports `event`/`component`/`level` and no\n`source.*` key at all, and the [Workers Logs\ndocs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) show\n`console.log({user_id: 123})` being filtered as `user_id`.\n\nThis matters because the provider accepts an unknown filter key, matches nothing, and returns an empty\npage with no error. So discovery reports indexed names (`observability-discovery.ts`), and\n`observabilityFieldKey` additionally accepts a `source.`-prefixed key as an alias wherever a caller\nnames a field — filters, `calculations`, `groupBys`, `listValues` — because copying a path out of a\n`listEvents` result is the obvious thing to do and used to fail silently.\n\n`openid` is intentionally **not** requested — the Cloudflare dashboard OAuth client isn't permitted\nthat scope; identity comes from the `/user` API (`user-details.read`).\n\n### Why an error's text never reaches the log\n\n`summarizeFilter` deliberately keeps filter *values* out of the audit trail — they are caller text.\nBut a provider error message can quote that same value straight back, so logging the message would\nreadmit through the error path exactly what the audit path excludes. `CloudflareObservabilityApiError`\ntherefore carries Cloudflare's numeric `codes` alongside the message: the request log names the\ncodes, and the message travels only to the caller who caused it.\n\nThe codes are not the discriminator, though — Cloudflare can return a message with no numeric code\nat all, so the error records separately whether the message is *its* or *ours*, and only ours is\nlogged. A provider failure carrying no codes is therefore logged as a bare status, which is the\nfail-closed answer: the status still says what happened without quoting anyone's filter back.\n\n### Listing accounts is a walk, not a request\n\n`/accounts` is paginated and defaults to **20** per page, so a single GET silently returns a truncated\nlist — an account past the first page simply cannot be picked, with nothing to indicate why. The\naccount picker's substring match also stays client-side: `name` is the only documented server-side\nfilter and whether it matches exactly or by substring is not specified, so pushing it down would trade\na visible truncation for an invisible one.\n\n## Setting Up Cloudflare OAuth Credentials\n\nYou need a Cloudflare dashboard OAuth client (client id + secret). The dashboard OAuth endpoints and\nscopes are hardcoded in `src/oauth.ts`, so you only configure the client id/secret and register the\nredirect URI. Ensure the client's scope allowlist includes `workers-observability.read` when this\ndeployment offers Workers Observability resources.\n\n### Step 1: Register the redirect URI\n\nThe gatekeeper's OAuth redirect URI is:\n\n```\n${BASE_URL}/oauth\n```\n\nwhere `BASE_URL` defaults to `http://localhost:8787/gatekeeper/cloudflare` in dev — i.e. the full\nredirect URI is:\n\n```\nhttp://localhost:8787/gatekeeper/cloudflare/oauth\n```\n\nRegister **exactly** this (replace the host with your `PUBLIC_BASE_URL` when not running locally) as\nan allowed/pre-registered redirect URL on the Cloudflare OAuth client. If it isn't registered you'll\nget an `invalid_request` error: _\"the 'redirect_uri' parameter does not match any of the OAuth 2.0\nClient's pre-registered redirect urls.\"_\n\n### Step 2: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-cloudflare/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\nIn local dev, `run-dev-server.ts` will also seed these from `CLOUDFLARE_OAUTH_CLIENT_ID` /\n`CLOUDFLARE_OAUTH_CLIENT_SECRET` if you'd rather set them in the root `.dev.vars`. A per-package\n`.env` takes precedence and keeps the credential with the gatekeeper that uses it.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable Cloudflare sign-in / billing\n\nTo offer \"Continue with Cloudflare\" on the login page, add `cloudflare` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nThe order controls the order of the login buttons. For the AI Gateway billing / top-up flow, also\nset `ENABLE_CLOUDFLARE_LIMITS=true` (see [AI Gateway billing](../../docs/ai-gateway-billing.md)); a\nuser enables billing by connecting Cloudflare, which requests the billing scopes\n(`offline_access aig.read aig.run user-details.read account-settings.read`). Connecting all\nCloudflare gadget resources also requests `workers-observability.read`.\n\n### Step 4: Verify Setup\n\n1. Start the application in dev mode (see the root README.md).\n2. On the login page, click **Continue with Cloudflare**.\n3. A pop-up opens to the Cloudflare authorization page; approve it.\n4. The pop-up closes and you're signed in, identified by your Cloudflare account email.\n5. To use AI Gateway credits, open **Usage & billing** in settings and **Connect Cloudflare** (this\n   requests billing scopes only).\n\n## Troubleshooting\n\n### \"redirect_uri ... does not match any of the ... pre-registered redirect urls\"\n\nThe redirect URI isn't registered on the OAuth client. Register exactly\n`http://localhost:8787/gatekeeper/cloudflare/oauth` (or your `PUBLIC_BASE_URL` equivalent) — no\ntrailing slash, `http` not `https` for localhost.\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` / `CLIENT_SECRET` are missing. Ensure they're set (per-package `.env` or seeded from the\nroot `.dev.vars`), then restart the dev server.\n","isInternal":false,"tokens":2393,"sizeBytes":10684},{"name":"README.md","path":"packages/gatekeeper-confluence/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-confluence/README.md","title":"gatekeeper-confluence Documentation","category":"plugin-manifest","format":"markdown","content":"# Confluence (Cloud) gatekeeper\n\nConnects Gadgets to Atlassian Cloud [Confluence](https://www.atlassian.com/software/confluence)\n(`*.atlassian.net`) using OAuth 2.0 (3LO). It exposes spaces, pages, blog posts, comments, labels,\nand attachments through a capability-based Session API, with page/blog bodies converted to and from\nMarkdown. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_CONFLUENCE` binding.\n\nOnly Atlassian **Cloud** is supported; there is no Server / Data Center adapter.\n\n## What it provides\n\nThree connectable resource granularities:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| **Site** — search and open any space/page/blog post the account can access | `https://*.atlassian.net/wiki` | `ConfluenceSite` |\n| **Space** — list/search/create pages and blog posts within one space | `https://*.atlassian.net/wiki/spaces/:spaceKey` | `ConfluenceSpace` |\n| **Page or blog post** — read/edit the body, manage labels/comments/attachments, and (pages only) read/create child pages | `https://*.atlassian.net/wiki/spaces/:spaceKey/pages/:pageId/*` | `ConfluenceContent` |\n\nPages and blog posts share the `ConfluenceContent` session type (distinguished by\n`ContentMetadata.type`); child-page methods are page-only.\n\nA single account-level grant can span multiple sites. `getGatekeeperClassFor()` resolves a URL's\nhost to one of the connection's accessible sites (its cloud ID) — the one chokepoint enforcing site\naccess.\n\nSee `src/types.d.ts` for the full, agent-facing Session API.\n\n## Setting up an Atlassian OAuth 2.0 app\n\n1. Go to the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/) and\n   create an **OAuth 2.0 integration**.\n2. Add the **Confluence API** to the app and enable **both** scope families below (see\n   `CONFLUENCE_SCOPES` in `confluence-api.ts`). The console has separate **Granular scopes** and\n   **Classic scopes** tabs; this gatekeeper needs scopes from each because it uses the v2 API for\n   most operations and falls back to v1 for a few (search, label writes, attachment upload,\n   restore).\n\n   **Granular scopes** (authorize the v2 API — v2 doesn't accept classic scopes):\n   - `read:space:confluence`\n   - `read:page:confluence`, `write:page:confluence`, `delete:page:confluence`\n   - `read:blogpost:confluence`, `write:blogpost:confluence`, `delete:blogpost:confluence`\n   - `read:comment:confluence`, `write:comment:confluence`, `delete:comment:confluence`\n   - `read:label:confluence`, `write:label:confluence`\n   - `read:attachment:confluence`, `write:attachment:confluence`, `delete:attachment:confluence`\n   - `read:user:confluence`\n\n   **Classic scopes** (authorize the v1 fallbacks — v1 doesn't accept granular scopes):\n   - `search:confluence` (CQL search)\n   - `read:confluence-content.all`, `read:confluence-content.summary` (search results / content reads)\n   - `read:confluence-space.summary`\n   - `write:confluence-content` (label add/remove, restore-from-trash)\n   - `write:confluence-file` (attachment upload)\n   - `readonly:content.attachment:confluence` (attachment download)\n   - `read:confluence-user`\n\n   **Account-level:**\n   - `read:me` (authorizing user's identity)\n   - `offline_access` (required for refresh tokens)\n3. Under **Authorization**, set the callback URL to `<BASE_URL>/oauth`:\n   - `http://localhost:8787/gatekeeper/confluence/oauth` for local dev\n   - `<PUBLIC_BASE_URL>/gatekeeper/confluence/oauth` otherwise\n4. Copy the app's **Client ID** and **Secret**.\n\n> **API version:** this gatekeeper targets the Confluence **v2** REST API (`/wiki/api/v2/...`),\n> which Atlassian now requires (the v1 content/space endpoints have been removed) — and which is\n> why the **granular** scopes are needed. A few capabilities with no v2 equivalent (CQL search,\n> label add/remove, attachment upload, restore-from-trash) fall back to v1 endpoints, which need\n> the **classic** scopes, and degrade with a clear error if Atlassian has removed them too. The app\n> therefore must enable scopes from both the Granular and Classic tabs.\n\n## Configuring credentials\n\nFor local development, set these in the repo-root environment (e.g. `.dev.vars`) so the dev server\ninjects them into this Worker:\n\n```\nCONFLUENCE_CLIENT_ID=your-client-id\nCONFLUENCE_CLIENT_SECRET=your-client-secret\n```\n\n`run-dev-server.ts` maps `CONFLUENCE_CLIENT_ID` / `CONFLUENCE_CLIENT_SECRET` into the Worker's\n`CLIENT_ID` / `CLIENT_SECRET` vars. For production, set `CLIENT_ID` and `CLIENT_SECRET` as secrets\non the deployed Worker and set `BASE_URL` to the public gatekeeper URL.\n\n`providesAuth` is false: Confluence is a data connector, not a sign-in method. Wiring it up as a\n\"Sign in with Atlassian\" provider (using the verified `/me` email) is a possible fast follow.\n\n## How the connect flow works\n\n1. The user starts a connection; `connectAccount()` creates a `UserAccount` Durable Object and\n   returns a URL of the form `<BASE_URL>/<doId>/<nonce>`. The flow uses a two-phase nonce\n   (initiation → OAuth).\n2. Visiting that URL redirects to `auth.atlassian.com` to authorize the requested scopes.\n3. Atlassian redirects back to `<BASE_URL>/oauth`; the gatekeeper exchanges the code for access +\n   rotating refresh tokens, lists the account's accessible Confluence sites\n   (`/oauth/token/accessible-resources`), reads the authorizing user's identity (`/me`), and stores\n   them.\n4. Each connected resource routes API calls through\n   `https://api.atlassian.com/ex/confluence/<cloudId>/wiki/...`. Access tokens are short-lived and\n   refreshed automatically (proactively before expiry and again on a 401); each refresh persists\n   the **new** rotating refresh token.\n\nA connection is created either by pasting a Confluence URL into the chat, or from the Connections\nUI.\n\n## API\n\nPage/blog bodies are exchanged as **Markdown** (best-effort conversion to/from Confluence \"storage\nformat\" XHTML; see `confluence-markdown.ts`). Highlights:\n\n- `ConfluenceSite`: `listSpaces`, `getSpace`, `getContent`, `search` (text or raw CQL),\n  `getCurrentUser`\n- `ConfluenceSpace`: `listPages`, `listBlogPosts`, `getContent`, `search`, `createPage`,\n  `createBlogPost`\n- `ConfluenceContent`: `getMetadata`/`getContent`, `setContent`/`appendContent`/`setTitle`,\n  `listChildPages`/`createChildPage`, labels, comments, attachments\n  (`downloadAttachment` is capped at 16 KB), `trash`/`restore`\n\nEndpoint split: v2 for spaces, pages, blog posts, child pages, and reading comments/labels/\nattachments; v1 (with graceful degradation) for CQL search, label add/remove, attachment upload,\nand restore-from-trash.\n\n## Approvals, caching & simulation\n\nEvery read calls `ApprovalQueue.authorizeObservation()`; every side-effecting operation\n(create/edit page or blog post, comments, labels, attachment upload, trash/restore) is recorded as\na pending action and submitted via `submitAction()` — nothing reaches Confluence until the Overseer\ncalls `applyAction()`. Reads simulate pending (unapproved) writes so the agent can keep working\nbefore approval (provisional IDs for created content, body/title/label/comment overlays, trashed\nstate). `rejectAction()` / `revertAction()` discard or undo. Content responses are cached in DO\nstorage with a short TTL and invalidated on write. This logic lives in `confluence-actions.ts`.\n\n## Resource pickers\n\nThe Connections UI shows space / page-or-blog / site pickers supplied by\n`startResourceConfigurator()`. Pasting a resource URL — or an agent's\n`requestConnection(resourceUrl)` — also works and pre-fills the picker. For multi-site Atlassian\naccounts the space/page pickers search across **all** accessible sites (each option's value carries\nthe correct site), so pasting a URL works for any connected site too.\n\n## Notes and current limitations\n\n- Body conversion between Confluence storage format and Markdown is best-effort; unsupported macros\n  degrade to a labeled placeholder.\n- `author` / `lastUpdatedBy` expose only an `accountId` (the v2 content API returns IDs, not user\n  objects).\n- CQL search, label add/remove, attachment upload, and restore-from-trash rely on v1 endpoints; if\n  Atlassian removes them, those operations surface a clear \"not available\" error.\n\n## Development\n\n- `pnpm exec vp run -F @gadgets/confluence-gatekeeper build` — build the configurator UIs (`build:configurator`, which this task depends on) and type-check (`tsc`). A Vite+ task rather than a script, so `pnpm --filter` cannot see it.\n- `pnpm --filter @gadgets/confluence-gatekeeper test:run` — run unit tests (URL/CQL parsing, Markdown conversion, v2 converters, action simulation).\n- The Worker is run via the root `pnpm dev-server`, not directly.\n","isInternal":false,"tokens":2156,"sizeBytes":8719},{"name":"README.md","path":"packages/gatekeeper-context/app/skills/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-context/app/skills/README.md","title":"skills Documentation","category":"plugin-manifest","format":"markdown","content":"# Skills Navigator Direction and Compatibility\n\nThe target information model is deliberately flat: a collection contains atomic skills. Each skill\nencompasses its `SKILL.md` manifest and all related agentic standards files. Those files may be\nstored together in a skill directory, but the navigator presents the complete bundle as one skill,\nnot as a file hierarchy.\n\nSkill titles are presented as human-readable title case. Creation and rename accept that display\nform, then derive the lowercase, hyphen-separated `name` required by the Agent Skills specification\nfor the manifest metadata and directory name.\n\nOrganizational directories are being abandoned. The navigator does not let users create, rename, or\nmove them, and new product behavior must not depend on adding more directory hierarchy. Directory\nnodes remain only to preserve collections that already use older nested layouts.\n\nSelecting a skill still opens the existing collection editor, which remains the general-purpose\ndocument editing surface. Every reader can also open a collection from its row menu, so collections\nthat contain only ordinary documents remain reachable.\n\n## Legacy directories\n\nExisting organizational directories remain usable for backwards compatibility: skills can be\ncreated in them and moved into, out of, or between them, and the directories themselves can be\ndeleted. They cannot be created, renamed, or moved through the navigator, because the forward model\ntreats only skill bundles as movable units.\n\nLegacy directories can also be deleted. Deleting one removes its complete document subtree,\nincluding files the skills-only projection does not display. None of these compatibility behaviors\nmake directories part of the forward-looking data model: no operation can create another\norganizational directory.\n\n## Mutation boundaries\n\n- Users can create, rename, move, and delete complete skill bundles, but cannot create, rename, or\n  move organizational directories.\n- Skill mutations use the skill-specific RPC methods rather than composing generic document calls in\n  the client. Each operation validates the manifest and applies all storage changes atomically.\n- Renaming or moving a non-root skill operates on its complete directory, preserving supporting\n  files. Destination conflicts fail rather than overwrite existing documents.\n- A root-level `SKILL.md` is supported, but it has no exclusive directory subtree: moving, renaming,\n  or deleting it affects only that manifest. Treating every other root document as its support file\n  would risk moving or deleting unrelated skills and documents.\n- A destination must be the collection root or an existing legacy directory outside every skill\n  bundle. Skills cannot be nested inside other skills, even if an RPC caller supplies a path the UI\n  would not offer.\n\n## Permissions and sources\n\nCollection actions follow `getContextCollectionPermissions`. Skill and directory actions are offered\nonly when the account can edit the collection and the collection is web-backed. Git-backed\ncollections remain read-only at the skill level, but editors, owners, and admins can still edit\ntheir collection settings, manage Git tokens, and refresh them. Sharing is offered on editable\nprivate collections, and deleting the whole collection only to owners and admins. Viewers keep\nnavigation, previews, downloads, and opening the collection. Otherwise read-only collections expose\nno mutation actions and are not offered as Add Skill targets. If loading a collection's documents\nor permissions fails, that collection fails closed as non-writable in the current view.\n\nCollection labels come from the viewer's live membership: deployment-public collections show\nOrganization, an owned collection shows Private or, once anyone else can read it, Shared, and a\ncollection someone else shared shows Shared with you and the viewer's role. Collection titles need\nnot be unique. Labels describe access, not identity: same-titled collections can also have identical\nlabels. UI selection and API operations use collection IDs rather than titles.\n\n## Sharing\n\nThe Share dialog adds people through the Workshop's host-owned person picker\n(`useSelectUsers()` in `bridge.ts`). Each picker session gets its own receiver that fixes the\ncollection and the role chosen when the picker opened, and shares with each picked person as soon as\nthey are delivered; a backend refusal rejects that delivery, so the picker shows the message in a\nstatus notice that names the person. Cancelling the picker does not undo shares already delivered.\nStopping the wait is not a sharing error and does not undo a delivery already running. The dialog\nreloads sharing and permissions when the picker closes and again when a late delivery settles,\nincluding rejected deliveries that may have committed a grant before cleanup failed. A delivery that\nsettles after the Share dialog closes also refreshes the parent collection metadata or listing.\n\nMutation controls remain disabled until refreshed sharing and permissions arrive together. If that\nreload fails, the previous rows remain visible for context but cannot be used to mutate access;\n**Retry** must successfully refresh them first. Backend cleanup errors remain visible independently\nof reload failures.\n\n`Share “<collection title>”` starts with an **Add users with role** dropdown and **Select users**\nbutton. Compact access rows show the current account as **You** with a generic profile icon, then\n**Anyone in organization** (No access or Can view), then a dashed divider and the other members.\nThe add-users role and access controls use ghost-button dropdown menus with right-aligned selection\ncheckmarks; member menus contain role changes and a separate destructive **Remove access** action,\nor **Leave collection** for the current account. The last owner cannot leave or be demoted. A neutral\n**Done** button is the only footer action and stays visible while the member list scrolls. The\nWorkshop's person picker uses the same neutral **Done** action.\n\nAnyone a collection was shared with as a viewer or editor can leave it from the collection's row menu,\nincluding viewers, who cannot open the Share dialog. Owners leave from the Share dialog, which knows\nwhether another owner remains. Both surfaces open the same confirmation before leaving. It warns\nthat the last member leaving an ownerless collection permanently deletes the collection and its\ndocuments, even when organization-wide viewing is enabled. Leaving reconciles the parent even if\nGit token cleanup rejects after the membership was removed, while retaining the cleanup error rather\nthan presenting a rejected operation as success.\n\nThe server remains the authority for every mutation. The client-side checks control affordances and\nprovide earlier feedback; they are not the security boundary.\n\n## Move limitations\n\nDrag-and-drop is intentionally limited to one collection. Within that collection, existing legacy\ndirectories remain valid destinations for the compatibility reasons above. A same-collection move\ncan update the skill directory and all supporting documents in one Durable Object storage\ntransaction.\n\nCross-collection moves are disabled. Implementing one as separate client-side copy and delete calls\ncould overwrite concurrent edits or leave duplicated or partially moved data after a failure. A\nfuture backend operation must authorize both collections, reject read-only sources and destinations,\npreserve the complete skill subtree, fail on destination conflicts, and define recoverable or atomic\nsemantics across the two collection Durable Objects before the UI enables these drops.\n\n## Upload limitations\n\nThe Skills Navigator accepts standalone Markdown files and folders. Each loose `.md` or `.markdown`\nfile becomes one skill. A `SKILL.md` file defines a bundle and imports the other files below its source\ndirectory as related skill files; nested bundles are imported as separate top-level skills. Browser\nfolder selection is based on the non-standard but widely supported `webkitdirectory` input. Chrome's\ndirectory APIs fail or terminate the management app's opaque-origin iframe, so complete folders should\nbe selected with the folder picker. Supporting folder drops requires reading them in the Workshop host\nand passing the files into the sandboxed app.\n\nSkill manifests are created through the skill-specific RPC, but there is currently no backend RPC for\natomically creating a complete bundle. Supporting files are written only after the manifest succeeds.\nIf one of those writes fails, the user is told which skill was only partially uploaded, but the created\nmanifest and any successful related files remain. A future bundle-oriented RPC is required for true\nall-or-nothing uploads.\n\n## Skill upload format contract\n\nThe upload dialog accepts [Agent Skills](https://agentskills.io/specification) and\nlegacy Markdown. Import acceptance is deliberately broader than validation of the\nstored `SKILL.md`.\n\n### Recognizing metadata\n\nAn import opts into skill metadata only when it begins with a complete `---`\nfenced block that parses as a YAML mapping containing `name` or `description`.\nThe delimiter must be unindented; a BOM, CRLF, and trailing fence whitespace are\naccepted. Indented delimiters within YAML block scalars do not close the block.\n\nEverything else is legacy Markdown, including ordinary text, horizontal rules,\nunclosed fences, invalid YAML, sequences, and mappings without skill metadata keys.\nThese inputs are ambiguous, so preserve the entire source as instruction text\nrather than guessing which part to discard. This also applies to imports named\n`SKILL.md`; that filename groups supporting files but does not require a legacy\nimport to already be a valid skill.\n\n### Review and conversion\n\n- Recognized metadata supplies the editable name and description. Missing or\n  non-string fields are left blank for correction; invalid strings are not replaced\n  with inferred defaults. Extra metadata is retained when writing the manifest.\n- Legacy Markdown gets a filename-derived name and a suggested description for\n  user review. Conversion adds new frontmatter and preserves the entire original\n  instruction text, including leading whitespace and line endings.\n- Recognized frontmatter is serialized after edits, but its Markdown body is\n  preserved verbatim. The metadata syntax itself need not round-trip byte-for-byte.\n- The existing review form checks the final name and description before upload;\n  the server's `parseSkillManifest` validates the resulting stored manifest.\n\n`readSkillUploadSource` in `skillUpload.ts` is the single recognition decision used\nby both inference and conversion. New import behavior should be expressed here\nand covered by the table-driven round-trip tests, rather than added as an independent\ndelimiter heuristic in a caller.\n\nThis is not a claim of full specification validation: the existing server validates\nrequired fields, permits names differing from the parent directory, and does not\nvalidate every optional specification field.\n","isInternal":false,"tokens":2081,"sizeBytes":11060},{"name":"README.md","path":"packages/gatekeeper-email/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-email/README.md","title":"gatekeeper-email Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper Email\n\nThis package provides an email receiving gatekeeper for Gadgets. It allows a Gadget to receive inbound emails at an address like `<name>@<host>` via a hook.\n\nUnlike most gatekeepers which connect to external services, this gatekeeper *is* the service -- it implements a Cloudflare Email Worker that receives mail directly.\n\n## How It Works\n\nThe email gatekeeper uses the URL scheme `http://localhost:8787/gatekeeper/email/mailbox/<name>` to represent the email address `<name>@<host>`, where `<host>` is the domain configured to route email to this worker.\n\nWhen a Gadget is connected to an email address:\n\n1. The Gadget's code exports a `WorkerEntrypoint` that implements the `EmailHook` interface.\n2. The coding agent calls `setBindingHook` to connect the hook to the binding.\n3. The gatekeeper stores the hook reference in a Durable Object keyed by the email username.\n4. When an email arrives, the Email Worker routes it to the appropriate DO, which invokes the Gadget's hook with the parsed email content.\n\nEmails are parsed using [postal-mime](https://www.npmjs.com/package/postal-mime), so the hook receives structured data (from, to, subject, text body, HTML body, attachments) rather than raw MIME.\n\n## Creating a Binding\n\n1. Start the dev server (see root README).\n2. Create or open a Gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose **Email Mailbox**.\n6. Enter the mailbox local part, e.g. `myinbox`.\n7. This represents the email address `myinbox@<host>`.\n8. The binding will appear as `EMAIL` (the suggested name).\n\nMailbox names are canonicalized to lowercase. They may contain letters, numbers, dots, underscores, plus signs, or hyphens, and cannot start or end with a dot or contain consecutive dots.\n\nThe binding provides an `EmailSession` interface with a single method:\n\n```typescript\ninterface EmailSession {\n  getAddress(): Promise<string>;  // e.g. \"myinbox@example.com\"\n}\n```\n\nTo actually receive emails, the Gadget must implement a hook. Ask the coding agent to set up a hook for the email binding, or do it manually:\n\n```typescript\n// In the Gadget's code:\nimport { WorkerEntrypoint } from \"cloudflare:workers\";\n\nexport class MyEmailHandler extends WorkerEntrypoint {\n  async receiveEmail(email) {\n    // email has: from, to, cc, subject, date, text, html, attachments\n    console.log(`Got email from ${email.from.address}: ${email.subject}`);\n    // Store it, process it, etc.\n  }\n}\n```\n\nThen prompt your coding agent to use the `setBindingHook` tool to connect it:\n- Binding: `EMAIL`\n- Export name: `MyEmailHandler`\n\n## Local Development\n\nIn local development, the gatekeeper is served under the path `/gatekeeper/email` on `localhost:8787`. We don't actually support receiving real SMTP email locally.\n\n### Sending a Test Email\n\nIn local dev, wrangler exposes a `/cdn-cgi/handler/email` endpoint that simulates inbound email. You can POST raw email content to it using curl.\n\nWith the dev server running (`pnpm run dev-server` from the repo root), send a test email:\n\n```bash\ncurl -X POST 'http://localhost:8787/cdn-cgi/handler/email' \\\n  --url-query 'from=sender@example.com' \\\n  --url-query 'to=myinbox@example.com' \\\n  --header 'Content-Type: application/json' \\\n  --data-raw 'From: \"Alice\" <sender@example.com>\nTo: myinbox@example.com\nSubject: Hello from local dev\nContent-Type: text/plain; charset=\"utf-8\"\nDate: Mon, 16 Feb 2026 12:00:00 +0000\nMessage-ID: <test-123@example.com>\n\nThis is a test email body.'\n```\n\nThe `to` address's local part (`myinbox`) determines which `EmailAddress` Durable Object receives the email. Make sure:\n- You have a Gadget with an Email Mailbox binding for `myinbox@<host>`\n- The binding has a hook connected via `setBindingHook`\n\nIf no hook is configured for that address, the email will be rejected.\n\n## Production Configuration\n\nIn production, you need to set up [Cloudflare Email Routing](https://developers.cloudflare.com/email-routing/) to forward emails to this worker.\n\n### Step 1: Deploy the email gatekeeper and set env.BASE_URL\n\nSet the `BASE_URL` environment variable to the full base URL (protocol + host + optional path) at which the email gatekeeper's fetch handler is served. No trailing slash. For example:\n\n```\n# Deployed as its own worker at the root:\nBASE_URL=https://gatekeeper-email.example.workers.dev\n\n# Or co-hosted on the same domain as the main app under a path:\nBASE_URL=https://app.example.com/gatekeeper/email\n```\n\nAll occurrences of `http://localhost:8787/gatekeeper/email` in the doc above will in production be replaced by this `BASE_URL` value.\n\n### Step 2: Enable Email Routing\n\n1. In the Cloudflare dashboard, go to your domain's **Email Routing** settings.\n2. Follow the setup wizard to enable Email Routing and configure the required DNS records (MX, SPF, etc.).\n\n### Step 3: Create an Email Worker Route\n\n1. Go to **Email Routing** > **Email Workers**.\n2. Create a route that matches the addresses you want to handle. For example:\n   - **Custom address**: `*@yourdomain.com` (catch-all) or specific addresses like `gadget-*@yourdomain.com`\n   - **Action**: Send to a Worker\n   - **Worker**: Select the deployed `gatekeeper-email` worker\n3. Alternatively, you can configure this in your Worker config for deployment.\n\n### How It Fits Together\n\n```\nInternet email → Cloudflare Email Routing → gatekeeper-email worker\n                                               │\n                                               ├── email() handler parses recipient\n                                               │\n                                               ▼\n                                          EmailAddress DO (per username)\n                                               │\n                                               ├── loads stored hook Fetcher\n                                               │\n                                               ▼\n                                          Gadget's hook entrypoint\n                                          (via Overseer loopback)\n```\n\nEach email address maps to a Durable Object named by its local part. The DO stores the hook `Fetcher` in KV storage, which persists across requests. When an email arrives, the DO loads the hook and calls `receiveEmail()` with the parsed email data.\n","isInternal":false,"tokens":1411,"sizeBytes":6319},{"name":"README.md","path":"packages/gatekeeper-github/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-github/README.md","title":"gatekeeper-github Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper GitHub\n\nThis package provides GitHub OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `github` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitHub\" appears on the login page. Sign-in requests only minimal scopes (`read:user user:email`)\n  to read the account's **primary verified email**, which becomes the user's identity. The sign-in\n  grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects GitHub (or signs in and later connects it), the full scopes\n  (`repo read:user user:email`) are requested so gadgets can access repositories, issues, and pull\n  requests on the user's behalf.\n\n> **Use a GitHub _OAuth App_, not a GitHub _App_.** Only OAuth Apps honor the OAuth `scope`\n> parameter, which is what makes the minimal-on-login / full-on-connect behavior work, and the\n> `user:email` scope is what grants access to the user's verified email. A **GitHub App** (client id\n> starting with `Iv…`) ignores `scope` entirely and will return `Resource not accessible by\n> integration` for the email lookup unless you separately grant it the **Email addresses** account\n> permission — and even then login won't be minimal-scope. See [Using a GitHub App\n> instead](#using-a-github-app-instead) if you must.\n\n## Auto-approval\n\nEvery queued action carries a stable kind (`github.<action>`, e.g. `github.push`), and the user may\nopt in to approving a kind automatically on a connection. Every action is eligible, including\nclosing and reopening, `requestChanges` reviews and force pushes, except two that always wait for a\nperson: **merging a pull request** and **an approving review**. An approval shares the\n`github.postReview` kind with comment and request-changes reviews, but is never auto-approvable,\nso enabling that kind does not cover it. A force push has its own kind (`github.forcePush`), so\nenabling ordinary pushes does not let history be rewritten. Excluded actions are still approved\nby hand as before. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit (an issue binding, for example, never lists pushes).\n\n## Setting Up GitHub OAuth Credentials\n\nIf you're running this project locally and want to use GitHub integrations, you'll need to create\nyour own GitHub OAuth app. This guide walks you through the process.\n\n### Step 1: Create a GitHub OAuth App\n\n1. Go to [GitHub Settings > Developer settings > OAuth Apps](https://github.com/settings/developers)\n   (**OAuth Apps**, not \"GitHub Apps\").\n2. Click **New OAuth App**\n3. Fill in the application details:\n   - **Application name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - **Homepage URL**: `http://localhost:3000`\n   - **Authorization callback URL**: `http://localhost:8787/gatekeeper/github/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n4. Click **Register application**\n\nGitHub enables expiring user tokens by default for OAuth apps registered since August 2026\n(**Optional features** in the app's settings). The gatekeeper supports both kinds: it refreshes an\nexpiring token shortly before its eight hours run out, and asks the user to reconnect only once\nGitHub rejects the refresh token itself (unused for six months, or revoked).\n\n### Step 2: Generate a Client Secret\n\nOn the app's settings page after registration:\n\n1. Click **Generate a new client secret**\n2. Copy the **Client ID** and the generated **Client secret** — you'll need both in the next step.\n\n### Step 3: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-github/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 4: (Optional) Enable GitHub sign-in\n\nTo offer \"Continue with GitHub\" on the login page, add `github` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nUsers are keyed by their GitHub primary verified email, so the OAuth App must be able to read it\n(the `user:email` scope, requested automatically). No extra setup is needed for an OAuth App.\n\n### Step 5: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitHub resource type: repository, issue, or pull request.\n6. If prompted, connect a GitHub account.\n7. You should be redirected to GitHub's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the repository, issue, or pull request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitHub resource scope.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Event hooks (optional)\n\nGadgets can subscribe to a repository's issue, pull request, comment, review, push and tag events,\nor to one issue's or pull request's (`subscribe()` in `src/types.d.ts`). GitHub delivers them through\na repository webhook, so this needs a public URL:\n\n1. Set `WEBHOOK_ORIGIN` on this worker to the public origin GitHub should deliver to, e.g.\n   `https://gadgets.example.com`. Without it, hooks are refused and everything else works as\n   before.\n2. If the deployment sits behind Cloudflare Access, add a bypass for\n   `/gatekeeper/github/webhook/*`; the worker accepts only deliveries signed with a webhook's\n   secret instead.\n\nEnabling a hook adds a webhook to the repository, delivering to\n`${WEBHOOK_ORIGIN}/gatekeeper/github/webhook/<id>`, so GitHub allows it only if the connected\naccount is an admin of the repository. An account's hooks on one repository share its webhook,\nwhich is deleted when the last of them is disabled, or when the account is disconnected. Should\nGitHub fail to delete it, the hourly check tries again, and a disconnect that leaves it fails,\nkeeping the account connected, with the token that can delete it, until disconnecting again finds\nit gone. Each webhook signs its deliveries with a secret only GitHub and this deployment hold; a\ndelivery of a payload already received, or of an event more than a day old, is ignored; and an\nevent is delivered only while the account can still read the repository, since a webhook outlives\nits creator's access.\n\nThe repository's other admins can see the webhook, though not its secret. Every hour the worker\nchecks each webhook on GitHub, and restores one that someone has deleted, deactivated, pointed\nelsewhere, switched to form-encoded payloads or unverified TLS, or given other events, another\nsecret or none: to stop the deliveries, disable the hooks. The same check moves them to a new\n`WEBHOOK_ORIGIN` within the hour. GitHub never retries a failed delivery itself, so the same check\nasks it to redeliver what it failed to deliver in the past two hours: up to 20 an hour for each\naccount, shared among its repositories so that a busy one can't crowd out the rest. That includes\nwhat was refused while the webhook was misconfigured, by this worker or by another receiver, as\nGitHub redelivers to the webhook as it is configured now. Failures beyond that, or older than two\nhours, are lost, as is anything another receiver accepted while the webhook pointed there. A\ndelivery that reaches the worker is retried until the gadget's hook accepts it, eight attempts in\nall. When every recent delivery failed, as when Cloudflare Access turns GitHub away, the check logs\n`hooks.webhook.deliveries.failing` with the statuses GitHub got.\n\nFor local development, GitHub cannot reach `localhost`: run a tunnel to the dev server (e.g.\n`cloudflared tunnel --url http://localhost:8787`) and set `WEBHOOK_ORIGIN` to its origin in the\nroot `.dev.vars`.\n\nWhat this relies on GitHub for is checked against GitHub itself by `pnpm test:contract`\n(`__tests__/contract`), which the `github-contract` workflow runs weekly and on demand once the\n`CONTRACT_GITHUB_TOKEN` secret and `CONTRACT_GITHUB_REPO` variable name a disposable private\nrepository (the token fine-grained, with access to that repository alone; see the workflow):\n- a reader without access gets a 404, whatever ETag it sends;\n- a redelivery uses the webhook's current URL, encoding and secret;\n- the delivery log is newest first;\n- the account is the sender of what it does;\n- each event's payload parses.\n\n## Using a GitHub App instead\n\nIf you must use a **GitHub App** (client id `Iv…`) rather than an OAuth App, be aware:\n\n- GitHub Apps **ignore the OAuth `scope` parameter**. Permissions are fixed in the App's\n  configuration and apply to every user authorization, so sign-in cannot be limited to minimal\n  scopes — it grants whatever the App is configured for.\n- To read the user's email for sign-in, you must grant the App the **Email addresses** account\n  permission: App settings → **Permissions & events** → **Account permissions** → **Email\n  addresses → Read-only** → save. Existing users must then re-authorize (re-run the sign-in flow) to\n  approve the added permission. Without it, the email lookup fails with `Resource not accessible by\n  integration` and sign-in is rejected.\n\nFor these reasons an **OAuth App is recommended** for sign-in.\n\n## Troubleshooting\n\n### \"Resource not accessible by integration\" error\n\nYou're using a **GitHub App** that lacks the **Email addresses** account permission, so the email\nlookup required for sign-in is forbidden. Either switch to an OAuth App (recommended) or grant the\nApp the Email-addresses permission and re-authorize — see [Using a GitHub App\ninstead](#using-a-github-app-instead).\n\n### \"redirect_uri_mismatch\" error\n\nThe callback URL in your OAuth app settings doesn't match what the app is sending. Double-check that you set it to exactly `http://localhost:8787/gatekeeper/github/oauth` (no trailing slash, `http` not `https`).\n\n### \"bad_verification_code\" error\n\nThe authorization code has expired or already been used. Return to Gadgets and try connecting again.\n\n### \"Not configured\" page during authorization\n\nYour `CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at `packages/gatekeeper-github/.env` and contains both values, then restart the dev server.\n","isInternal":false,"tokens":2389,"sizeBytes":10460},{"name":"README.md","path":"packages/gatekeeper-gitlab/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-gitlab/README.md","title":"gatekeeper-gitlab Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper GitLab\n\nThis package provides GitLab OAuth integration for Gadgets, for gitlab.com and for self-hosted\nGitLab instances. It serves two purposes:\n\n- **Sign-in:** when `gitlab` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitLab\" appears on the login page. Sign-in requests only the `read_user` scope to read the\n  account's **confirmed primary email**, which becomes the user's identity. The sign-in grant is\n  transient (discarded right after the email is read).\n- **Connections:** when a user connects GitLab (or signs in and later connects it), the `api` and\n  `write_repository` scopes are requested so gadgets can read and manage projects, issues, and\n  merge requests, and pull from and push to repositories, on the user's behalf.\n\nIt is a mirror of the GitHub gatekeeper: the same three resource granularities (project, issue,\nmerge request), the same worktree integration (commit ids advertised, `gitPull()` over git\nsmart-HTTP, `push()` as a queued action), and the same review and merge flows expressed in GitLab's\nown terms. See `plans/gitlab-gatekeeper.md` for the design record.\n\n`gitPull(oids, options)` returns a `GitPullTransfer`: a one-shot raw PACK stream and a separate\nterminal status. Lazy DO reads consume it through the bounded object cache. The same capability\nsupports native Git hydration, but does not enable it here: the container consumer ships in a\nseparate kernel worktree-replica bootstrap change. That consumer must bind the bootstrap to an\nauthorized immutable commit, request its complete tree at depth one, and import the pack without\npopulating the DO cache. `gitPull` is kernel-facing, not a gadget-facing arbitrary Git proxy;\nthe kernel selects objects from recorded provenance, not a provider-side advertisement validator.\nNo encoded Git commands, ref discovery, or arbitrary clone URLs cross this interface. OAuth and\noptional Cloudflare Access credentials remain in the gatekeeper's transport, including on\nself-hosted instances.\n\n## Auto-approval\n\nAs in the GitHub gatekeeper, every queued action carries a stable kind (`gitlab.<action>`), and\nevery one may be auto-approved once the user opts in to its kind, including closing and reopening,\nresolving and reopening diff threads, `requestChanges` reviews and force pushes (under their own\n`gitlab.forcePush` kind). **Merging a merge request** and **an approving review** always wait for\na person: an approval shares the `gitlab.postReview` kind with the other reviews but is never\nauto-approvable. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit.\n\n## Setting up GitLab OAuth credentials\n\nIf you're running this project locally and want to use GitLab integrations, you'll need to create\nyour own GitLab OAuth application.\n\n### Step 1: Create a GitLab application\n\n1. On GitLab, go to **User settings → Applications** (or, to share the application with a team,\n   a group's **Settings → Applications**).\n2. Fill in the application details:\n   - **Name**: anything (e.g. \"Gadgets Local Dev\")\n   - **Redirect URI**: `http://localhost:8787/gatekeeper/gitlab/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n   - **Confidential**: checked\n   - **Scopes**: `api`, `write_repository`, `read_user`\n3. Save the application, and copy the **Application ID** and **Secret**.\n\n> **Why `write_repository` when `api` is \"everything\"?** GitLab's documentation grants Git over\n> HTTPS to OAuth tokens only through the `read_repository`/`write_repository` scopes; `api` covers\n> the REST API. Pushing commits from a worktree needs `write_repository`, so both are requested.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-gitlab/.env`):\n\n```bash\nCLIENT_ID=your-application-id-here\nCLIENT_SECRET=your-application-secret-here\n```\n\nAlternatively, export `GITLAB_CLIENT_ID` and `GITLAB_CLIENT_SECRET` in your shell; `pnpm dev-server`\nmaps them into the worker's `CLIENT_ID`/`CLIENT_SECRET`.\n\nTo develop against a self-hosted instance, set `GITLAB_URL` (and `GITLAB_API_URL`,\n`CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` as the instance needs -- see\n[Self-hosted instances](#self-hosted-instances)) in the root `.dev.vars` or your shell;\n`pnpm dev-server` passes them through to the worker, so nothing tracked has to change.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable GitLab sign-in\n\nTo offer \"Continue with GitLab\" on the login page, add `gitlab` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,gitlab\n```\n\nUsers are keyed by their GitLab primary email, which GitLab only makes primary once confirmed. An\naccount whose primary email is unconfirmed cannot sign in.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitLab resource type: project, issue, or merge request.\n6. If prompted, connect a GitLab account.\n7. You should be redirected to GitLab's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the project, issue, or merge request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitLab resource.\n\n## Self-hosted instances\n\nBy default the gatekeeper talks to `https://gitlab.com`. Point it at another instance with these\nvariables on the worker:\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `GITLAB_URL` | The instance users visit: the OAuth authorization page, links in results, and the URLs of connectable resources. | `https://gitlab.com` |\n| `GITLAB_API_URL` | The origin the Worker sends requests to (REST, token endpoints, git over HTTPS), when it differs from `GITLAB_URL`. | `GITLAB_URL` |\n| `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` (secrets) | A Cloudflare Access **service token**, attached to every Worker→GitLab request as `CF-Access-Client-Id`/`CF-Access-Client-Secret`. Set both or neither. | unset |\n\nBoth URLs must be `https` origins (scheme and host, no path): every request to the API origin\ncarries a user's token, and the OAuth exchanges carry the client secret. Plain `http` is accepted\nonly on `localhost`, `127.0.0.1` or `[::1]`, for a GitLab run on your own machine. A GitLab\nserved under a relative URL root (`https://example.com/gitlab`) is not supported; the worker\nrefuses the URL at its first request rather than sending tokens to the wrong path.\n\nThe typical Access-protected layout is `GITLAB_URL=https://gitlab.example.com` (behind Access for\nbrowsers) and `GITLAB_API_URL=https://gitlab-access.example.com` (a hostname whose Access\napplication admits the service token). The Access application must admit the token on the\n`/api/v4/*`, `/oauth/*`, **and** `/<group>/<project>.git/*` paths — the last is where git fetch and\npush go, and a policy scoped to the API alone makes worktree pulls fail with a login page.\n\nRegister the OAuth application on the instance itself, with the same redirect URI and scopes as\nabove.\n\nA review that requests changes needs GitLab 19.2 or later: earlier versions' review-publishing\nendpoint ignores the reviewer state without an error, so on them the gatekeeper refuses a\n`requestChanges` review rather than post it as a plain comment. Comment and approve reviews work\non every version.\n\n## Worker Preview OAuth callbacks\n\nA Worker Preview's hostname cannot be registered with the GitLab application, so a deployment\nthat tests on previews registers one **stable** callback and relays the result to the preview\nthat started the flow (`@gadgets/gatekeeper-kit/preview-oauth`, as `gatekeeper-google` does).\nGive the stable Worker and its previews the same `OAUTH_STATE_SIGNING_SECRET` and\n`OAUTH_ALLOW_PREVIEW_REDIRECTS=true`, and set the fixed redirect on previews only:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-gitlab.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the application's redirect URI. A preview sends that URI to\nGitLab and carries its own callback in signed, short-lived state; the stable Worker accepts\nreturn URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only GitLab's\nresult and the state. The preview then exchanges the code with the same redirect URI it\nauthorized under. Normal deployments omit all three settings and use `${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Previews\ndo not inherit the stable Worker's secrets: set `CLIENT_ID`, `CLIENT_SECRET`,\n`OAUTH_STATE_SIGNING_SECRET` and, for an Access-protected instance, `CF_ACCESS_CLIENT_ID` /\n`CF_ACCESS_CLIENT_SECRET` on the stable Worker (`wrangler secret put`) **and** in its Previews\nBase configuration (`wrangler preview base-config secret put`); a preview created afterwards\nreceives them.\n\n## Troubleshooting\n\n### \"The GitLab project has been renamed or transferred\"\n\nThe project a connection was bound to has moved. GitLab answers the old path with a redirect that\nthe gatekeeper deliberately does not follow (following it would turn writes into reads). Re-bind\nthe connection to the project's new path.\n\n### \"GitLab credentials have expired or been revoked\"\n\nGitLab access tokens last two hours and are refreshed automatically; the refresh token behind them\nis single-use and rotates on every refresh. When GitLab refuses an access token the gatekeeper\nfirst refreshes past it. This error means the refresh token itself was rejected (`invalid_grant`)\n— typically because the application was revoked in GitLab, or the account sat idle past the\ninstance's refresh-token lifetime. Reconnect the account. A token endpoint that is unreachable,\nanswers 5xx, or is turned away by Cloudflare Access fails the request with \"Could not refresh\nGitLab credentials\" instead and leaves the connection intact.\n\n### \"GitLab credentials were renewed during this request\"\n\nGitLab invalidates an access token when it issues its replacement, so a request in flight while\nthe gatekeeper refreshed can be refused. Reads are rerun once with the new token; anything else\nfails with this message and is safe to retry.\n\n### The redirect URI does not match\n\nThe **Redirect URI** on the GitLab application must be exactly\n`<PUBLIC_BASE_URL>/gatekeeper/gitlab/oauth` (no trailing slash, `http` for local dev) -- or, on a\nWorker Preview, the stable Worker's `OAUTH_REDIRECT_URI` (see above).\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at\n`packages/gatekeeper-gitlab/.env` and contains both values, then restart the dev server.\n\n### \"GitLab did not answer the request: the API redirected to …\"\n\nEvery REST call is answered with a redirect to a login page: the instance is behind Cloudflare\nAccess and the Access application in front of `GITLAB_API_URL` does not admit the gatekeeper's\nservice token, or `CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET` are not set on the worker (see\n[Self-hosted instances](#self-hosted-instances)). Distinct from the rename message above: the\ngatekeeper tells them apart by where the redirect points.\n\n### git fetch or push fails with a redirect or an HTML page\n\nThe same cause on the `.git/` paths: the Access application admits the service token for\n`/api/v4` but not for the repository, or the token is not configured.\n\n### \"This collaborator does not have read access to the GitLab project …\"\n\nSomeone sharing a workspace is not a member of the project it is bound to at **Reporter** or\nabove — or is a member only at Guest. Being able to open the project in GitLab is not enough,\neven on an `internal` or `public` one: a project can hold confidential issues and internal notes\nthat only members at those roles see, and the workspace may have read them. The remedy is for a\nproject maintainer to add the collaborator as a Reporter (inherited group membership counts).\n","isInternal":false,"tokens":2830,"sizeBytes":12157},{"name":"README.md","path":"packages/gatekeeper-google/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-google/README.md","title":"gatekeeper-google Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper Google\n\nThis package provides Google OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `google` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Google\" appears on the login page. Sign-in requests only minimal scopes (`openid`,\n  `userinfo.email`, `userinfo.profile`) to read the account's **verified email** (`email_verified`),\n  which becomes the user's identity.\n  The sign-in grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects Google (or signs in and later connects it), the scopes for\n  the selected resources (Gmail, Docs, Sheets, Slides, Drive, Calendar, or BigQuery — see below) are requested\n  so gadgets can access those APIs on the user's behalf.\n\nA single Google OAuth client is used for both. Set it up as follows.\n\n## Setting Up Google OAuth Credentials\n\nIf you're running this project locally and want to use Google API integrations, you'll need to create your own Google OAuth credentials. This guide walks you through the process step-by-step.\n\n### Step 1: Create a Google Cloud Project\n\n1. Go to the [Google Cloud Console](https://console.cloud.google.com/)\n2. Sign in with your Google account\n3. Click the project dropdown at the top of the page (it may say \"Select a project\" or show an existing project name)\n4. Click **New Project** in the top-right of the popup\n5. Enter a project name (e.g., \"Gadgets Local Dev\")\n6. Click **Create**\n7. Wait for the project to be created, then select it from the project dropdown\n\n### Step 2: Enable Required APIs\n\nYou'll need to enable the Google APIs that you want to use. Currently supported: Gmail, Google Docs, Google Sheets, Google Slides, Google Drive, Google Calendar, Google Chat, and BigQuery.\n\n1. In the left sidebar, go to **APIs & Services** > **Library** (or [click here](https://console.cloud.google.com/apis/library))\n2. Search for \"Gmail API\"\n3. Click on **Gmail API** in the results\n4. Click **Enable**\n5. Go back to the Library, search for \"Google Docs API\"\n6. Click on **Google Docs API** in the results\n7. Click **Enable**\n8. Go back to the Library, search for \"Google Drive API\"\n9. Click on **Google Drive API** in the results\n10. Click **Enable**\n11. Go back to the Library, search for \"Google Sheets API\"\n12. Click on **Google Sheets API** in the results\n13. Click **Enable**\n14. Go back to the Library, search for \"Google Slides API\"\n15. Click on **Google Slides API** in the results\n16. Click **Enable**\n17. Go back to the Library, search for \"Google Calendar API\"\n18. Click on **Google Calendar API** in the results\n19. Click **Enable**\n20. Go back to the Library, search for \"BigQuery API\"\n21. Click on **BigQuery API** in the results\n22. Click **Enable**\n23. For Chat, enable **Google Chat API**, plus **People API** so direct messages and group chats can be named when Chat omits a participant's name, and so a whole-account Chat connection can search the organization's directory and confirm that everyone in a conversation it starts belongs to the organization.\n24. On the Google Chat API's **Configuration** tab, set an app name, avatar URL, and description, turn off **Interactive features**, and click **Save**. Reads work without this, but Google refuses every Chat send, edit, and reaction until a Chat app is configured.\n\nThe Google Drive API powers the Docs, Sheets, and Slides resource pickers, Drive discovery, and Drive scope checks. Native document, spreadsheet, or presentation content opened from a Drive binding is read through the Google Docs, Google Sheets, or Google Slides API. Direct Google Doc reads and edits still go through the Docs API, direct spreadsheet reads and edits go through the Sheets API, and direct presentation reads and edits go through the Slides API.\n\n### Step 3: Configure the OAuth Consent Screen\n\nBefore creating credentials, you must configure how the consent screen appears to users.\n\n1. In the left sidebar, go to **APIs & Services** > **OAuth consent screen** (or [click here](https://console.cloud.google.com/apis/credentials/consent))\n2. Select **External** as the user type (unless you have a Google Workspace organization and want to restrict to internal users only)\n3. Click **Create**\n4. Fill in the App Information:\n   - **App name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - Details are largely optional / irrelevant here, since this app will run it testing mode.\n   - Click **Save and Continue**\n5. On the Scopes page, you can just click **Save and Continue** without adding anything. The scopes are specified by the OAuth request itself, not the console configuration. (The console's scope UI is only relevant if you later want to publish your app for Google's verification review.)\n\nThe scopes requested depend on what the user is doing. **Sign-in** requests only the identity\nscopes (`openid`, `userinfo.email`, `userinfo.profile`). **Connecting** Google for capabilities\nrequests scopes granularly per resource type, not all at once: connecting a Gmail mailbox asks\nonly for the Gmail scopes, a Google Doc only for the Docs scopes, and so on (identity is always\nincluded). Across all resource types, the gatekeeper can request:\n\n- `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account.\n- `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending.\n- `documents` for direct Google Docs reads, edits, and creation; `documents.readonly` for native Docs opened from account-wide, folder, or exact-file Drive bindings.\n- `drive.metadata.readonly` for the Docs, Sheets and Slides pickers. Google classifies this as a restricted scope.\n- `drive.readonly` for account-wide, folder, and exact-file Drive bindings: the Drive picker, discovery, file metadata, folder descendant proofs, native-file scope checks, and downloading a PDF to read its text with `readPdf()`. Google classifies this as a restricted scope too, so every Drive resource here needs restricted-scope verification.\n- `drive.readonly` and `drive.file` for the Office import grant (`https://docs.google.com/import`), which converts an Office file into a Google Docs, Sheets or Slides copy (see [Office files](#office-files)). Only the connect dialog's **Convert** button asks for it, by incremental consent, the first time an account converts a file; using a copy made before reads only metadata and asks for nothing. Once held, it is part of the account's recorded grants, so a reconnect asks for it again. `drive.readonly` lets the gatekeeper see a source file this OAuth client did not create, which `drive.file` alone cannot, and `drive.file` lets it create the copy. The grant is private to the gatekeeper: it is not a resource, so it is never offered on connect, listed, bound, or shown among an account's granted resources, and admins cannot turn it off separately from Docs, Sheets and Slides. Once held, `drive.readonly` lets the account's token read any Drive file, so bindings stay limited by the gatekeeper's code rather than by the token. A verified OAuth app must add both scopes to its consent screen, and `drive.readonly` is a restricted scope.\n- `drive.readonly` for the shared-drives grant (`https://drive.google.com/drive/shared-drives`), which lets the Drive picker list the shared drives the account belongs to, since Drive's `drives.list` accepts no narrower scope. Only the picker's **Allow access** button asks for it, by incremental consent, the first time an account opens Shared drives; once held, a reconnect asks for it again. It is private to the gatekeeper as the Office import grant is, and an account holding that grant is still asked once, since a private grant counts only once it has been asked for, not because its scopes happen to be held.\n- `spreadsheets` to read metadata, bounded cell ranges and their formatting from directly selected spreadsheets, edit their values, sheets, rows, columns and formatting with approval, and create spreadsheets. The switch from `spreadsheets.readonly` retracted every existing Sheets grant, including one derived from `drive.readonly`: owners re-consent the next time they connect a spreadsheet, and collaborators observing an existing Sheets binding are asked to grant read-write `spreadsheets` the next time they open its workspace. A `spreadsheets` grant covers the Drive resources' `spreadsheets.readonly`.\n- `spreadsheets.readonly` to read native Sheets opened from account-wide, folder, or exact-file Drive bindings.\n- `presentations` to read, edit, and create directly selected presentations, and to render slide thumbnails; `presentations.readonly` to read and render native Slides opened from account-wide, folder, or exact-file Drive bindings. Google counts each thumbnail as an expensive read, limited to 60 a minute per user and 300 per project.\n- `calendar.calendarlist.readonly` so the resource picker can list calendars.\n- `calendar.events` to manage selected calendar and check calendar availability.\n- `chat.spaces.readonly`, `chat.messages`, and `chat.memberships.readonly` for every Chat resource. A whole-account Chat connection adds `chat.users.readstate.readonly` for its unread-only search, and `chat.spaces.create` and `directory.readonly` to start direct messages and group chats with people in the connected account's Workspace directory. Starting a conversation is its own approval kind, separate from sending in an existing one, and people outside the organization can't be added to a new conversation.\n- `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries.\n\n### Step 4: Test Users\n\nThis is important! While your app is in \"Testing\" mode (which it will be by default), only users you explicitly add here can use OAuth.\n\n1. Click **Add Users**\n2. Enter your own Google email address (the one you'll use to test Google API integrations)\n3. Click **Add**\n4. Click **Save and Continue**\n5. Review the summary and click **Back to Dashboard**\n\n### Step 5: Create OAuth Credentials\n\n1. In the left sidebar, go to **APIs & Services** > **Credentials** (or [click here](https://console.cloud.google.com/apis/credentials))\n2. Click **Create Credentials** at the top\n3. Select **OAuth client ID**\n4. For **Application type**, select **Web application**\n5. **Name**: Enter anything (e.g., \"Gadgets Local\")\n6. Under **Authorized redirect URIs**, click **Add URI** and enter: `http://localhost:8787/gatekeeper/google/oauth`\n7. Click **Create**\n\nA popup will appear with your **Client ID** and **Client Secret**. Keep this window open or copy these values somewhere safe.\n\n### Step 6: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-google/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here.apps.googleusercontent.com\nCLIENT_SECRET=your-client-secret-here\n```\n\nReplace the values with the credentials from Step 5.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 7: (Optional) Enable Google sign-in\n\nTo offer \"Continue with Google\" on the login page, add `google` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nSign-in only needs the identity scopes, which are always available, so no extra Google setup is\nrequired. (While the app is in Testing mode, the signing-in user must still be listed as a Test\nUser — see Step 4.)\n\n### Step 8: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Slides Presentation, Google Drive, Google Calendar, or BigQuery.\n6. If prompted, connect a Google account.\n7. You should be redirected to Google's consent screen in a new tab.\n8. The consent screen acts extra-scary since this is an \"unverified\" test app.\n9. After granting access, the tab closes, and you're back to Gadgets.\n10. Use the picker to choose the mailbox scope, document, presentation, project, dataset, or table to connect, or for Google Drive, the whole of Drive, a folder, or a single file.\n11. Create the connection. Ask the agent what it can do, or ask it to write a gadget using the new binding.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Gmail attachments\n\nNew messages, replies, forwards, and their draft-creation methods accept\n`attachments?: GmailAttachmentInput[]`. Each input is a regular file with a filename,\nMIME media type, and exact `ArrayBuffer` bytes; strings, base64 inputs, inline uploads,\nand multipart container uploads are not supported.\n\nFor example, given an exported PDF as `pdfBytes: ArrayBuffer`:\n\n```ts\nusing draft = await gmail.createDraft({\n  to: [\"recipient@example.com\"],\n  subject: \"Cloudflare at a Glance\",\n  text: \"Attached is the one-page overview.\",\n  attachments: [{\n    filename: \"cloudflare-at-a-glance.pdf\",\n    mimeType: \"application/pdf\",\n    content: pdfBytes,\n  }],\n});\n```\n\nFor an existing draft, `draft.addAttachment(input)` returns a readable, removable\n`GmailDraftAttachment`. `draft.attachments()` lists `{info, attachment}` entries, and\n`entry.attachment.remove()` removes that exact part, even when filenames are duplicated.\nDispose attachment capabilities when finished. `draft.update()` preserves attachments;\nit does not replace their list. Message attachment capabilities remain read-only.\n\nReplies include only supplied files. Forwards preserve the original attachments and\ninline parts, then append supplied files. A forward draft's preserved source parts are\nimmutable; its added files can be removed.\n\nFiles may be empty, but each upload is limited to **10 MiB**. Filenames must be nonempty\nand contain no path separators or control characters; `.` and `..` are refused.\nThe upload's `filename` is authoritative: redundant MIME `name`/`name*` parameters are\ndiscarded, while other media-type parameters such as `charset` are preserved.\nOutgoing MIME is limited to **25 MiB**, including transfer encoding and message headers.\nThe binding's existing **50 MiB** retained-file quota is shared by uploads and forward\nsource snapshots, including uploads in active drafts.\n\nUploaded bytes are captured in the existing integrity-checked chunk store, not embedded\nin queued-action metadata. Pending reads expose the files before approval, and body edits\nand Durable Object restarts preserve their exact bytes. Approval descriptions include\nfilename, media type, size, and SHA-256, with uploaded bytes marked as incomplete for\nhuman review. Files remain retained while a pending action or active draft references\nthem and are released when the last reference is removed. Rejecting a prerequisite\ninvalidates dependent edits; reject those edits too to release their references.\n\nCalendar files retain their original line endings and charset rather than being normalized.\nNested `message/rfc822` files also retain their exact bytes, including trailing line endings,\nthrough edits and sends; duplicate `.eml` filenames and identical files remain separate parts.\nProvider draft rewrites correlate attachment identities by metadata and SHA-256 in the actual\nMIME order, so queued edits and removals still select the right part when inline parts move.\nAn imported attachment without a disposition keeps its identity when a draft rewrite\nemits the regular `attachment` disposition.\n\nDraft and send fingerprints are prefixed `v2:` and hash attachment bytes exactly. Approvals\nqueued before that upgrade keep their unprefixed fingerprints and still apply, including a send\nwhose response was lost, which is reconciled rather than sent again. See\n`plans/gmail-simulation.md` for how they are verified.\n\n## Google Chat and Gmail new-message hooks (optional)\n\nGadgets can watch a Chat conversation for new messages (see `docs/google-chat-capabilities.md`),\nand a Gmail inbox, label or thread for new mail (`subscribeNewMessages()` in `src/types.d.ts`).\nGoogle delivers both through Pub/Sub, Chat's by Workspace Events and Gmail's by `users.watch`, so\nthis needs a public URL and these steps in the same Cloud project as the OAuth client:\n\n1. Enable the **Google Workspace Events API**, the **Cloud Pub/Sub API**, and the **Gmail API**.\n2. Create a Pub/Sub topic for Chat, granting `chat-api-push@system.gserviceaccount.com` the\n   **Pub/Sub Publisher** role on it, and one for Gmail, granting\n   `gmail-api-push@system.gserviceaccount.com` the same role. One topic granting both may serve\n   for both.\n3. Create a service account for push authentication (it needs no roles). Whoever creates the push\n   subscriptions needs `iam.serviceAccounts.actAs` on it (e.g. **Service Account User**). Pub/Sub\n   signs push tokens as its service agent, `service-PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com`,\n   which needs `iam.serviceAccounts.getOpenIdToken` on this account. Its default **Cloud Pub/Sub\n   Service Agent** role on the project includes that; only if it lacks the permission, grant it\n   **Service Account Token Creator** on this service account.\n4. Create a **push** subscription on each topic with endpoint\n   `${BASE_URL}/pubsub` (e.g. `https://example.com/gatekeeper/google/pubsub`), **Enable\n   authentication** with the service account from step 3, and audience set to that same endpoint.\n5. Set these values for this worker (in `.env` locally), both topics to the same one if it serves\n   for both:\n\n   ```bash\n   PUBSUB_CHAT_TOPIC=projects/your-project/topics/your-chat-topic\n   PUBSUB_GMAIL_TOPIC=projects/your-project/topics/your-gmail-topic\n   PUBSUB_PUSH_SERVICE_ACCOUNT=your-push-account@your-project.iam.gserviceaccount.com\n   ```\n\nIf the deployment sits behind Cloudflare Access, add a bypass for `/gatekeeper/google/pubsub`;\nthe worker instead accepts only pushes whose Google-signed token names that endpoint as audience\nand `PUBSUB_PUSH_SERVICE_ACCOUNT` as sender. That token proves a push came through a push\nsubscription, not who published to its topic, so each topic's Pub/Sub principals are trusted with\nhooked messages: anyone who can subscribe to it reads them all, and anyone who can publish to it\ncan inject messages into hooks of either kind, since a push is routed by its shape rather than\nits topic. Grant those roles to no one beyond step 2. A Gmail push carries only a mailbox address\nand a history ID, so a forged one can only make the worker read that mailbox's new history with\nthe connected account's own credentials. Without its topic and the push service account a kind of\nhook is refused, and everything else works as before.\n\nDisconnecting a Google account drops its hooks from the mailbox's and each space's driver, so\nnothing goes on reading or renewing with credentials that are gone; a watch or subscription no\nhook uses any more lapses, as when the hooks are disabled.\n\n## Worker Preview OAuth callbacks\n\nDeployments using Worker Preview hostnames can register one stable Google callback and relay the\nresult to the preview that initiated authorization. Configure the stable Worker and its previews\nwith the same `OAUTH_STATE_SIGNING_SECRET` and `OAUTH_ALLOW_PREVIEW_REDIRECTS=true`. Set the fixed\nredirect only on previews:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-google.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the authorized redirect URI in Google. The preview sends that fixed\nURI to Google and carries its own callback in signed, short-lived OAuth state. The stable Worker\naccepts return URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only the OAuth\nresult and state. Normal deployments should omit these settings and continue using\n`${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Wrangler\nstores baseline and Preview secrets separately, so provision the same signing value in both places.\n\n## Creating files\n\nGoogle Docs, Sheets and Slides are creatable resource types: an agent can ask for a new one with\n`createExternalResource`, giving a one-line title (trimmed, at most 256 characters), and its binding\nworks at once, before anything exists at Google. Until the creation is approved the gatekeeper\nsimulates an empty file: a Doc reads as one empty tab and queues edits as usual, a spreadsheet as\none empty `Sheet1` of 1000 × 26 cells, which takes no change until it exists, and a presentation as the one Google creates, a 16:9 deck\nwith a title slide, which queues changes as usual. Of the default theme's layouts it offers only\nfive (title slide, section header, title and body, title only, and blank); the others become\navailable once the presentation exists. Nothing reaches Google and no account is involved.\n\nThe approver picks which of their connected Google accounts to create the file in; it lands in\nthat account's My Drive, and the binding switches to the real file. Doc edits queued against the\nsimulation then apply to the created document like any other edit, and Slides changes apply to the\ncreated presentation unchanged.\n\nThat works for Slides because Google gives every new presentation the same object IDs (master\n`simple-light-2`, title slide `p`, and layouts `p2` to `p12`, seen both on a personal account\nthrough the API and on a Workspace account in the editor), so the simulation reads a recording of\none, `src/blank-presentation.json`: each read a session makes, through the session's own field\nmasks, less the presentation's ID, title and revision and the layouts it does not offer.\n`pnpm --filter @gadgets/google-gatekeeper record:blank-presentation <token-file>` records it again;\nthe file holds an access token with the `presentations` and `drive.file` scopes. It creates two\npresentations, refuses to record unless they read the same, and deletes them; with `--check` it\ncompares them with the committed recording instead. Should Google's new presentation change, a\nqueued change naming a slide, element or layout the created presentation lacks fails when\napproved, writing nothing.\n\nGoogle's create calls take no idempotency key. A retried approval after a lost reply binds the file\nalready created (the gatekeeper records it), but a file can still be orphaned in the approver's\nDrive: a create request that times out or fails with a 5xx after Google acted, a crash between\nGoogle's reply and that record being written, or a creation rejected after an approval whose\nsuccess was lost. Every created file's ID is logged (`google.creation.file.created`), so an orphan\ncan be found.\n\n## Office files\n\nGoogle's Sheets, Docs and Slides APIs cannot edit an Office file stored in Drive (`.xlsx`, `.xls`,\n`.xlsm` and `.ods`; `.docx`, `.doc`, `.odt` and `.rtf`; `.pptx`, `.ppt` and `.odp`): Sheets and\nSlides refuse to open one, and Docs reads one but refuses every edit to it. So the Docs, Sheets and\nSlides pickers list these files too, named with their extension and marked \"Makes a Google …\ncopy\", and connecting one converts it. The connect dialog says what converting does and offers\n**Convert**, which has Drive copy the file into the native type (`files.copy` with the native MIME\ntype: Google converts it, and no file content passes through the Worker). The copy is named after\nthe original without its extension, lands next to it when the original's folder takes new files and\nat the root of My Drive otherwise, and is what gets connected. The original is never changed, and later edits\nto it do not reach the copy. The conversion is Google's, and loses what Google's import loses, such\nas macros, some formulas, and complex layout.\n\n**Convert** asks for the Office import grant (see the scopes above) when the account does not yet\nhold it. Workshop opens Google's consent pop-up, and the dialog waits up to three minutes for the\ngrant before giving up. Each copy carries app properties naming its source (`gadgetsConvertedFrom`)\nand the source's modification time when it was copied (`gadgetsSourceModified`), so choosing the\nsame file again offers the copy made before instead of making another; using it reads only Drive\nmetadata, so it needs no new grant. App properties are private to the OAuth client that set them\nbut not to a user, so only copies this deployment's client made are found, and of those only ones\nthe account can edit; that may include a copy another user made in a folder they both edit. The\nlookup is best effort: a missed match makes another copy. A copy request is never retried, since a\nlost reply may still have made one. A file whose owner has turned off copying, a file in the trash,\nor one larger than Google imports into that type (`about.maxImportSizes`) is refused before\nanything is copied.\n\nA pasted link to an Office file fails to connect, and its error offers to open the connect dialog,\nwhere it can be converted. An Office file bound any other way is refused with a message pointing to\nthe dialog: by Sheets and Slides when they cannot open it, and by Docs on connecting, since Docs\nreads it. An Office file already bound as a Doc answers each approved edit with the same message.\n\n## Google Docs request costs\n\nConnecting a Doc reads its Drive metadata once, to refuse an Office file (see\n[Office files](#office-files)). A read costs one full `documents.get`, replayed for 10 seconds and then revalidated by a\nrevision-only `get`. Queuing an edit costs nothing more. Applying one costs a full `get` (the\nedit is materialized against the current revision), the `batchUpdate` carrying the edit and its\nwrite marker, and a second `batchUpdate` removing the marker; the document is reread after it only\nwhen more edits are queued behind it, since they must be replayed over the new revision.\n\n## Known limitations\n\nVery large Google Docs can exceed Durable Objects' 2 MB value limit after Markdown conversion,\ncausing tab listing, content reads, and edits to fail.\n\nA Google Slides presentation's slide summaries come from one response capped at 10 MiB. It holds the\ntext of every slide's shapes and speaker notes, but no styles or geometry: about 4 KiB a slide on a\nlive deck, so a presentation needs thousands of slides to exceed it. It also holds every master's\nand layout's name and background and the masters' theme colours, a few KiB in all. Slide content is\nread one slide at a time, capped at 2 MiB each. While changes await approval, reads also fetch the\nslides they address, so each can be checked as approving it would: slide content reads the other\nslides of any batch touching a slide it shows, since a batch applies all or none, and the summaries\nread every slide a queued batch changes, since they hold no tables or grouped shapes. Large batches\nawaiting approval therefore cost reads more requests against Google's per-user quota.\n\nThe simulated presentation was recorded from an English-locale account, so until it is created it\nreads with locale `en` and English layout names. Google translates those names: a presentation a\nSpanish-language account made in the editor read locale `es-419` and layout names such as\n`Título y cuerpo`, with the recording's object IDs, placeholders and geometry. One created through\nthe API in a non-English account has not been checked, so an approver's presentation may name its\nlayouts differently from what the agent saw before approval, including in an approval card queued\nthen. `createSlide()` takes a layout's ID, never its name.\n\n## Google Slides reads\n\n`getSlides()` reads each element as the Slides editor shows it: its box and rotation, and what it\nsets rather than inherits. Shapes carry their text and its formatting (links to URLs or to slides,\nfont weight, paragraph indents and bullets), and their fill, its opacity, and their outline with its\ndash. Tables carry their cells, column widths, row heights and borders, collapsed to one `border`\nwhen every edge matches. A table's `size` is nominal, 3,000,000 EMU square on every live read, so\nits box is its columns and rows added up; a row's height is the least it may have, since Google\ndraws it taller to fit its text and reports that nowhere. Lines carry their ends, computed from\ntheir transform, arrowheads, and the elements they connect; videos their YouTube or Drive ID; and\nimages and Sheets charts their source URL and spreadsheet. The `contentUrl` an image or chart also\nhas is a bearer URL, like a thumbnail's, so it is never read. `getPresentation()` lists the\npresentation's layouts with their placeholders, for `createSlide()`.\n\n## Google Slides edits\n\nA `.pptx` or other Office presentation cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound presentation accepts six changes, each queued for approval: `updateSlides()`,\n`duplicateSlide()`, `deleteSlide()`, `moveSlides()`, `createSlide()`, and `setSlidesSkipped()`.\nChanges are journaled with the kit's `ActionJournal`, and apply in the order they were queued.\n\n`updateSlides()` takes up to 50 changes applied together: find-and-replace or whole-text\nreplacement in shapes, table cells, and speaker notes, text and paragraph formatting (including\nbullets), shapes and their fill, outline and vertical alignment, moving, resizing, rotating,\nstacking and deleting elements, alt text, images by public `https:` URL, and tables with their\nrows, columns and cell fills. Elements a batch creates get IDs the gatekeeper mints: later changes\nin the batch can address one by its `ref`, and later batches by the ID returned for it. An image\nURL is never fetched by the gatekeeper: Google downloads it when the change is applied, so nothing\nis read from it before approval.\n\nTwo of its changes reach beyond a slide's elements:\n\n- `setBackground` sets the background of a slide, a layout or a master, named by exactly one of\n  `slideId`, `layoutId` and `masterId`: a colour, `\"none\"`, a picture by public `https:` URL, or\n  `null` to inherit the background of the layout or master the page is made from. A master has\n  nothing to inherit from, and Google turns a reset there into no background, so `null` is refused\n  for one. As with images, Google, not the gatekeeper, downloads a picture URL when the batch is\n  applied. Reads report a picture background only as `\"picture\"`, and never request its content\n  URL, a short-lived URL that reads as the account that fetched it.\n- `setThemeColors` sets some of a master's 12 theme colours. Google takes all 12 at once, so the\n  colours not given are merged in from the master's scheme as the batch is planned against its\n  fresh read: a colour a collaborator changed meanwhile is kept, and one changed after that read\n  makes Google refuse the revision-pinned write, so it is planned again.\n\n`getPresentation()` lists the masters, with their backgrounds and theme colours, and the layouts,\nwith their master and background, but not their elements; each slide reports its layout, its master\nand a background of its own. The Slides API cannot change which layout an existing slide is made\nfrom.\n\nA batch is queued as one of three action kinds, which share everything but which ones a user may\nlet apply without asking: \"Slide text edits\" (only text edits), \"Slide formatting and arrangement\"\n(only formatting text, setting no link or font, since Google keeps their whole strings; formatting\nparagraphs and shapes; moving or stacking elements; and setting slides' backgrounds to a colour,\n`\"none\"` or `null`), and every other batch, mixed ones included, which always waits for approval.\nA picture background, any change to a layout or master, and theme colours therefore always wait:\nthey change slides the user is not looking at, or make Google fetch a URL.\n\n`createSlide()` adds a slide made from one of the presentation's layouts, at the start, the end, or\nafter a given slide. The gatekeeper mints the slide's ID and one for each placeholder it gets from\nthe layout, read from the layout's page when the change is queued, so later changes can fill the\nplaceholders before it is approved. Reads show the new slide with the layout's placeholders, empty,\nat the layout's size and position, as a live probe showed Google makes them, but for a slide\nnumber: Google adds one only while the presentation shows slide numbers, which a new one does not,\nand otherwise ignores its mapping, so none is minted and a slide number appears once the slide\nexists (`instantiatedPlaceholders()` in `slides-simulation.ts`).\nGoogle takes a new slide's layout only from the master of the slide before it, of the first slide\nwhen it goes first, or of the presentation's first master when it has no slides, so a deck with\nslides imported in another theme has layouts that fit only some places: layouts and slide\nsummaries carry their master's ID, and a layout that does not fit where it is to go is refused\nwhen queued, and again on approval against a fresh read, as is one deleted since; approval also\nrefuses one that has lost a placeholder the slide was to get. Google deletes a master and its\nlayouts with the last slide on it, unless it is the presentation's first master, as a live probe\nshowed, so reads drop a master whose last slide a queued deletion removes, with its layouts, and\na slide queued after it on one of those layouts is refused. Google names a new slide's speaker notes\nonly as it creates the slide, so they cannot be edited until it is approved. Adding a slide always\nwaits for approval.\n\n`setSlidesSkipped()` skips slides, leaving them out when presenting, or shows them again. It is\nthe \"Skipping slides\" kind, which a user may let apply without asking: it destroys nothing, and\nis undone the same way. It applies against the presentation's outline alone, reading no slide.\n\nReads show queued changes as if applied, by replaying them over Slides' own JSON before it is\nprojected; thumbnails show the presentation as saved. Each change is replayed as Google documents\nits request, and one the replay cannot follow exactly is refused instead: a bullet list started\nright after another list item, which Google may join to that list; table rows or columns inserted\nor deleted beside a merged cell; deleting a grouped element that would leave its group with one;\nrotating a video, which Google cannot shear; and new text filling a list item whose bullet is\nstyled apart from its text, which Google would restyle to match. The replay makes no claim about\nwhat Google renders: shrinking text to fit, wrapping, the box Google fits an image to, the borders\naround new or deleted table rows and columns, and the formatting new rows and columns take appear\nonce applied. Table rows and columns are sized as live reads show Google sizing them: a new table\nshares its box evenly, a new row or column copies the one beside it, every column then narrows so\nthe table keeps its width, and the lines a deletion leaves keep theirs. It does follow Google in\nturning a shape's autofit off once its text changes, fixing the font sizes and line spacings the\ntext sets at what the autofit had shrunk them to. A replacement takes the style of the text it\nreplaces, and text left unchanged at either end of a match is not rewritten, so it keeps its own.\nFormatting a whole list paragraph also updates its bullet's style in replay, which later\nreplacements use when checking whether they can keep that style. Setting a font sets it at\nregular weight, so the text is no longer bold unless the change says so. A slide number or other\nAutoText can only be replaced whole.\n\nEach approved change is one `batchUpdate`, planned against a fresh read and pinned to its revision\nwith `requiredRevisionId`, so a change applies only to the text it was planned against: one that no\nlonger applies fails without writing, and a concurrent edit makes Google refuse the write so it is\nplanned again. A write whose response is lost may have been committed, so it is only ever resent as\nfirst sent, at the same revision. If that is refused, a read decides whether it landed, counting\nonly what the write would have changed: an element it created that it does not also delete, an\nelement it deleted that was there before, text it edited, a table it adds or deletes rows or columns\nof, by its size and its cells' positions, spans and text, a theme colour it set, and a background\nother than a picture it left a page with, that the page did not already have (every picture reads\nalike). If the read cannot tell, as for a batch that only formats or moves elements or leaves a page\na picture, the change is marked as having an unknown outcome and never retried.\n\nGoogle returns a presentation's revision only to an account that can edit it, so a view-only\naccount can read a presentation but every change to it fails. A queued change is stored in one\nDurable Object value, so each is capped at 100 KiB.\n\n## Google Sheets edits\n\nAn `.xlsx` or other Office spreadsheet cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound spreadsheet accepts `updateSpreadsheet()`, which queues up to 50 changes for\napproval as one batch, applied all or none and in order: `writeCells` enters values in every cell\nof a bounded A1 range that names its sheet, such as `'Sales 2026'!A1:C3`, and `clearRange` clears\na range's contents, keeping its formatting. Batches are journaled with the kit's `ActionJournal`, and\napply in the order they were queued. A spreadsheet opened from a Drive binding has no change method.\n\nInput is literal and typed, so nothing depends on the spreadsheet's locale: a string starting with\n`=` is a formula, any other string is text exactly as given, never read as a number or date,\nnumbers and booleans are entered as such, and `null` clears a cell. A date is entered as its\nserial number in a cell formatted as a date, as `formatCells` can format it. Text starting with\n`=` cannot be written as text, and formula-mode reads show it exactly as a formula, so writing\nback what was read can turn it into one. A batch of only literal values and clears is queued as\n\"Sheet value edits\", which a user may let apply without asking; a batch with any formula always waits for approval.\nA formula naming `IMPORTRANGE`, which reads another spreadsheet and so escapes the binding, or\n`IMPORTDATA`, `IMPORTHTML`, `IMPORTXML`, `IMPORTFEED` or `IMAGE`, which make Google fetch a URL, is\nrefused in any letter case, outside string literals. Formulas the spreadsheet already holds are\nnot inspected, and one of its named functions could wrap a refused function, which is why every\nformula needs approval.\n\nReads show queued changes as entered and never guess what Google computes. Formula-mode reads are\nexact, and raw reads are exact for literal values. A queued formula's result, and in formatted\nreads a queued number or boolean, whose display depends on number format and locale, read `null`,\nand their cells are listed in the range's `pendingCells`. Formulas elsewhere keep their saved\nresults, even when they depend on queued cells. Google tidies the references in a formula as it is entered: column\nletters upper-cased, a sheet named by its title as the spreadsheet spells and quotes it, a range of\none cell collapsed to that cell. A queued formula is shown, approved and written already tidied, but\nwith its whitespace as typed, which Google may later drop. A queued change that no longer applies,\nsuch as one writing to a sheet since shrunk, is reported as `queuedChangeConflict`, and it and the\nchanges queued after it are not shown.\n\nThe same batch can change a spreadsheet's structure: `addSheet`, `renameSheet`, `duplicateSheet`\nand `deleteSheet` act on its sheets, and `insertRows`, `deleteRows`, `insertColumns` and\n`deleteColumns` on a sheet's rows and columns. A change names its sheet by title in `sheet`, as a\nrange does, including a title an earlier change of the batch gives a sheet it adds, copies or\nrenames, so it never needs an ID the spreadsheet has not assigned yet. The gatekeeper mints the ID\nof each sheet a batch adds. A batch with any structural change always waits for approval.\nEvery default is sent explicitly, so the approver sees what will be made: a new sheet has Google's\n1,000 rows and 26 columns and comes last, and a copy is titled `Copy of <title>` and placed right\nafter its source (Google would put it first, under a name of its own choosing). Changes are refused\nwhen queued that would delete the last visible sheet, every row or column of a sheet, or every one\nof its unfrozen ones, delete a sheet of more than 50,000 cells, whose content the guard below could\nnot cover (delete it in Google Sheets instead), grow a sheet past 18,278 columns or the spreadsheet\npast 10,000,000 cells, or change or copy a sheet holding a protected range the account cannot edit. Reads show the\nqueued structure: sheets, rows and columns where the changes put them, cells moved with their rows\nand columns, and formulas rewritten as Google rewrites them, references shifted, grown, shrunk or\nturned into `#REF!`, and sheet titles requoted. A formula's value is shown only while every\nreference it holds keeps exactly its cells; one whose reference grows, shrinks or breaks, or that\ncalls a position-dependent function such as `ROW`, `OFFSET` or `INDIRECT`, or `QUERY`, whose query\nnames columns by letter, or uses a name such as a named range, or calls a function the\ngatekeeper does not know to depend only on its arguments (one of the spreadsheet's named functions\nmay hide references or such calls), reads `null` and is listed in `pendingCells`, as are cells a\nqueued formula enters. Cells of inserted rows and columns and of\nadded sheets read as empty. Deleting a sheet leaves the formulas that refer to it as written, as\nGoogle does; they read `#REF!` once it applies, and stay so even if a later sheet takes the title.\nOnce rows, columns or sheets are queued, cells Google fills with nothing entered in them, such as an\narray formula's results (`SORT`, `SEQUENCE`, `FILTER`, `QUERY`) or a pivot table, read `null` and\nare listed in `pendingCells`.\n\n`formatCells` formats every cell of a range, keeping its contents: bold, italic, underline,\nstrikethrough, font size, text and fill colour (`#rrggbb` or a theme colour such as `ACCENT1`),\nnumber format (a type and an optional pattern in Google's syntax), horizontal and vertical\nalignment, wrapping, and borders along the range's edges and between its rows and columns. A field\nset to `null` is reset to the default, and a border set to `null` is removed; fields left out are\nkept. A batch of only `formatCells` changes is queued as \"Sheet formatting\", which a user may let\napply without asking; mixed with any other change it waits for approval. `readFormats()`, which a\nspreadsheet opened from a Drive binding offers too, reads the formatting set on up to 10,000 cells\nof a range, never their values, and shows queued formatting merged in queue order. Cells of rows\nand columns a queued change inserts, whose formatting Google inherits from a neighbour in ways that\ndiffer between rows and columns, read `null` and are listed in `pendingCells`. A border along a\nrange's outer edge removes the facing border of the cell beside it, since Google gives an edge one\nowner, and reads show that. A formatted value read of a cell whose number format a queued change\nsets or resets reads `null` too, listed in that range's `pendingCells`, unless a queued change\nleaves the cell empty: a format such as `;;;` can hide what a blank-looking cell holds. So does a\nvalue queued on an inserted row or column, which takes the number format of the line before it.\nGoogle accepts any font size and number pattern, so the gatekeeper's checks are the only ones: a font size is an\ninteger from 1 to 400, and a pattern holds 1 to 200 characters and no control characters.\nFormatting is not guarded: a collaborator's formatting change made after a batch is queued is\noverwritten when it applies.\n\nSheets has no revision to pin a write to, so each approved batch is planned against a fresh read\nand guarded by a digest of what it overwrites or removes: the cells it writes and the rows,\ncolumns and sheets it deletes, as entered, the title and size of each sheet it addresses, and,\nwhen it adds or copies a sheet, the order of every sheet, ignoring whitespace in formulas. Cells\nthat changes queued before it write are expected to hold what those enter, and sheets those\nrestructure to have the shape they leave, so a batch built on one that was rejected or failed fails\ntoo, as does a batch with a formula when any structural change queued before it was not applied,\nand a batch adding or copying a sheet when a sheet queued before it to be added, copied or deleted\nwas not. A batch guards at most 50,000 cells. A change whose cells or sheet\nchanged since it was queued fails without writing; a collaborator's edit between that read and the\nwrite is not caught. Each batch also creates a developer-metadata marker whose ID is minted at queue\ntime, which Google stores at most once, so a write whose response is lost is found by its marker or\nresent exactly as first sent. A lost write that cannot be resent, because its cells changed in the\nmeantime or Google refused the resend, or that three sends leave unfound, is marked as having an\nunknown outcome. A marker is visible only to the Google Cloud project of this deployment's OAuth\nclient, leaves no Drive revision, and is deleted by the first write at least ten minutes after its\nbatch was last sent, since a send given up on may commit until then. While a write with an unknown\noutcome could still land, later changes stay pending.\n\nA change is refused when it is queued if the connected account can only view the spreadsheet, or\nif it writes outside a sheet's grid or into a protected range the account cannot edit. A change\naddresses at most 10,000 cells and a batch 20,000, a cell holds at most 50,000 characters, the\nchanges queued at once may set values or formatting in at most 100,000 cells in all (a cell given\nboth counting twice, a copied sheet counting what changes queued to it set) and make at most 50\nchanges to rows, columns and sheets, and a queued batch is capped at 100 KiB. While such changes are\nqueued, a read or a batch may involve at most 5,000,000 characters of formulas times their number,\nsince each formula is rewritten through each of them, and hold at most 5 MiB of text as rewritten.\nGoogle allows 60 write requests a minute per user; a write over that quota stays pending.\nuser; a write over that quota stays pending.\n\n## Google Drive read-only bindings\n\nDrive exposes three permanent resource URL forms:\n\n- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: any ID the account token resolves is in scope. Listings use `corpora=user`, which Google defines as My Drive items the account created or opened plus items shared directly with it, so a shared drive's contents may be readable by ID without appearing in a listing. Bind a folder or file when this authority is too broad.\n- `https://drive.google.com/drive/folders/<folderId>` selects one folder or shared-drive root.\n- `https://drive.google.com/file/d/<fileId>/view` selects one file by its immutable ID.\n\nThe Drive picker opens on Google Drive, whose own choice binds the whole account, above My Drive, Shared drives and Shared with me. Locations list a page at a time. Search spans My Drive, \"Shared with me\" and every shared drive the account is a member of (`corpora=allDrives`) and says where each result lives, and a Drive, Docs, Sheets or Slides link pasted into it answers with the one folder or file the link names. A pre-filled folder or file opens where it lives, with breadcrumbs up to My Drive or its shared drive, or with none when an ancestor is out of reach or more than 20 folders up. All of this runs on the Drive resources' own grant except listing Shared drives, which also needs the shared-drives grant above: until the account holds it, opening Shared drives offers **Allow access**, and a shared drive's folders stay reachable through search, a pasted link or a pre-filled URL. Folders whose children the account cannot list are shown but cannot be opened or bound; on a pre-filled node's breadcrumbs, where Google's [folders with limited access](https://developers.google.com/workspace/drive/api/guides/limited-expansive-access) put them above a file shared directly, they can be opened but are not offered. A shortcut stands for its target, which a listing says nothing of, so a page's shortcut targets are read in one batch: a shortcut left behind when its target was deleted, unshared or trashed is left out, and one to a folder is judged as that folder would be. A search Google reports as incomplete fails rather than presenting partial results as complete.\n\nA folder URL carrying `?resourcekey=` is not supported: the key is dropped, and the binding then fails with a Drive 404 for anyone whose access to that folder comes from the link rather than from a direct grant. Google requires resource keys only for items shared by link before September 2021, and only for link-access users — an owner or anyone granted access directly is unaffected, even when the URL they paste happens to carry a key. Such a folder needs direct access to connect.\n\nThe agent-facing `GoogleDriveReadSession` covers account and exact-file bindings. `GoogleDriveFolderSession` is positioned at the selected root and exposes only its current folder's direct children: `list()`, provider-side structured `search()`, `getEntry()`, native Doc/Sheet/Slides opens, and `openFolder()` for one live direct child. Listing and search return disposable RPC cursors; child folders and native content sessions are independently disposab\n\n<!-- [TRUNCATED AT 50KB] -->","isInternal":false,"tokens":11193,"sizeBytes":50040},{"name":"README.md","path":"packages/gatekeeper-homeassistant/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-homeassistant/README.md","title":"gatekeeper-homeassistant Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper Home Assistant\n\nThis package provides a [Home Assistant](https://www.home-assistant.io/) gatekeeper for Gadgets. It lets a Gadget read state from a connected HA instance, call services on devices (turn lights on/off, set thermostats, lock doors, etc.), edit Lovelace dashboards, render templates, and read history — all mediated through the standard Gadgets approval queue.\n\n## Authentication\n\nThe gatekeeper uses Home Assistant's **long-lived access token (LLAT)** + **URL** combination. There's no OAuth flow because:\n\n- Each HA instance has a different URL (no central directory).\n- LLATs don't expire (10-year default lifetime).\n- LLAT is the path every other HA-adjacent tool uses, so users are familiar with it.\n- It works for both Nabu Casa Cloud users and self-hosted users on a LAN.\n\nWhen the user connects an account, they paste their HA URL and an LLAT into a form. The gatekeeper validates by calling `GET /api/`, then stores both in the per-user Durable Object.\n\n### Reachability\n\nThe gatekeeper makes HTTP and WebSocket calls to the configured HA URL. On a Cloudflare-hosted Gadgets deployment, that means HA must be publicly reachable (via Nabu Casa, Cloudflare Tunnel, port-forwarding, etc.). On a self-hosted (workerd) deployment, LAN addresses like `http://homeassistant.local:8123` and `http://192.168.x.x:8123` work fine — that's the intended setup for users running Gadgets on the same network as their HA.\n\n## Resource granularities\n\nFive granularities are exposed; each comes with its own configurator UI for picking a specific resource from the connected HA instance:\n\n| Granularity   | What it grants                                                      |\n| ------------- | ------------------------------------------------------------------- |\n| Whole instance | Access to every area, device, entity, dashboard, and service.       |\n| Area          | A single area (room) — its devices and entities only.               |\n| Label         | All entities carrying a particular HA label.                        |\n| Device        | A single physical device and the entities it provides.              |\n| Entity        | A single entity (light, sensor, switch, etc).                       |\n\n## TypeScript API\n\nBindings expose one of `HomeAssistantSession` (whole-instance), `Area`, `Label`, `Device`, or `Entity`, depending on the granularity granted. Common operations:\n\n```ts\n// Whole-instance:\nconst config = await session.getConfig();\nconst areas = await session.listAreas();\nconst lights = await session.listEntities({ domain: \"light\" });\n\n// Capability-based:\nconst light = await session.getEntity(\"light.kitchen\");\nawait light.turnOn({ brightness: 200 });\nconst state = await light.getState();  // reflects the simulated post-write state\n\n// Area-scoped service call (affects every entity in the area):\nconst livingRoom = await session.getArea(\"living_room\");\nawait livingRoom.callService(\"light\", \"turn_off\");\n\n// Templates:\nconst temp = await session.renderTemplate(\"{{ states('sensor.outside_temp') | float }}\");\n\n// Dashboards:\nconst dashboard = await session.getDashboard(\"lovelace\");\nconst dashboardConfig = await dashboard.getConfig();\n// ... mutate the JSON ...\nawait dashboard.saveConfig(dashboardConfig);\n```\n\nSee `src/types.d.ts` for the complete API and `@example` blocks for every method.\n\n## Approval & simulation\n\nEvery read calls `authorizeObservation` and every write goes through `submitAction`. Writes do **not** execute against HA until the user approves them.\n\nUntil approval, reads reflect a **simulated post-action world**: e.g. after `entity.turnOn()`, an immediate `entity.getState()` shows `state: \"on\"` even though HA hasn't been touched. This lets agents chain reads and writes without waiting for user approval. Simulation predicts final states only — no transition timing, and unrecognized service calls (custom integrations, scenes, scripts, templates) leave state untouched.\n\nSee the **APPROVAL & SIMULATION** section at the top of `src/types.d.ts` for the full list of caveats.\n\n## Phase 2 — not yet implemented\n\n- **Caching.** Every read does a fresh registry fetch. Once caching is added, area / device / label-scoped reads will be much cheaper.\n- **Hooks (push events).** `setHook` is a no-op. WebSocket `subscribe_events` / `subscribe_entities` would enable a `HomeAssistantHook` interface — e.g. a Gadget receiving a callback when a motion sensor fires.\n\n## Implementation notes\n\n- **Service calls go through the WebSocket API.** HA's REST `POST /api/services` endpoint expects target fields flattened at the top level of the body and has uneven support for area / label / floor targets across versions. The WebSocket `call_service` command is HA's modern path and supports the full target shape natively.\n- **All actions get an integer `id`** assigned by a per-DO counter. Pending actions are stored under `pending:<id>` in DO storage so reads can simulate the post-action state.\n- **Defensive validation.** Malformed action bodies (e.g. an agent passing a single options object instead of positional arguments) fail synchronously with a rich error message that includes a suggested corrected call.\n\n## Files\n\n```\nsrc/\n├── homeassistant.ts        # main: Vendor, UserAccount, User, GatekeeperImpl, Session impls\n├── homeassistant-api.ts    # REST + WS clients (incl. timeouts and error-body sanitization)\n├── approvals.ts            # describeAction, executeAction, applyRevertForEntity, ...\n├── simulation.ts           # pure overlay-at-read-time helpers\n├── registry-utils.ts       # shared resolveTargets helper\n├── types.d.ts              # public Session/Area/Label/Device/Entity/Dashboard interfaces\n└── configurator/           # 5 picker UIs (instance / area / label / device / entity)\n```\n","isInternal":false,"tokens":1257,"sizeBytes":5830},{"name":"README.md","path":"packages/gatekeeper-kit/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-kit/README.md","title":"gatekeeper-kit Documentation","category":"plugin-manifest","format":"markdown","content":"# `@gadgets/gatekeeper-kit`\n\nShared building blocks for gatekeeper connect flows, credentials, actions, observations, cursors,\ncaching, and simulation. These modules replace security-sensitive plumbing that gatekeepers had\nimplemented separately.\n\nThe kit provides a pragmatic baseline for common gatekeeper behavior. Provider-specific or esoteric\nbehavior can use the canonical TypeScript interfaces directly while retaining whichever leaf\nmodules still fit.\n\n## Current scope\n\nOnly Layer 1 is shipped: independent leaf modules exposed through package subpaths. Import them à la\ncarte; none requires a gatekeeper assembly.\n\nLayer 2, including `KitUserAccountBase`, `KitVendorBase`, and `KitGatekeeperBase`, remains a proposal\nin [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md). No gatekeeper consumes it.\n\nThe code and tests define the shipped behavior. The plan records the design and the unshipped\nproposal.\n\n## Responsibility boundary\n\nThe kit owns provider-independent mechanisms inside each imported module. The gatekeeper owns\nprovider facts, policy, and the assembly around those modules. Importing a leaf does not transfer\nthe duties in the right-hand column.\nWorkshop owns connect completion ticket minting and redemption.\n\n| Concern | Kit owns | Gatekeeper owns |\n| --- | --- | --- |\n| Assembly and lifetime | Independent leaf contracts; no Layer 2 assembly is shipped. | Worker, account, and resource-facet RPC surfaces; stub disposal; keeping stateful kit objects stable for one Durable Object activation. |\n| Connect | Nonce generation and comparison, the two-stage handshake, staged reconnect escrow, hardened handoff HTML, and browser mutation guards. | Provider exchange, staged payload shape, exact live commit, provider cleanup policy, routes, and authorization parameters. |\n| Preview OAuth | Signed state, stable-to-preview callback relay, and return-host validation. | Deployment configuration, provider callback parameters, issuer checks, and retaining the exact redirect URI for code exchange. |\n| Credentials | Atomic credential records, refresh coalescing, identity and connection generations, replay, and rejection adjudication. | Grant shape and projection, the refresh callback (see OAuth token endpoint), and display-safe errors. |\n| Credential expiry and simple auth retry | Durable deduplication of expiry notifications and one-refresh/one-replay helpers. | Deciding what proves grant expiry beyond an OAuth `invalid_grant`, provider refresh and revoke calls, and choosing the coordinator flow versus the standalone retry helper. |\n| OAuth token endpoint | Token-request hardening, response parsing, PKCE, the default `invalid_grant` death rule, and the canonical refresh merge. | Endpoints and client authentication, provider death evidence beyond `invalid_grant`, non-canonical merges, and non-RFC revocation. |\n| Actions | Durable submission and resolution, serialization, journaling, retention mechanics, connection fences, and dependency tracking and refusal. | Approval text, provider calls, idempotency and reconciliation, action-specific simulation, revert semantics, and retention policy. |\n| Action files | Bounded chunk storage, integrity verification, aggregate accounting, deletion, and orphan-pruning mechanics. | Byte limits and key prefixes, keeping references in action records, describing the same bytes that will be applied, and releasing files with their records. |\n| Simulation | Ordered pending-action views, pure replay with explicit incomplete results, and durable provisional-ID allocation and binding. | Target extraction, state transitions, unsupported-effect policy, provider ID syntax, and projecting pending effects onto every affected read. |\n| Observations | Admission strategies, tracked-collection fencing, exclusion derivation, and the guarded authorization call. | Calling the gate for every read, truthful escaped descriptions, choosing the matching strategy and scope, ACL oracles, and collection canonicalization. |\n| Cursors | Serialized walks, buffering, a per-call provider fetch bound, common page/offset/token continuation, merging injected simulated rows, filtering hooks, and per-page authorization callbacks. | Provider page adapters, page limits and termination semantics, the injected rows and their order, resource lifetime, and the observation description for each returned page. |\n| Cache | Authority partitioning, stale-fill fencing, TTL storage, single-flight loads, and invalidation. | Cache families, names and keys, authority dimensions, TTLs, value projection, and deciding which reads are safe to cache. |\n| HTTP endpoints and responses | Operator-supplied endpoint normalization, dot-segment-safe path-segment encoding, common no-access probes, byte-capped text decoding, and best-effort error-body prefixes. | Host allowlists, redirect and header policy, provider id grammar, response schemas, provider errors, and binary or streaming limits. |\n\nWhen a provider does not fit a leaf, implement the canonical TypeScript interface directly and keep\nthe other leaves that still fit. Bypassing a leaf also means owning its guarantees for that concern;\ndo not call around a stateful module while relying on its journal, fence, or lifetime elsewhere.\n\n## Start here\n\n- For an OAuth-shaped provider, start with [Connect flows](USAGE.md#connect-flows), then read the\n  [credentials guide](USAGE.md#credentials). They cover browser handoff sequencing, account-side\n  storage, consumer-side RPC, refresh, replay, expiry, and action fences.\n- For provider writes, use [`./actions`](#module-inventory) and read\n  [Actions and files](USAGE.md#actions-and-files).\n- For every gatekeeper's observer methods, select a strategy from `./observers` and read\n  [Observations](USAGE.md#observations).\n- For details attached to one class, function, option, or error, read that export's JSDoc.\n\nImport from the narrow subpath:\n\n```ts\nimport {\n  CredentialCoordinator,\n  CredentialSource,\n} from \"@gadgets/gatekeeper-kit/credentials\";\n```\n\n## Module inventory\n\n| Subpath | Purpose | Use it when |\n| --- | --- | --- |\n| `./connect-nonce` | Nonce generation, expiry, and constant-time comparison. | A connect flow mints or checks its own nonce. The handshake and credential modules already use it. |\n| `./connect-handshake` | Two-stage `initiation` to `oauth` nonce storage, and the once-only `complete()` marker. | A connect link or form redirects through an OAuth provider, or has no single-use nonce to keep `complete()` from running twice. |\n| `./credential-stage` | Durable reconnect escrow, exact commit, and exact-stage cleanup. | A reconnect must remain inert until Workshop commits the completed stage. |\n| `./connect-pages` | Hardened connect HTML, escaping, and browser mutation guards. | A gatekeeper serves HTML from its own origin. |\n| `./credentials` | Account-side `CredentialCoordinator` and consumer-side `CredentialSource`. | An OAuth-shaped provider stores, refreshes, or rejects credentials. |\n| `./credential-expiry` | Durable, deduplicated `credentialsExpired()` notification. | An account has a Workshop connect callback to notify. |\n| `./auth-retry` | One refresh and replay without account adjudication. | A token flow has no `CredentialSource`; otherwise use `CredentialSource.run()`. |\n| `./cache` | Authority-partitioned Durable Object TTL caching. | Provider reads repeat and reconnects must fence stale fills. |\n| `./single-flight` | Keyed coalescing of concurrent work, without caching results. | Concurrent callers must share one in-flight request or write, such as two approvals that would each create the same resource. |\n| `./serial-queue` | FIFO serialization of async operations; a failed one never blocks the next. | Operations on shared state must not interleave, such as preparing changes that each check those queued before them. |\n| `./cursors` | Array, page-number, offset, and continuation-token cursors, with injected simulated rows (`CursorInjection`); `PageHookCursor`, which runs a hook on each page of a cursor the gatekeeper already built; and `SessionGitCache`, a git session's `GitCache` stub holder that advertises returned commit ids. | A session returns more rows than one RPC reply should carry. |\n| `./actions` | Action declaration, approval, application, retention, and journaling. | An operation has an externally visible side effect. |\n| `./action-files` | Bounded, integrity-checked action-file storage. | A queued action carries file bytes. Store only its `ActionFileReference` in the action. |\n| `./action-description` | Approval-text builder: prose plus exact typed fields under one byte budget, and the `descriptionIsComplete` claim. | An action's `describe` renders what it will write or send. |\n| `./simulation` | Pending-action replay and provisional-ID mapping. | An action continues with simulation and later reads must include its projected effect. |\n| `./observers` | Observer admission strategies and per-read authorization. | A gatekeeper implements its required observer methods. |\n| `./hook-delivery-queue` | Per-hook queue of pushed events: deduplication, retry with backoff, and bounded delivery runs from the driver's alarm. | A hook driver delivers provider pushes through `HookInitiator.startHook()`. |\n| `./oauth-client` | OAuth 2.0 token-endpoint client, PKCE, and refresh adapter. | A gatekeeper exchanges, refreshes, or revokes OAuth 2.0 tokens. |\n| `./preview-oauth` | Signed OAuth state and stable-to-preview callback relay. | Preview Workers share one callback registered with the OAuth provider. |\n| `./endpoint` | User-supplied provider endpoint normalization, and agent-supplied path-segment encoding (`pathSegment`, `pathSegments`). | A user enters a self-hosted provider URL, or an agent-supplied id goes into a request path. |\n| `./http-errors` | HTTP access-error classification and ACL probes. | A verifier distinguishes no access from provider failure. |\n| `./response-body` | Strict byte-capped body decoding, and `readTextPrefix` for error excerpts. | A gatekeeper reads any provider response body, or a connect form posted to a route that reads the body before checking the nonce. |\n| `./git-transport` | Git smart-HTTP framing: protocol-v2 fetch (pkt-line, filter specs, sideband demux into a raw pack stream) and send-pack ref updates, plus `simulateBranchHead` for a branch with queued pushes. | A gatekeeper implements `Gatekeeper.gitPull()` or a `push` action against a git host; it supplies only the URL, auth, and timeouts as a fetch callback. |\n| `./git-pull-transfer` | `startGitPull`: a one-shot raw PACK stream with a separate RPC-visible terminal status. | A gatekeeper implements `Gatekeeper.gitPull()` and must preserve provider errors across Workers RPC. |\n| `./git-objects` | Commit-id validation, raw commit-object parsing, and commit advertising (`advertiseCommits`, and `advertisePages` as a `PageHookCursor` hook). | A session returns commit ids (advertise them) or simulates reads of commits queued for push from their local bytes. |\n| `./git-diff` | Tree-to-tree diff over an injected `TreeDiffSource`, and `parseUnifiedPatch` for provider patches, both producing the unified-patch hunk shape agents see. | A pull/merge request's source branch has queued pushes and the provider cannot compute the diff yet, or a provider returns unified patches. |\n\n## Internal modules\n\nThe package does not export `kv`, `positive-int`, `per-storage`, `action-journal`, or\n`observer-tracker`. The last two are re-exported through `./actions` and `./observers`.\n\n## More documentation\n\n- [`USAGE.md`](USAGE.md): integration sequencing, storage, bounds, and operational sharp edges.\n- Exported-symbol JSDoc: exact API contracts and examples.\n- [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md): design record and Layer 2 proposal.\n- [`AGENTS.md`](AGENTS.md): package-specific contributor constraints and verification commands.\n","isInternal":false,"tokens":2530,"sizeBytes":11919},{"name":"README.md","path":"packages/gatekeeper-mcp-portal/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-mcp-portal/README.md","title":"gatekeeper-mcp-portal Documentation","category":"plugin-manifest","format":"markdown","content":"# MCP Server Portals gatekeeper\n\nConnects the deployment's own MCP portal as a Gadgets capability. An administrator configures one\nURL; everyone in the organization then reaches every MCP server the organization has approved\nthrough it, and no user ever types an endpoint. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_MCP_PORTAL` binding.\n\nThe intended deployment is a\n[Cloudflare MCP server portal](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/):\nAccess decides who may connect, Cloudflare Gateway logs and inspects what crosses, and this\nconnector turns what is behind it into typed capabilities that Gadgets can grant one server at a\ntime.\n\nFor endpoints a user supplies themselves, use [`gatekeeper-mcp`](../gatekeeper-mcp/README.md).\n\n## What it provides\n\nOne resource type, **MCP portal server**, at two grant breadths. A grant always names one upstream\nserver; \"everything the portal offers\" is not offered, since it would hand a Gadget every tool of\nevery system the organization has connected in one click.\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool of one upstream server, including ones it adds later | `<endpoint>#server=github` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools of that server | `<endpoint>#server=github&tool=github_a&tool=github_b` | `Mcp<Name><tag>Session` |\n\n`<tag>` is four hex characters derived from the resource URL. It matters most here: two grants\npinning different tools of one upstream server share both the name and the endpoint, so the scope is\nthe only thing that distinguishes them.\n\nThe session API — a typed method per described tool, plus `callTool`, `getActionResult`, and\n`listTools` with progressive search/name options — is the same as\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).\n\nA server-wide grant can cover more tools than one catalog describes, so its authority is not limited\nto the ones with generated signatures. `listTools({ search })` searches beyond the bounded preview\nand returns up to 20 compact matches within the shared 5,000-tool / 4 MiB discovery scan;\n`listTools({ name })` loads one exact bounded definition, and `callTool` resolves that name under the\nsame bound before dispatch. Every path rejects names outside the\nbinding's scope before loading the catalog or contacting the endpoint. Discovery and read results are\nrecorded as observations; writes retain the ordinary approval flow.\n\nScoping to one server also shrinks what the agent reads: only that server's tools are rendered as\nsignatures, and only as many as the budget describes. Additional tools are discovered on demand\nwithin the explicit scan limits above; exceeding a limit fails rather than pretending a tool is absent.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `MCP_PORTAL_URL` | The portal's MCP endpoint. Unset means the connector hides itself. |\n| `MCP_PORTAL_NAME` | Display name in the connector list and every approval prompt. Defaults to `MCP Server Portal (<host>)`. |\n| `MCP_PORTAL_AUTH` | `oauth` (default), `none`, or `token`. |\n| `MCP_PORTAL_TOKEN` | Secret bearer token, for `MCP_PORTAL_AUTH: \"token\"`. |\n| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default; see below. |\n| `MCP_PORTAL_HIDDEN_SERVER_IDS` | Comma-separated upstream server IDs to hide from the configurator and refuse at the grant boundary. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, for the portal and every OAuth URL discovered from it. Local dev only. |\n\nThe portal must expose upstream tools directly. Use a portal where Code Mode is off or opt-in, or\nappend `?codemode=off` when its policy is default-on. Enforced Code Mode is unsupported. Do not add\nan `optimize_context` parameter or opt in to Code Mode on `MCP_PORTAL_URL`.\n\nOnly `MCP_ALLOW_INSECURE` is set in the repo's `cloudflare.config.ts`, pinned to `\"false\"` so the default\nis explicit rather than merely absent. None of the others is, and a portal URL committed there would\nbecome the default for every deployment of this repo and would send their users' OAuth flows to\nwhichever host it named, so it belongs in the deployment's own configuration — for Cloudflare's\ninternal deployment, the per-package overrides in `gadgets-internal`.\n\nUnconfigured, `getSupportedResources()` returns nothing and the Workshop drops the vendor. A\n`MCP_PORTAL_URL` that cannot be used — a non-`https` typo or a URL containing `username:password`,\nsay — is treated the same way, so a misconfiguration hides the connector rather than producing one\nthat fails on first use or copying URL credentials into account state and configurator fields.\n\nChanging `MCP_PORTAL_URL` on a deployment that already has connected accounts is a **repoint**.\nExisting bindings fail closed at once: the minting path checks facet props against current\nconfiguration, and an already-minted facet must name its endpoint when asking the account for\ncredentials, which the account refuses after it has moved. The user recovers by reconnecting, which\nis the one endpoint change an account will accept. It is accepted only because the new endpoint\ncomes from this Worker's configuration rather than from a form. Nothing held for the old portal\nsurvives the move: tokens, the transport session, and any in-progress authorization are dropped, so\nthe user re-authorizes against the new host. The account advances a persisted generation before\nprobing; refreshes, expiry notifications, and session writes that started under the old generation\nare ignored when they eventually return. Always-approve action kinds also include the exact endpoint,\nso consent for the old portal does not carry over to the new one.\n\n`MCP_PORTAL_TRUST_ANNOTATIONS` is read at each point of use (`portalTrust(env)`) and never\npersisted on an account or a binding's props, so clearing it de-escalates every existing connection\non the next call. Setting it does not retroactively auto-apply anything; the user must still enable\na rule for each action kind.\n\n## How the connect flow works\n\nThere is no connect form: the endpoint is a deployment setting, so pressing \"connect\" goes straight\nto the portal's own sign-in. Under `MCP_PORTAL_AUTH: \"oauth\"` the gatekeeper runs the same\ndiscovery chain as [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#how-the-connect-flow-works)\nagainst the portal; under `\"token\"` it presents `MCP_PORTAL_TOKEN` and no user interaction is\nneeded; under `\"none\"` it connects unauthenticated.\n\nThe account records `provenance: \"deployment\"`, which is what keeps an upstream server from renaming\nitself over `MCP_PORTAL_NAME` in every approval prompt.\n\n## Granting\n\nThe configurator asks which server behind the portal, how broad the grant is, and — if it is\npinned — which tools:\n\n```\nServer · Which server behind this portal to grant. Its tools appear next.\n[ 🔍 GitHub ]\n\nTools · Choose how much of this server the Gadget may call.\n(•) All tools      Every tool this server offers, including ones it adds later.\n( ) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · Read-only tools return data straight away. The rest queue for your approval.\n[ 🔍 Filter tools...                                                   ]  12 of 12 selected\n☑ List issues                                                                     read-only\n☑ Create issue                                                               needs approval\n```\n\n**All tools** is the default. The breadth is asked outright rather than inferred from whether every\nbox is ticked, since the two look identical and diverge as soon as the server publishes another\ntool. A portal with one server behind it collapses to the tool questions alone, because the choice\nis made for the user rather than skipped — the grant still records that server.\n\nEvery grant here names exactly one upstream server. There is no breadth that spans the portal, so a\nconfigured endpoint with no upstream servers — a plain MCP server, or a portal that currently fronts\nnothing — is **not grantable through this connector**: the configurator has no server to offer and\nthe form stays unsubmittable. That is deliberate. The alternative is a grant recorded against the\nbare endpoint, which would silently include every server added to it later. Point a plain MCP\nendpoint at the **MCP** connector (`gatekeeper-mcp`) instead, which is built for exactly that and\noffers endpoint-wide and per-tool grants.\n\n## How upstream servers are recovered\n\nA portal flattens every upstream server's tools into one `tools/list`, so the seam has to be\nrecovered from two facts in the portal's documented contract:\n\n1. Every tool is named `{server_id}_{original_name}`, split on the first underscore only, and an\n   alias replaces the tool name but never the server-id prefix. Membership is therefore a pure\n   string test needing no network call, so a server scope cannot fail open on a transient error.\n2. `portal_list_servers` is available in every portal session and returns each upstream server's id,\n   name, and enabled state.\n\nDetection is a capability probe — does the endpoint offer `portal_list_servers`? — not a hostname\nmatch, so it works for a custom portal hostname and for any other aggregator adopting the\nconvention. A *truncated* listing counts as a portal whether or not the probe tool is in it:\n`tools/list` is unordered, so concluding \"not a portal\" because the evidence fell past the cut would\nfail open on the `portal_*` exclusion below. Truncation is reported by the client rather than\ninferred from the tool count, because either cap can stop a listing — the count the caller asked for,\nor the 96 KiB UTF-8 budget, and the latter can cut a listing of verbose tools short while leaving an\narray that looks complete. The byte budget leaves 32 KiB below Durable Object's per-value limit for\nthe cache wrapper and serialization overhead; if storage nevertheless rejects the cache value, the\nfresh catalog is still used for that operation rather than turning a cache miss into a failure.\n\n### Surveying a portal too large for one catalog\n\nThe configurator normally gets server names directly from `portal_list_servers`, without surveying\nevery upstream tool. If that response is unavailable or only partly understood, it falls back to a\n**name-only tool index** of up to 1,000 entries. The index detects the portal and recovers server\nmembership from tool-name prefixes; it carries no descriptions, schemas, or policy claims, so the\n96 KiB result budget covers as many names as possible. A truncated fallback cannot establish the\ncomplete server list and blocks the form rather than presenting a partial list as complete.\n\nAfter a server is selected, a separate filtered scan returns up to 200 compact summaries from that\nserver. Each summary carries its bounded title, description, and annotations, and is classified\nthrough the shared `tools.ts` trust boundary before it becomes a read-versus-approval label. The\nfilter is applied before result budgets, so unrelated servers cannot crowd the selected one out.\nOnly the returned prefix is offered for an individual-tool grant; additional tools require the\nserver-wide grant, and a call resolves the full definition before approval or dispatch.\n\nIndex entries are typed separately (`IndexedTool`) to distinguish name-only survey results from tool\ndefinitions rendered into approval prompts or handed to an agent.\n\nThe server list is advisory: it supplies display names and ordering while tool-name prefixes remain\nthe authority on membership, and a failed call degrades to bare ids recovered from tool prefixes.\nThe gatekeeper makes that call\nwhile building a form, so it does not pass through the approval queue. Failing to reach the portal\nat all is different, and blocks the grant rather than falling back to the bare endpoint — the\nconfigurator reports it and stays unsubmittable.\n\n`portal_*` tools are excluded from every grant at every scope. `portal_toggle_servers` and friends\nchange which upstream servers the session can reach, so granting one would let a Gadget widen its\nown authority. This is a capability-boundary rule, not a policy preference, and is not configurable.\n\n## Approvals and sharing\n\nIdentical to [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#approvals-and-sharing), except for the\ntrust tier. A portal is an aggregator: `destructiveHint` and `idempotentHint` are written by\nwhichever upstream server it fronts, servers the administrator who chose the portal never reviewed,\nand any one of them could self-declare both and get writes that skip the approval prompt. So the\ntier is `byo` unless a deployment sets `MCP_PORTAL_TRUST_ANNOTATIONS=true`. That flag is the\n\"trusted server\" assertion MCP's guidance asks for, and it has to be about the upstreams themselves,\nnot just the portal in front of them.\n\n`addObserver` refuses everyone, as it does for user-supplied endpoints. Reaching the portal is not\nthe same as being allowed to see a particular tool result, and the Gadget runs on the owner's\ncredentials regardless. A sharper check is available here than for a bare endpoint —\n`portal_list_servers` is per-user and Access-filtered, so it could confirm a collaborator may reach\nthe same server — but that is still server-granular, not record-granular. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\n## Notes and current limitations\n\nThe limitations in\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#notes-and-current-limitations) apply here too: no\nsimulation, no revert, no hooks, no scoping below tool names, and `tools/*` only. SSRF is enforced\nthe same way, by `global_fetch_strictly_public` rather than by hostname patterns. In addition:\n\n- **One portal per deployment.** `MCP_PORTAL_URL` is a single value; a second portal needs a\n  second Worker.\n- **Server ids come from tool-name prefixes.** An aggregator that flattens tools without prefixing\n  them exposes no upstream seam, so this connector finds no server to scope a grant to and the form\n  stays unsubmittable. It is not granted as a whole endpoint — that grant does not exist here. Use\n  the **MCP** connector (`gatekeeper-mcp`) for such an endpoint, where endpoint-wide breadth is an\n  explicit, reviewable choice.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/portal.ts` | Vendor, account DO, user, gatekeeper facet, session, configurator RPC |\n| `src/config.ts` | Reading and validating the deployment's portal configuration |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nEverything else comes from [`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-portal-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-portal-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n","isInternal":false,"tokens":3375,"sizeBytes":15155},{"name":"README.md","path":"packages/gatekeeper-mcp/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-mcp/README.md","title":"gatekeeper-mcp Documentation","category":"plugin-manifest","format":"markdown","content":"# MCP gatekeeper\n\nConnects any [Model Context Protocol](https://modelcontextprotocol.io) server as a Gadgets\ncapability. The user pastes an endpoint URL, the gatekeeper runs the OAuth discovery chain against\nit, and each of the server's tools becomes a typed method on the session. One Worker covers every\nMCP server, so a server needs no Gadgets-specific work to be usable from a Gadget. Runs as its own\nCloudflare Worker and is auto-discovered by the backend from its `GATEKEEPER_MCP` binding.\n\nIf your organization runs an MCP server portal, use\n[`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) instead: it takes one\nadministrator-configured URL, needs no user to paste an endpoint, and scopes a grant to a single\nupstream server behind the portal.\n\n## What it provides\n\nOne resource type, **Any MCP server**, at two grant breadths:\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool the endpoint offers, including ones it adds later | `<endpoint>` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools; anything else is refused | `<endpoint>#tool=a&tool=b` | `Mcp<Name><tag>Session` |\n\nA `#server=` fragment is refused here; scoping to one server behind a portal is the MCP Server Portals\nconnector's grammar. `@gadgets/mcp-shared/scope` owns the grammar and the enforcement.\n\n`<Name>` comes from the endpoint's host and is for reading, not identity — `acme.com` and `acme.io`\nboth give `Acme`. `<tag>` is four hex characters derived from the resource URL above, so two grants\nthat differ in what they may call get different type names. The agent is shown each binding's types\nin its own block with no way to compare them, so a shared name would present two unrelated tool\nsurfaces as one.\n\nThe session carries a named method per tool, generated from the server's own `inputSchema`:\n\nThe binding is named after the endpoint's host, so `https://mcp.linear.app/mcp` suggests\n`MCP_LINEAR` (see `server-id.ts`):\n\n```ts\n// Read-only tool: resolves immediately, recorded as an observation.\nlet search = await env.MCP_LINEAR.searchIssues({ query: \"state:open\" });\nif (search.status !== \"ok\") throw new Error(search.message);\nlet { text } = search;\n\n// Anything else: queued for approval, result collected afterwards.\nlet queued = await env.MCP_LINEAR.createIssue({ title: \"Fix the thing\" });\nif (queued.status === \"pending\") {\n  let outcome = await env.MCP_LINEAR.getActionResult(queued.actionId);\n}\n\n// The same call by its exact wire name, for a tool whose name cannot be a method.\nawait env.MCP_LINEAR.callTool(\"search_issues\", { query: \"state:open\" });\n```\n\nEach generated method is a one-line delegate to `callTool`, so the scope check, approval queue, and\nobservation record stay in one place. Tools whose names the RPC layer cannot deliver (`then`, `map`,\n`dup`), names that collide with session methods (`listTools`, `callTool`, `getActionResult`), names\nthat are not identifiers (`2fa`), and both\nsides of a case collision (`list_issues` and `listIssues`) get no method and remain callable through\n`callTool`.\n\nThe agent normally discovers tools statically: `describeGatekeeper()` sends it the binding name and\nthe bounded generated `.d.ts`, where each method carries the tool's own description as JSDoc and\nstates whether calling it needs approval. A server can publish more definitions than that bounded\ncatalog holds: `listTools({ search })` returns up to 20 compact summaries from a bounded scan of the\ngrant, limited to 5,000 tools / 4 MiB; `listTools({ name })` fetches one exact granted definition and\nschema under the same bound, and `callTool()` invokes it by wire name.\n`listTools()` remains the list of definitions currently described in the generated surface.\n\nSee `src/types.d.ts` in `@gadgets/mcp-shared` for the base session API.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `BASE_URL` | Public base URL of this Worker, for OAuth redirects. |\n| `MCP_CLIENT_NAME` | Client name sent in `initialize` and dynamic client registration. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, on the endpoint and on every OAuth URL discovered from it. Local dev only. |\n\nThere is nothing to configure per server: users supply endpoints, and an administrator's only lever\nis whether this connector is offered at all, in the Gatekeepers admin panel. There is no server\ncatalog — a deployment that wants to offer a chosen set of servers should front them with a portal\nand use [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md).\n\nFor local development no credentials are needed. Set `MCP_ALLOW_INSECURE=true` in the repo-root\n`.dev.vars` to connect a server running on localhost.\n\n## How the connect flow works\n\n1. The user starts a connection and gets a form asking for the server's endpoint URL. The URL is\n   validated against the host blocklist in `endpoint.ts` (no private, loopback, or metadata hosts;\n   HTTPS required unless `MCP_ALLOW_INSECURE`). The form says plainly that connecting a server is a\n   decision to trust it, since the server's own annotations decide which of its tools run without\n   asking (see below).\n2. The gatekeeper opens a Streamable HTTP session and calls `initialize`. A server that completes\n   the handshake unauthenticated is recorded as public and no token is demanded of it later.\n3. A `401` starts the official MCP client's OAuth flow:\n\n   ```\n   401 + WWW-Authenticate  ->  protected resource metadata   (RFC 9728)\n                           ->  authorization server metadata (RFC 8414)\n                           ->  dynamic client registration   (RFC 7591)\n                           ->  authorization code + PKCE     (RFC 7636)\n                               + resource indicator          (RFC 8707)\n   ```\n\n   The SDK falls back to the conventional\n   paths (`/authorize`, `/token`, `/register`) that the Workers OAuth provider and most MCP servers\n   use. That means a server which does not implement dynamic client registration is not detected as\n   such up front — the synthesized `/register` is tried and refuses, so the failure reads as a\n   rejected registration rather than a missing capability. The remedy is the same either way:\n   connect a server that supports registration, or reach it through a portal an administrator has\n   configured with a preissued token.\n4. Tokens are stored in the `McpAccount` Durable Object and refreshed proactively, before the\n   recorded expiry. Nothing outside this Worker can obtain one.\n\n   A `401` mid-session is not a refresh trigger: it means the server rejected a token this Worker\n   believed was valid, so the account is marked as needing attention and the user is asked to\n   reconnect. Refreshing in response would paper over a revoked or repudiated grant. Refresh\n   failures are classified — only the authorization server's own verdict on the credential\n   (`invalid_grant` and friends) marks the account expired, while transport and unrecognised failures\n   leave it alone to be retried.\n\nThe endpoint is fixed at first connect. Reconnecting an account cannot point it at a different\nserver, since the binding's props still name the original. (The MCP Server Portals connector is the\nexception: its endpoint comes from deployment configuration rather than a form, so a reconnect there\nmay adopt a repointed gateway. See its README.)\n\n## Scoping a binding\n\nThe configurator asks how broad the grant is and, if it is pinned, which tools:\n\n```\nTools · Choose how much of this server the Gadget may call.\n( ) All tools      Every tool this server offers (14 today), including ones it adds later.\n(•) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · 3 selected. Read-only tools return data straight away; the rest queue for approval.\n[ 🔍 Filter tools...                                  ]  3 of 14 selected   Select all   Clear\n☑ Search issues                                                                        read-only\n☑ Create issue                                                                    needs approval\n```\n\nThe breadth is asked outright rather than inferred from whether every box is ticked: \"all 14 ticked\"\nand \"these 14 by name\" look identical and diverge as soon as the server publishes a fifteenth. The\nlist stays on screen in both modes, disabled under **All tools**.\n\nTools named `portal_*` are never grantable on an endpoint that identifies itself as a portal, since\nthey let a session change which upstream servers it can reach.\n\n## Approvals and sharing\n\nA call to a tool the server annotates `readOnlyHint: true` is an observation and returns straight\naway. Every other call is queued via `submitAction()` and reaches the server only when the Overseer\ncalls `applyAction()`. Annotations are optional in MCP and most servers publish none; every hint is\ntested with `=== true` or `=== false`, so an unannotated tool is an action, needs approval, and can\nnever auto-apply.\n\nMCP's own guidance is that a client must treat tool annotations as untrusted unless they come from a\ntrusted server. The trust tier is that distinction, and it is the deployment's to make rather than\nthe server's: endpoints here are user-supplied rather than administrator-vetted, so they get `byo`.\n`readOnlyHint` classifies reads, but no claim the server makes can auto-apply a write.\n\nHonouring `readOnlyHint` on `byo` is a knowing departure from that rule, and the limit of what this\nconnector can promise. A server that labels a destructive tool `readOnlyHint: true` gets that tool\nrun without a prompt. Refusing the hint would mean an approval prompt for every `search` and `list`,\nwhich makes the connector unusable for the thing people connect it to do; and it would buy less than\nit looks, since a dishonest server can act on any call, including one the user did approve. What the\ntier does guarantee is that no BYO server gets a write *auto-applied*, and that every call trusted on\nthe server's word is recorded as such.\n\nSo the trust decision is made once, by the person pasting the URL, and the connect form says so\nplainly: an annotation is only as trustworthy as the server that sent it. Auto-approval requires a\n`vetted` endpoint, which only [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) can\nproduce. Every call records which side classified it (`McpToolInfo.classifiedBy`), so an audit can\nfind each one taken on the server's word. See `tools.ts` in `@gadgets/mcp-shared` for the tier\nrules.\n\nA Gadget bound to an MCP server can only be opened by its owner: `addObserver` refuses\nunconditionally. Being able to authenticate to a server is not evidence of being allowed to see what\nthe *owner* read from it, and the Gadget runs on the owner's credentials throughout. Writes still\nwork — the alternative, marking every observation `containsRestrictedData`, would set that\nflag, whose restricted mode blocks every action for the rest of the session. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\nTo share the work rather than the binding, publish the Gadget as a blueprint and let each person\nconnect their own server.\n\n## Notes and current limitations\n\n- **No simulation.** MCP describes no way to predict a tool's effect, so a queued call is not\n  reflected in later reads. `awaitDecision` is set and the agent's turn suspends until the user\n  decides.\n- **No revert.** MCP describes no inverse for a tool call, so `implementsRevert` is false.\n- **No hooks.** `notifications/tools/list_changed` is session-scoped; a Gadget hook is durable and\n  must survive restarts.\n- **No scoping below tool names.** MCP tools take arguments, not capabilities, so \"this repo only\"\n  cannot be expressed. A list of allowed tool names is the narrowest grant available.\n- **Only `tools/*`.** Prompts, resources, sampling, and elicitation are not implemented. Sampling\n  and elicitation would let a server drive the agent.\n- **Tool-list changes are adopted, not pinned.** A changed list is taken and logged\n  (`catalog.changed`), since refusing to see new tools would break working Gadgets. A binding with a\n  tool scope cannot widen this way, which is the reason to prefer **Choose tools** for anything that\n  writes.\n- **SSRF is enforced after DNS, not by the blocklist.** The hostname patterns in `endpoint.ts` are a\n  legible refusal at connect time; they cannot see through a public hostname that resolves, or\n  rebinds, to a private address. The actual boundary is the `global_fetch_strictly_public`\n  compatibility flag in `cloudflare.config.ts`, which makes workerd reject reserved IP ranges after\n  resolution on every request and redirect hop. It does not apply under `wrangler dev`, which is\n  what keeps `MCP_ALLOW_INSECURE` usable locally.\n- **Sharing UI reports late.** `GadgetMetadata.containsRestrictedData` derives only from\n  `ObservationDescription.containsRestrictedData`, so creating a share key appears to succeed and\n  fails when the recipient opens it (their observer verification is refused). Fixing this needs a\n  kernel change.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/mcp.ts` | Vendor, account DO, user, verifier, gatekeeper facet, session |\n| `src/connect-form.ts` | The endpoint prompt served during connect |\n| `src/server-id.ts` | Endpoint to display slug, for the binding name and session type |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nThe MCP client, OAuth, tool classification, generated TypeScript, and the scope grammar come from\n[`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n","isInternal":false,"tokens":3205,"sizeBytes":13918},{"name":"README.md","path":"packages/gatekeeper-notion/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-notion/README.md","title":"gatekeeper-notion Documentation","category":"plugin-manifest","format":"markdown","content":"# Notion gatekeeper\n\nMediates a Gadget's access to a user's [Notion](https://www.notion.so) workspace: pages and\ndatabases. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_NOTION` binding.\n\n## Auth\n\nOAuth 2.0 public connection. Configure a Notion **public integration** and provide its client\ncredentials to the worker as `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts`\nmaps `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nThe integration's **redirect URI** must match `<BASE_URL>/oauth`, which in local dev defaults to\n`http://localhost:8787/gatekeeper/notion/oauth`. Recommended capabilities: read/insert/update\ncontent, read/insert comments, read user info (no email needed — `providesAuth` is false).\n\nThe connect flow uses a two-phase nonce (initiation → OAuth) and stores the access + refresh\ntokens in a `UserAccount` Durable Object; access tokens are refreshed on a 401.\n\n## Resources\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all) | `NotionWorkspace` |\n| A page or database | `https://www.notion.so/:path+` | `NotionPage` or `NotionDatabase` (detected server-side) |\n\nOnly the pages/databases the user shares with the integration during the OAuth page-picker are\nreachable.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Page bodies are\nexchanged as Markdown and property values use a simplified union. Highlights:\n\n- `NotionWorkspace`: `search`, `getPage`, `getDatabase`, `createPage`, `listUsers`\n- `NotionPage`: `getMetadata`/`getProperties`/`getContent`, `appendContent`, `setTitle`/\n  `setProperties`/`setIcon`, `createSubPage`, `archive`/`restore`, comments, `listChildPages`\n- `NotionDatabase`: `getSchema`, `query` (typed filters/sorts), `getPage`, `createPage`\n\n## Approvals, caching & simulation\n\nEvery read calls `authorizeObservation()`; every write is staged via `submitAction()` and only\nperformed in `applyAction()`. Reads simulate pending (unapproved) writes so a Gadget sees its own\nchanges immediately — including provisional IDs for created pages. Page/database/data-source/user\nresponses are cached in DO storage with short TTLs.\n\n## Data sources\n\nNotion's newer model splits a database into one or more **data sources**. Database `query`,\n`getSchema`, and row creation resolve the database's primary data source under Notion-Version\n`2025-09-03`; pages/blocks/comments/search use `2022-06-28` so user-facing IDs/URLs stay\nconsistent. This split is hidden from the Session API.\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/notion-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/notion-gatekeeper test:run    # vitest\n```\n","isInternal":false,"tokens":712,"sizeBytes":2796},{"name":"README.md","path":"packages/gatekeeper-scheduler/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-scheduler/README.md","title":"gatekeeper-scheduler Documentation","category":"plugin-manifest","format":"markdown","content":"# Scheduled Tasks\n\nScheduled Tasks is an ambient Gatekeeper that lets workspace code register persistent callbacks for\nelapsed intervals, wall-clock recurrences, and one-time runs. Each account also gets a read-only\nmanagement app at `/gatekeepers/scheduler`.\n\n## User flow\n\n1. Ask the agent to create a scheduled task, or open **Scheduled** and choose a starter prompt.\n2. Confirm what the task should do, the workspace and resources it may use, its cadence, and its\n   IANA timezone when it uses wall-clock time.\n3. The agent registers a persistent callback. Registration creates a disabled hook and returns its\n   schedule ID; it does not start the schedule.\n4. Enable the hook in the Workshop's Connections UI.\n5. Use **Scheduled** to search and inspect schedules across the account. Select a schedule row to\n   open its workspace.\n\nThe Scheduler app is intentionally read-only. Enabling and disabling remain in Connections; the app\ndoes not provide editing, pausing, deletion, run history, or a second hook toggle.\n\n## Agent API\n\nThe ambient binding exposes `ScheduleSession`. The exact agent-facing contract and examples live in\n[`src/types.d.ts`](src/types.d.ts).\n\n```ts\n// executeCode, where `callback` is a persistent stub for the params `{ type: \"dailyBrief\" }`\nconst scheduleId = await env.SCHEDULER.calendarAt(\n  {\n    timeZone: \"America/Chicago\",\n    freq: \"weekly\",\n    byDay: [\"MO\", \"TU\", \"WE\", \"TH\", \"FR\"],\n    hour: 8,\n    minute: 0,\n  },\n  callback,\n  {\n    title: \"Daily brief\",\n    description: \"Prepare the morning calendar and inbox brief.\",\n    occurrences: { count: 10 },\n  },\n);\n```\n\nUse the three registration methods according to the user's intent:\n\n- `every(everyMs, callback, options)` uses elapsed UTC time. The minimum interval is 60 seconds.\n- `calendarAt(rule, callback, options)` follows local wall-clock time and requires an explicit IANA\n  timezone. It supports hourly, daily, and weekly rules.\n- `runAt(when, callback, options)` runs once at an absolute epoch-millisecond timestamp or an\n  explicit timezone-aware wall-clock time.\n\nRecurring `every()` and `calendarAt()` calls may stop after a finite bound. Give one or the other,\nnever both; `runAt()` accepts neither.\n\n- `occurrences: { count: 10 }` allows ten logical scheduled occurrences.\n- `occurrences: { until: { timeZone, year, month, day, hour, minute } }` allows occurrences through\n  that instant, inclusively. An absolute epoch-millisecond `until` is also accepted. Registration\n  rejects a cutoff that precedes the schedule's first occurrence.\n\nThe count bounds *due slots*, not successful runs. A slot consumes one count as soon as it becomes\ndue and takes a `runId`, even if admission or callback delivery then fails; admission failures skip\nthe slot without retrying it. Retries reuse the same `runId` and do not consume another count.\nMissed occurrences remain skipped and do not count.\n\nA bound is checked against live state, so a schedule that reaches it reports `completed`. A schedule\nwhose cutoff already passed by the time its hook is enabled reports `expired` instead: it never got\na slot. A schedule whose callback exhausts its eight attempts reports `dead` and stops there,\nwhether or not the bound was reached — a `count: 10` schedule that dies on its third occurrence\nnever reaches the fourth. Disabling a hook drops its driver state, so re-enabling the same schedule\nrestarts the count — treat `count` as a bound per enablement, not a lifetime guarantee.\n\n`list()` returns active and terminal schedules for enabled hooks in the current workspace only. It\ndoes not expose schedules from other workspaces in the account.\n\n## Persistent callbacks and retries\n\nCallbacks implement `ScheduledTaskHook.onSchedule()` and must be made persistent before\nregistration: `this.ctx.restore(params)` inside the Gadget, or through the Gadget's binding from\n`executeCode`, as the agent's instructions on persistent stubs describe. Each firing contains:\n\n- `scheduleId`, the stable registration ID;\n- `runId`, stable across retries of one logical occurrence;\n- `scheduledTime`, the planned Unix epoch time;\n- `actualTime`, the current delivery-attempt time; and\n- `timeZone`, the schedule's display timezone (`UTC` for elapsed intervals and numeric one-shots).\n\nCallback delivery is best-effort within a bounded retry window and may occur more than once. Callback\ncode should use `runId` as an idempotency key. Authorization or callback failures retry up to eight\ntotal attempts with exponential delays beginning at one minute and capped at one hour. Exhausted\nschedules enter the **Needs attention** state.\n\nThe Workshop admission check runs before every attempt. If the hook, gatekeeper, or account is no\nlonger allowed, the occurrence is skipped without consuming a callback attempt. Recurring schedules\nadvance to their next future occurrence; a due one-shot expires.\n\n## Cadence behavior\n\n- Recurrences preserve the phase established at registration.\n- Missed occurrences are skipped, not replayed or caught up.\n- Fixed intervals measure elapsed time and therefore shift relative to local clocks across DST.\n- Calendar schedules retain their requested local wall-clock cadence across DST.\n- A nonexistent spring-forward time moves forward by the transition gap.\n- An ambiguous fall-back time uses the earlier instant and fires once.\n- Date-less wall-clock one-shots resolve to the next future occurrence of that local time.\n\nAlways ask the user for the timezone of a wall-clock schedule. Do not infer it from locale or silently\nchoose UTC.\n\n## Lifecycle\n\nRegistration only binds a disabled Workshop hook; it writes no schedule row. Enabling records the\ntarget workspace ID and optional gadget ID, then creates the account-driver row and arms its alarm.\nDisabling removes the row and its stored capabilities. Re-enabling creates fresh active state,\npreserves the original recurrence phase, and does not replay missed work. A repeated enable while\nthe row still exists instead preserves its current schedule state and refreshes its activation details.\n\nSuccessful one-shots become **Finished**. Past or rejected one-shots become expired. Terminal rows\nremain visible and consume enabled-schedule quota until their hook is disabled in Connections.\nCreating a workspace from a blueprint does not copy schedules or capabilities: the new workspace must\nregister its callback and receive fresh enablement.\n\nDisconnecting the Scheduler account revokes its driver, deletes schedule state, and leaves a permanent\ntombstone so retained stale controllers cannot recreate the account.\n\n## Management app\n\nThe account advertises its UI through the generic `AccountDescription.providesUi` mechanism, the same\nmechanism used by other Gatekeeper management apps. The Workshop discovers it dynamically and hosts\nthe single-file app in an opaque-origin, network-isolated `srcDoc` frame.\n\nThe app can only call its account-scoped, read-only `list()` capability plus bounded host methods for\ntheme updates, workspace-title resolution, navigation, and starter prompts. Pages contain at most 100\nschedules. Search is case-insensitive over title and description and is limited to 200 characters.\nCursors are opaque, bounded, and tied to the normalized search and status filters.\n\nThe **All**, **Active**, **Needs attention**, and **Finished** tabs map to the projected\n`active`, `dead`, and `completed`/`expired` statuses. Target metadata is presentation data only;\nopening its validated internal route grants no new authority. Create and starter actions place fixed\neditable text in the Home composer and never submit it automatically.\n\n## Architecture and security\n\nEach Scheduler account owns one SQLite-backed `ScheduleDriver` Durable Object and one alarm for all of\nits workspaces. Workspace sessions inherit their opaque scope from the containing Overseer facet;\ncallers cannot supply an account or workspace ID. Plain schedule metadata and reconstructable RPC\ncapabilities are stored under separate keys and changed transactionally.\n\nThe alarm persists state before crossing an RPC boundary. It processes at most 20 due schedules per\npass with four concurrent deliveries, then arms an immediate continuation when a backlog remains.\nStable `runId` fencing prevents late completion or retry continuations from mutating a disabled,\nre-enabled, or revoked schedule.\n\nOne account-wide driver keeps management and revocation simple, but it is also a shared failure domain:\na user callback that never settles can delay other schedules in that account until the runtime aborts\nthe alarm. Bounded batches limit ordinary load; they do not eliminate that tradeoff.\n\nThe Scheduler is capability-authorized. It does not receive Workshop user identity, assert its own\nambient policy, expose external network authority, or implement actions. The Workshop's existing hook\nadmission and observation authorization remain the security boundaries.\n\n## Limits\n\n- 500 enabled or terminal schedule rows per account.\n- 100 enabled or terminal schedule rows per workspace.\n- 100 rows per management page.\n- 20 due schedules per alarm pass and four concurrent deliveries.\n- Eight callback attempts per logical occurrence.\n- Schedule titles: 200 characters; descriptions: 2,000 characters.\n\nThese are fixed policy limits rather than deployment settings.\n\n## Development\n\nInstall and build from the repository root:\n\n```sh\npnpm install\npnpm --filter @gadgets/gatekeeper-scheduler test:run\npnpm --filter @gadgets/gatekeeper-scheduler build\npnpm run dev-server\npnpm run dev-client\n```\n\nThe development server discovers `gatekeeper-*` packages, builds `app/` into the generated single-file\nasset, and creates the local `GATEKEEPER_SCHEDULER` service binding. Do not edit generated local\nWrangler configuration.\n\nWorker code gets its runtime and `Cloudflare.Env` types from generated\n[`worker-configuration.d.ts`](worker-configuration.d.ts), referenced by `tsconfig.json`. Regenerate it\nwith Wrangler after changing Worker configuration; do not hand-maintain a parallel `Env` interface.\nThe browser app uses the separate DOM-only `tsconfig.app.json` and imports only environment-neutral\nmanagement DTOs.\n\n## Deployment\n\nBefore exposing Scheduler:\n\n1. Deploy the Workshop hook target-metadata contract and the `startHook()` admission backstop.\n2. Deploy this Worker so its `ScheduleDriver` and `SchedulerGatekeeper` SQLite migration exists.\n3. Add `GATEKEEPER_SCHEDULER` to the deployment's Workshop service bindings.\n\nScheduler declares only that it can auto-provision an account. Workshop provisioning policy defaults\nthe `scheduler` vendor to **optional**; an administrator can explicitly choose disabled, optional, or\nenabled. Production service bindings are site-specific and are not generated by this package.\n\nThe `allow_irrevocable_stub_storage` compatibility flag is required while stored callback capabilities\nexist and must not be removed from an existing deployment.\n\n## Troubleshooting\n\n- **Scheduled is missing from navigation:** confirm the Scheduler binding exists, the vendor is enabled\n  or optional with a connected account, and account discovery returns `providesUi`.\n- **A new schedule is not listed or running:** registration starts disabled. Enable its hook in the\n  workspace Connections UI.\n- **A schedule says Needs attention:** callback authorization or execution exhausted its retry budget.\n  Fix the callback or resource access, then disable and re-enable the hook.\n- **A one-shot says Expired:** its firing time passed without admission or it was already past when\n  enabled. Register a new future one-shot.\n- **A recurrence appears an hour off:** use `calendarAt()` for local wall-clock intent and `every()` for\n  elapsed time; verify the explicit IANA timezone.\n- **A blueprint-created workspace has no schedules:** schedules are deliberately not copied. Register\n  them again in the new workspace.\n\n## Non-goals\n\nV1 does not provide schedule editing, local pause/toggle/delete controls, recurring run history,\n`lastRunAt`, account default timezones, actor attribution, blueprint schedule cloning, catch-up delivery,\nor collaborator-owned schedule routing. Hook lifecycle remains in the Workshop Connections UI.\n","isInternal":false,"tokens":2536,"sizeBytes":12203},{"name":"README.md","path":"packages/gatekeeper-slack/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-slack/README.md","title":"gatekeeper-slack Documentation","category":"plugin-manifest","format":"markdown","content":"# Slack gatekeeper\n\nMediates a Gadget's **read-only** access to a user's [Slack](https://slack.com) workspace:\nchannels, direct messages, threads, members, and search. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_SLACK` binding.\n\nThis gatekeeper is read-only and never sends or modifies Slack data.\n\n## Auth\n\nOAuth 2.0 using a **user token** (`xoxp-…`), requested via `user_scope` (not a bot token) so the\nagent sees exactly what the connecting user can see — including private channels, DMs, and search.\n\nCreate a Slack app (https://api.slack.com/apps) and provide its client credentials to the worker\nas `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts` maps `SLACK_CLIENT_ID` /\n`SLACK_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nApp configuration:\n\n- **Redirect URL** must match `<BASE_URL>/oauth`, which in local dev defaults to\n  `http://localhost:8787/gatekeeper/slack/oauth`.\n- Enable **token rotation** (OAuth & Permissions → *Token Rotation*). Tokens are then short-lived\n  (~12h) and refreshed via `oauth.v2.access?grant_type=refresh_token`. Non-rotating tokens also\n  work as a fallback (they're returned as-is).\n- Request the **User Token Scopes** the granted resources need (see below). `users:read` is always\n  requested for connected-account display and user-name resolution.\n\n## Org-wide installations\n\nBoth workspace and org-wide installations use the connecting user's token; the bot token in an\norg OAuth response is never used for reads. Org OAuth can return `team: null`, an `enterprise`\nidentity, and `is_enterprise_install: true`. Those describe the installation, not a resource's\nworkspace. In particular, an `E…` enterprise ID must never be used in a `/client/T…` workspace URL.\n\nAn org admin must also assign the app to the desired workspaces. Installing it at the org level\nalone does not make every workspace available. The workspace picker intersects all pages of\n`auth.teams.list` (the app's assignments) with `users.info.enterprise_user.teams` (the connecting\nuser's memberships). See Slack's [Enterprise development guide](https://docs.slack.dev/enterprise/developing-for-enterprise-orgs/)\nand [auth.teams.list reference](https://docs.slack.dev/reference/methods/auth.teams.list/).\n\nEach granted resource still selects exactly one workspace. Workspace and conversation pickers\nretain its `T…` ID in the resource URL and Durable Object props. For thread URLs, workspace\ninstallations verify the permalink host against their own workspace's metadata; only org\ninstallations use the Enterprise-only `team.info(domain)` lookup, followed by membership checks.\nThe agent-facing session API is unchanged.\n\n| API calls | Workspace selector |\n| --- | --- |\n| `users.conversations`, `users.list`, `search.messages` | `team_id` |\n| `team.info` | `team` (not `team_id`) |\n| Channel-addressed conversation calls | `client_context_team_id`, plus workspace membership verification |\n\nPassing channel context is not itself an authority grant. Before reading a channel by ID, the\nclient verifies it belongs to the selected workspace, allowing shared channels linked to that\nworkspace. Responses without decisive membership metadata (including DMs) must appear in a\nworkspace-scoped conversation listing. Search matches undergo the same check. Direct user lookup\nis limited to the workspace directory; author/mention resolution can still resolve participants\nfrom other workspaces in shared channels.\n\nEnterprise DM responses can name another workspace in `shared_team_ids` even when the selected\nworkspace's DM discovery includes the conversation. For DMs and group DMs, that mismatch falls\nthrough to the existing workspace-scoped `users.conversations` proof instead of rejecting the\nread. An exact conversation ID must still be found in the selected workspace's listing; neither\norg-token visibility nor `context_team_id` is sufficient. Shared-channel mismatch denials are\nunchanged. See the [users.conversations reference](https://docs.slack.dev/reference/methods/users.conversations/).\n\nObserver checks use each collaborator's own credentials and the selected workspace ID, never\nenterprise-ID equality. Workspace bindings additionally track the conversations actually read,\nexcluding collaborators who cannot read newly observed private channels or DMs.\n\n### Upgrading existing connections\n\nOlder credentials need not be reconnected solely to learn the installation kind: discovery can\nrecognize an org token through `auth.test`. Legacy bindings did not retain the workspace from their\nURL. On first use, they recover a stored workspace ID, a thread's permalink workspace, or the sole\neligible workspace, then persist that selection separately from credentials. A subsequent reconnect\ncannot silently switch a pinned binding to another workspace.\n\nThe access proof is cached separately from that workspace selection and keyed to the access\ntoken. Every outgoing bound read, including rate-limit retries, checks the exact credential it\nwill use. Replacement credentials must demonstrate access to the pinned workspace before any\ndata request is sent. Failed checks are evicted so transient failures can recover without\nchanging the selected workspace; concurrent checks using the same credential are coalesced.\n\nIf a legacy org binding has several eligible workspaces and no recoverable selection, it fails\nclosed: add the resource again and choose its workspace. Old resource URLs containing an `E…` ID\nalso need to be replaced with a selected `T…` workspace URL.\n\nLegacy conversation-only/thread-only grants may lack the metadata scopes listed below. Reconnect\nor grant the resource again to approve the additional scopes; the reconnect flow preserves those\nprevious resource selections while requesting the new consent. Reads do not treat old scopes as\nsufficient. Workspace grants already requested the required scopes.\n\n## Resources\n\nAccess is granted at one of three granularities. Each grantable resource maps to a URL pattern\nthat drives both consent (which OAuth scopes are requested) and routing:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all whole-instance) | `SlackWorkspaceSession` |\n| A conversation (channel, DM, or group DM) | `https://app.slack.com/client/:teamId/:conversationId` | `SlackConversation` |\n| A thread | `https://*.slack.com/archives/:conversationId/:messageId` | `SlackThread` |\n\nWorkspace grants use the framework's account-wide `https://*` pattern; more-specific conversation\nand thread URLs take precedence. Channels and DMs share one \"Conversation\" grant.\n\n### Scopes per resource (user token scopes)\n\n- **Workspace**: `team:read`, conversation read scopes, `search:read`\n- **Conversation**: `team:read`, conversation read scopes, `search:read`\n- **Thread**: `team:read`, conversation read scopes\n- **Always**: `users:read`\n\nwhere the conversation read scopes are `channels`/`groups`/`im`/`mpim` `:read` + `:history`.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Highlights:\n\n- `SlackWorkspaceSession`: `getInfo`, `listChannels`, `listDirectMessages`, `listUsers`,\n  `getUser`, `getConversation`, `search`\n- `SlackConversation`: `getInfo`, `members`, `listMessages`, `getThread`, `search`\n  (conversation-scoped search is **hard-restricted** to the bound conversation regardless of query)\n- `SlackThread`: `getRoot`, `listReplies`\n\nList and search methods return paginated `Cursor` objects. Known mentions are rendered with readable\nnames.\n\n## Build\n\n```\npnpm exec vp run -F @gadgets/slack-gatekeeper build\npnpm --filter @gadgets/slack-gatekeeper test:run\n```\n\nNode regression tests cover OAuth response shapes, workspace API parameters, shared-channel/DM\nboundaries, legacy recovery, reconnects, observer exclusion, and configurator field dependencies.\nEnterprise DM regressions include nonmatching shared-team metadata, membership on a later listing\npage, nonempty search validation, and denial when the conversation is absent from that workspace.\nValidate the Worker RPC schema with `pnpm exec capnweb-validate build --out .wrangler/validate`\nfrom this package before a local Wrangler dry-run.\n","isInternal":false,"tokens":1806,"sizeBytes":8259},{"name":"README.md","path":"packages/gatekeeper-spotify/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-spotify/README.md","title":"gatekeeper-spotify Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper Spotify\n\nThis package provides Spotify Web API integration for Gadgets. Connect a Spotify account and gadgets\ncan search the catalog, read and edit your library and playlists, manage follows, and control\nplayback on your Spotify Connect devices — all behind the approval queue.\n\nIt offers two resource granularities:\n\n- **Spotify Account** (whole-instance): profile, catalog search, library (saved tracks/albums, top\n  items, recently played), follows, playlists, and playback control via `getPlayer()`.\n- **Spotify Playlist**: read / edit / follow / unfollow a single playlist.\n\nThis gatekeeper is **not** an authentication provider (`providesAuth: false`) — Spotify isn't offered\nas a \"Continue with…\" sign-in method.\n\n> **Targets Spotify's post-February-2026 development-mode API.** Library writes use the generic\n> `PUT/DELETE /me/library`, playlist contents use `/playlists/{id}/items`, etc. Some fields Spotify\n> removed in dev mode (e.g. track `popularity`, parts of the user profile) are returned as `null`.\n\n## Setting up Spotify OAuth credentials\n\nIf you're running this project locally and want to use Spotify integration, you'll need to create\nyour own Spotify app.\n\n### Step 1: Create a Spotify app\n\n1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and click\n   **Create app**.\n2. Fill in a name and description (anything).\n3. **Redirect URI**: add exactly\n\n   ```\n   http://127.0.0.1:8787/gatekeeper/spotify/oauth\n   ```\n\n   > Spotify **does not allow `localhost`** as a redirect URI for new apps — you must use the\n   > loopback IP literal `127.0.0.1`. Replace host/port with your `BASE_URL` when not running\n   > locally (production must use HTTPS).\n4. Under **APIs used**, select **Web API**.\n5. Save, then copy the **Client ID** and **Client secret**.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-spotify/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n# Must match the redirect URI host you registered. Spotify rejects \"localhost\", so use 127.0.0.1.\nBASE_URL=http://127.0.0.1:8787/gatekeeper/spotify\n```\n\n> **Note**: `.env` is gitignored and should never be committed. Because the redirect uses\n> `127.0.0.1`, open the Workshop at `http://127.0.0.1:8787` (not `localhost`) so the OAuth popup\n> stays on the same host.\n\n### Step 3: Add yourself as a user (development mode)\n\nA new Spotify app starts in **development mode**, which limits it to a small number of users you add\nexplicitly. In the dashboard, under **User Management**, add the Spotify account (name + email) you\nintend to connect — otherwise authorization will fail. Lifting this limit requires requesting\nextended quota from Spotify.\n\n> **Playback control requires Spotify Premium.** Player write commands (play/pause/seek/volume/…)\n> return 403 for free accounts.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see the root README.md).\n2. Create or open a gadget and go to the **Connections** tab.\n3. Click **+ New Connection** and choose a Spotify resource type (whole account, or a playlist).\n4. If prompted, connect your Spotify account — you'll be redirected to Spotify's authorization page\n   in a new tab; after granting access the tab closes and you're back in Gadgets.\n5. For a playlist resource, use the picker to choose the playlist to connect.\n6. Create the connection.\n\nYou can see and manage connected accounts in settings (via the account menu in the upper-right).\n\n## Notes & limitations\n\n- **Approvals & simulation.** Reads are logged and writes are queued for human approval; nothing is\n  performed on Spotify until approved. Reads optimistically reflect your own pending edits (e.g. a\n  pending playlist add shows in `listTracks`) so a gadget can keep working before approvals land.\n  Playback commands are gated but **not** simulated — `getState()` always shows real device state.\n- **Non-owned playlists.** Spotify withholds track contents for playlists you don't own/collaborate\n  on, so `listTracks()` returns an empty list (metadata via `getDetails()` still works).\n- **Spotify Connect quirks.** Some third-party Connect endpoints (e.g. Music Assistant) can be\n  invisible to `getDevices()` and `getState().device` may misidentify the active device; confirm\n  device transfers with a follow-up `getState()`.\n\n## Troubleshooting\n\n### \"Spotify Gatekeeper Not Configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Ensure `packages/gatekeeper-spotify/.env` exists with both\nvalues, then restart the dev server.\n\n### \"INVALID_CLIENT: Invalid redirect URI\"\n\nThe redirect URI sent doesn't exactly match one registered on the app. Confirm the dashboard has\n`http://127.0.0.1:8787/gatekeeper/spotify/oauth` and that `BASE_URL` in `.env` is\n`http://127.0.0.1:8787/gatekeeper/spotify` (note `127.0.0.1`, not `localhost`; no trailing slash).\n\n### Authorization fails with \"User not registered in the Developer Dashboard\"\n\nThe connecting Spotify account isn't on the app's **User Management** list (development-mode limit).\nAdd it in the dashboard and retry.\n","isInternal":false,"tokens":1242,"sizeBytes":5173},{"name":"README.md","path":"packages/gatekeeper-supabase/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-supabase/README.md","title":"gatekeeper-supabase Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper Supabase\n\nThis package provides Supabase integration for Gadgets via the [Supabase Management\nAPI](https://supabase.com/docs/reference/api/introduction), using OAuth2.\n\nIt exposes two resource granularities:\n\n- **Project** (`https://supabase.com/dashboard/project/:ref`) — the recommended unit. A project is a\n  hosted Postgres database plus its auth, storage, and edge functions. Gadgets can run read-only and\n  (approval-gated) mutating SQL, introspect schema, and list edge functions and storage buckets.\n- **Organization** (`https://supabase.com/dashboard/org/:slug`) — broader access to discover and act\n  across every project in an organization.\n\nA connected account corresponds to the organization the user authorizes during the OAuth consent\nflow.\n\n## Creating the OAuth app\n\n1. In your Supabase **organization settings**, open the **OAuth Apps** tab\n   (`https://supabase.com/dashboard/org/_/apps`) and **Add application**.\n2. Set the **Redirect URI** to your deployment's callback (replace the host with `PUBLIC_BASE_URL`):\n\n   ```\n   ${PUBLIC_BASE_URL}/gatekeeper/supabase/oauth\n   ```\n\n   For local development that is `http://localhost:8787/gatekeeper/supabase/oauth`.\n3. After creating it, copy the **Client ID** and **Client Secret** (the secret is shown once).\n\n> Use the OAuth app's **Client ID + Client Secret** — not a personal access token or project API\n> key. The `authorization_code` exchange requires the OAuth app credentials.\n\n## Configuration\n\nThe gatekeeper Worker reads `CLIENT_ID` and `CLIENT_SECRET`. In local development these are seeded\nfrom shell/`.dev.vars` variables by `run-dev-server.ts`:\n\n```\nSUPABASE_CLIENT_ID=<oauth app client id>\nSUPABASE_CLIENT_SECRET=<oauth app client secret>\n```\n\n## Notes\n\n- Reads are logged as observations; mutating SQL (`execute()`) is submitted to the approval queue\n  and only runs once a human approves it.\n- Mutating SQL is **not** simulated — arbitrary statements cannot be reliably previewed through the\n  stateless query endpoint, so a `query()` will not observe an un-approved change. This is\n  documented in the API types.\n","isInternal":false,"tokens":491,"sizeBytes":2120},{"name":"README.md","path":"packages/gatekeeper-websearch/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-websearch/README.md","title":"gatekeeper-websearch Documentation","category":"plugin-manifest","format":"markdown","content":"# Web Search\n\nWeb Search is an ambient gatekeeper that lets the agent and gadgets search the public web through\nCloudflare's [Web Search API](https://developers.cloudflare.com/web-search/). It has no connect\nflow, no settings and no management UI.\n\n## Who gets it\n\nThe admin Gatekeepers panel sets the mode, like every auto-provisioned gatekeeper\n(`provisioning-policy.ts` in workshop-backend):\n\n- **Optional** (the default): onboarding offers it switched on, and a user can turn it on or off\n  later on the Gatekeepers page.\n- **Enabled**: every user gets it.\n- **Disabled**: no one gets it, and existing accounts go dormant.\n\nA user who has it gets a `WEB_SEARCH` binding in the agent's `executeCode`, in every workspace. The\nagent can bind it into a gadget with `setGadgetBinding`. The binding appears when the workspace\nnext opens, from the next turn of each chat, including chats that started before. The agent is\ntold about it in that chat; the system prompt doesn't change. A spawned agent's chat keeps the\nbindings its spawner gave it.\n\nA change of mode, or a user turning it off, also takes effect when each workspace next opens. Until\nthen, its open chats and scheduled gadgets can still search. Uninstalling the Worker stops every\nsearch at once.\n\n## Agent API\n\nThe binding exposes `WebSearchSession`. The contract lives in [`src/types.d.ts`](src/types.d.ts).\n\n```js\nexport default async function(self, env) {\n  const results = await env.WEB_SEARCH.search(\"durable objects alarms\");\n  // [{ title, url, description?, lastModifiedDate? }, ...]\n}\n```\n\n`query` is 1 to 1,024 characters, the API's limits. The caller picks no provider and no result\ncount, and gets up to 10 results.\n\n## How a search runs\n\n<p align=\"center\"><img src=\"../../docs/images/web-search-flow.svg\" alt=\"A search passes the gatekeeper's query check and is recorded in the Activity log before it goes through the Workers AI binding and the deployment's AI Gateway to Ceramic\" width=\"860\"></p>\n\n`src/search.ts` does the work:\n\n1. It refuses a query outside 1 to 1,024 characters. Nothing is sent or recorded.\n2. It records the query through the session's `ApprovalQueue`. The query goes in a literal field,\n   built with gatekeeper-kit's `buildDescription`, so the record shows exactly what was sent. If\n   the record is refused or fails, nothing is sent.\n3. It calls `env.AI.websearch({ gatewayId: env.CF_AI_GATEWAY ?? \"default\", query, provider: \"ceramic\", limit: 10 })`.\n4. It parses the response with zod, which drops the image and favicon fields. A failed search\n   throws `Web search failed with HTTP <status>.` without the provider's body, which can quote the\n   query back. The record stays.\n\n## Security\n\nA query goes only to the provider that `search.ts` pins. The agent cannot choose where it goes, as\nit can with a `webFetch` URL, so a workspace that has observed restricted data can still search,\nas it can search GitHub or Slack through their gatekeepers.\n\n- **Observers.** `addObserver` admits every collaborator, because search results are public.\n- **Ambience.** The gatekeeper declares only that it can mint an account and that the account has\n  a singleton. The admin's mode and the user's opt-in decide who gets it.\n\n## Why Cloudflare's Web Search API\n\n- It runs on a Workers AI binding and the deployment's AI Gateway, so the gatekeeper needs no\n  secrets or inputs.\n- Logs, billing and provider keys stay in AI Gateway.\n- The standalone `web_search` Worker binding (`env.WEBSEARCH.search`) is not an option. Cloudflare\n  removed it before launch ([workers-sdk#15453](https://github.com/cloudflare/workers-sdk/pull/15453)).\n  Its types are still in the generated `worker-configuration.d.ts` files.\n\n## Why Ceramic\n\n<p align=\"center\"><img src=\"../../docs/images/web-search-providers.svg\" alt=\"Price per 1,000 searches and data retention: Ceramic $0.25 with ZDR, Linkup $5 with ZDR, Exa $7 without ZDR, model-native search $10 to $35\" width=\"860\"></p>\n\n- **Zero data retention.** A query can carry workspace text, so the provider must not keep it.\n  Exa offers no zero data retention, so we exclude it. Ceramic's zero data retention applies to\n  searches billed to AI Gateway credits. With an operator's own Ceramic key, their Ceramic terms\n  apply instead.\n- **Cost.** $0.25 per 1,000 searches: a twentieth of Linkup and a twenty-eighth of Exa.\n- **Content.** Ceramic runs its own index of 40B+ pages and returns descriptions of up to 8,000\n  characters, so a result often answers the question without a fetch.\n- **Pinned in code.** `search.ts` sets `provider: \"ceramic\"` instead of trusting the API's default,\n  which Cloudflare could change to a provider that keeps data. There is no fallback chain: each\n  fallback would be one more way out of the platform.\n- **Linkup** is the other provider with zero data retention. Switching to it changes one constant.\n\nWe rejected model-native search (Anthropic, OpenAI, Gemini). It runs at the model provider, where\nthe Workshop cannot record it, and costs $10 to $35 per 1,000. We rejected direct\nvendor APIs (Brave, Tavily, Parallel and others) because each needs a new secret and sends traffic\noutside AI Gateway.\n\n## Deployment\n\n- The release manifest gives the Worker an `AI` binding and asks for no inputs. The deploy service\n  installs it on every new deploy, like Scheduled Tasks and the Context Library.\n- **Gateway.** `CF_AI_GATEWAY` names the AI Gateway searches go through. The deploy service sets\n  it on every gatekeeper it uploads to the instance's gateway, the one the backend's\n  `CF_AI_GATEWAY` names. Everywhere else (the dev server, previews, a deploy you run yourself) it\n  stays `default`, which every account has, unless you set it on this Worker. The gateway must be\n  in the Worker's own account: the `AI` binding can't reach another.\n- **Billing.** Each search spends the deployment account's AI Gateway credits, unless the gateway\n  holds a Ceramic key (see Known limits). The free daily allowance and Cloudflare-credits top-up\n  for models do not cover searches, and there is no per-user limit. On a public server, set the\n  mode to Disabled unless you accept that: under the default, Optional, every new user who leaves\n  it on in onboarding gets it.\n- **Local dev.** Start with `pnpm dev-server -- --use-workers-ai-binding`, which needs a Cloudflare\n  login. Without the flag the Worker has no `AI` binding and every search fails.\n\n## Known limits\n\n- **No approval.** A search is a read: recorded, not queued. Code that runs on a schedule can send\n  workspace data in a query with no one present.\n- **Untrusted results.** Results are page text. An agent that reads them can meet a prompt\n  injection, as with `webFetch`.\n- **Gateway logs.** The gateway that `CF_AI_GATEWAY` names logs each query (log collection is on\n  by default). When that is `default` and the backend uses a gateway with another name, search\n  logs sit apart from model logs. Ceramic's zero data retention covers Ceramic, not those logs.\n  That gateway's own settings, such as rate limits, apply to searches.\n- **Your own Ceramic key.** If the gateway that `CF_AI_GATEWAY` names holds a Ceramic key under\n  the alias `default`, AI Gateway uses it. Ceramic bills the operator, their Ceramic terms apply,\n  including data retention, and a search no longer falls back to credits: a revoked or empty key\n  fails every search. The deploy wizard can store the key; a key on any other gateway is not used.\n- **Blueprints.** A blueprint export drops ambient bindings, so a gadget installed from a blueprint\n  loses `WEB_SEARCH` until the agent binds it again with `setGadgetBinding`.\n","isInternal":false,"tokens":1826,"sizeBytes":7619},{"name":"README.md","path":"packages/gatekeeper-x/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-x/README.md","title":"gatekeeper-x Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper X\n\nConnects X (formerly Twitter) accounts to Gadgets. A gadget can read an account's timelines,\nmentions, search, bookmarks and likes, and draft posts, threads, replies, likes, reposts, follows,\nmutes and List changes. Nothing reaches X until a user approves it; every draft is shown to the\ngadget as if it had already happened, so a gadget keeps working while it waits. See\n`plans/x-gatekeeper.md` for the design record.\n\nFour resource types, each its own grant:\n\n| Resource | Link | What it reaches |\n|---|---|---|\n| X Account | `https://x.com/settings/account` | The whole account. |\n| X Post | `https://x.com/<user>/status/<id>` | One post and its conversation: read, like, repost, bookmark; reply where X allows it (to the account's own posts, or posts that mention or quote it); hide replies in conversations the account started. |\n| X List | `https://x.com/i/lists/<id>` | One List's posts and members; its details and members, if the account owns it. |\n| X Profile | `https://x.com/<user>` | One user's public profile and posts. Read-only. |\n\nThe account resource's link names no user, so a blueprint that binds it binds whichever account\nre-resolves it.\n\n## What it costs\n\nX's API is pay-per-use, billed to the X app's prepaid credits: the deployment pays for every user's\nreads and posts. As of October 2026:\n\n| Request | Price |\n|---|---|\n| A post read | $0.005 |\n| A user read (a profile, or a post's author) | $0.010 |\n| A post created | $0.015 |\n| A post containing a link | $0.20 |\n\nX bills each resource once per UTC day, however often it is read. For example, a daily digest of\n200 posts costs $1-3 a day depending on how many distinct authors it shows, and a single post with a\nlink costs $0.20, which approval descriptions point out.\n\nTwo limits keep a gadget from running up the bill:\n\n- **A daily read limit per connection**, set by the deployment's `X_DAILY_READ_LIMIT` var: the\n  billable resources one connection may read per UTC day, across every binding and gadget using it.\n  The default is 2,000 (about $10 a day at the post-read price); `0` removes the limit. Reads past\n  it fail with a message naming when it resets.\n- **Approvals** bound writes: nothing is posted, liked or followed without a user approving it.\n\nReads are cached briefly (a post for 5 minutes, a profile for an hour, a listing's first page for a\nminute), so a gadget polling one page pays at most once a minute.\n\n## Setting up an X app\n\n1. In the [X Developer Console](https://console.x.com), create a Project and an App.\n2. Under **User authentication settings**, enable OAuth 2.0 with the type **Web App, Automated App\n   or Bot** and the permission **Read and write**.\n3. Set the **Callback URI** to `{PUBLIC_BASE_URL}/gatekeeper/x/oauth`, and give the app a website\n   URL.\n4. Buy credits, and set a spending limit.\n5. Copy the **OAuth 2.0 Client ID** and **Client Secret** (not the API key and secret, which are for\n   OAuth 1.0a) into the deployment's `CLIENT_ID` and `CLIENT_SECRET`.\n\n### Local development\n\nExport `X_CLIENT_ID` and `X_CLIENT_SECRET` in your shell or the root `.dev.vars`; `pnpm dev-server`\nmaps them into the worker's `CLIENT_ID` and `CLIENT_SECRET`, and passes `X_DAILY_READ_LIMIT`\nthrough when it is set.\n\nRegister `http://localhost:8787/gatekeeper/x/oauth` as a callback. If the console refuses\n`localhost`, register `http://127.0.0.1:8787/gatekeeper/x/oauth` instead and start the dev server\nwith `VITE_BACKEND_HOST=127.0.0.1:8787`, so the callback the gatekeeper sends matches.\n\n## Obligations that come with an X app\n\nX's developer agreement puts these on the app's owner, not on Gadgets:\n\n- **AI-generated replies need X's prior approval.** An app that replies to posts with AI-generated\n  content must be approved by X before it does. Replies drafted by agents are sent only after a user\n  approves each one, but X still counts them as the app's.\n- **Automated accounts must say so.** An account run by an agent must carry X's \"Automated\" label,\n  set in the account's settings.\n- **Likes, follows, reposts and posts must be the user's own.** X forbids automating them. That is\n  why only bookmarks, mutes and hiding replies can be auto-approved: they are private to the account\n  or reversible moderation. Everything else waits for a user to approve it.\n\n## Sharing a workspace\n\nA collaborator can observe a workspace that reads X only with an X connection of their own (and,\nfor a Post or List binding, one that can see the bound post or List). What the account reads that\nis private to it -- bookmarks, likes, mutes, private Lists, and posts by protected accounts -- is\nshown only to collaborators connected as the same X user. See `docs/observers.md`.\n\n## Troubleshooting\n\n### \"The X connection has expired or was revoked. Reconnect the X account.\"\n\nX refused the connection's refresh token: the user revoked the app in X's settings, the app's\ncredentials changed, or the refresh token was used twice. Reconnect the account from the Workshop's\nConnections page.\n\n### \"That's a different X account\"\n\nThe reconnect was authorized by another X user than the one the connection was made for. A\nconnection stays pinned to its X user; sign in to X as that user and reconnect, or connect the other\naccount separately.\n\n### \"This X connection has used today's 2,000 reads; the limit resets at 00:00 UTC.\"\n\nThe connection reached the deployment's daily read limit. Wait for the reset, or raise\n`X_DAILY_READ_LIMIT`.\n\n### \"The X API credits for this deployment are used up, or its spending limit was reached.\"\n\nThe X app's prepaid credits ran out, or its spending limit was hit. Add credits or raise the limit in\nthe X Developer Console.\n\n### \"X's rate limit for this request is used up for this account.\"\n\nX limits each endpoint per account per 15 minutes; the message names when the window resets. The\ngatekeeper never retries on its own.\n\n### \"X never confirmed whether this post was published, so it may already be on X and can't be rejected.\"\n\nAn approval sent the post, and X's answer was lost. Approve it again: the gatekeeper first checks the\naccount's recent posts and records the post if it landed, so it is never posted twice. Revert it\nafterwards if it shouldn't stay.\n\n### The callback URI does not match\n\nX compares the callback exactly. It must be the deployment's `{PUBLIC_BASE_URL}/gatekeeper/x/oauth`,\nwith the same scheme, host and port the gatekeeper's `BASE_URL` uses.\n","isInternal":false,"tokens":1584,"sizeBytes":6429},{"name":"README.md","path":"packages/gatekeeper-zoominfo/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/gatekeeper-zoominfo/README.md","title":"gatekeeper-zoominfo Documentation","category":"plugin-manifest","format":"markdown","content":"# Gatekeeper ZoomInfo\n\nThis package provides [ZoomInfo](https://www.zoominfo.com) integration for Gadgets via ZoomInfo's\nGTM (go-to-market) API, using OAuth2 (Authorization Code + PKCE).\n\nIt exposes a single **whole-account** resource (`https://app.zoominfo.com/`): a session scoped to one\nconnected ZoomInfo account's OAuth grant and package entitlements. Through it a gadget can:\n\n- **Lookup** — resolve controlled filter values (industries, intent topics, scoop types, …) and\n  discover the enrich fields the account is entitled to.\n- **Search** (free) — companies, contacts, intent signals, scoops, and news, with full firmographic\n  and technographic filter fidelity.\n- **Enrich** (consumes credits) — turn matched records into full detail: companies, contacts,\n  corporate hierarchy, hashtags, intent, scoops, and news.\n- **Copilot** — company/contact lookalikes, contact recommendations, AI account summaries (+ ask),\n  and curated company insight signals.\n- **Usage** — read the account's credit/limit counters.\n\nA connected account corresponds to the ZoomInfo user who authorizes the OAuth consent flow. ZoomInfo\nis **not** offered as a \"Continue with…\" sign-in method (`getAuthenticatedEmail()` returns `null`).\n\n## Creating the OAuth app\n\n1. In the [ZoomInfo Developer Portal](https://api.zoominfo.com/), create an OAuth application.\n2. Set the **Redirect URI** to your deployment's callback (replace the host with your base URL):\n\n   ```\n   ${BASE_URL}/gatekeeper/zoominfo/oauth\n   ```\n\n   For local development that is `http://localhost:8787/gatekeeper/zoominfo/oauth`.\n3. Enable all API scopes the account is entitled to. The gatekeeper requests exactly the scopes\n   ZoomInfo's authorization server recognizes:\n\n   ```\n   api:data:company  api:data:contact  api:data:intent  api:data:news  api:data:scoops\n   api:recommendations:read  api:account-summary:read  api:insights:read\n   ```\n\n   There is no `lookup` scope — lookup/enrich-field reference data comes with the data scopes.\n4. Copy the **Client ID** and **Client Secret**.\n\n> Use the OAuth app's **Client ID + Client Secret**. The `authorization_code` exchange posts them to\n> the token endpoint as HTTP Basic auth (form-urlencoded), with PKCE (S256) and refresh-token\n> rotation.\n\n## Configuration\n\nThe gatekeeper Worker reads `CLIENT_ID` and `CLIENT_SECRET`. In local development these are seeded\nfrom the root `.dev.vars` by `run-dev-server.ts`:\n\n```\nZOOMINFO_CLIENT_ID=<oauth app client id>\nZOOMINFO_CLIENT_SECRET=<oauth app client secret>\n```\n\nOptional overrides read by the Worker:\n\n- `BASE_URL` — the gatekeeper's public base (default `http://localhost:8787/gatekeeper/zoominfo`);\n  the OAuth redirect is `${BASE_URL}/oauth`.\n- `ZOOMINFO_API_BASE_URL` — the ZoomInfo API root (default `https://api.zoominfo.com/gtm`).\n\n## Notes & limitations\n\n- **Credits.** Search, lookup, recommendations, insights, and account summaries are free. Enrichment\n  consumes ZoomInfo credits (roughly one per newly-enriched record; already-owned records and\n  no-match/error results are free). `getCreditUsage()` is the authoritative source for accounting —\n  the per-record `creditCharged` flag is a display-only upper bound, because ZoomInfo often omits\n  management status from enrich responses and so over-reports records served for free.\n- **Approvals.** Reads are logged as observations. Each `enrich*` call is submitted to the approval\n  queue and spends credits only once a human approves it; results are fetched afterward via\n  `getEnrichmentResult(ticket)`. Enrichments are **not** simulated — the action carries\n  `awaitDecision`, so the agent turn suspends until the decision rather than reading back\n  un-enriched state.\n- **Intent/scoop company targeting.** Intent and scoop *search* filter by firmographics only —\n  ZoomInfo has no company-identity inputs there. To pull intent or scoops for a specific known\n  company, use `enrichIntent` / `enrichScoops` (which take a company identifier). The API surface and\n  a runtime guard both enforce this with a clear message instead of ZoomInfo's opaque 400.\n- **`state` vs `country`.** These are mutually exclusive: ZoomInfo silently ignores `state` when\n  `country` is also set and returns the whole country. The gatekeeper rejects the combination up\n  front rather than returning broadened results with no signal. `state` already scopes to the\n  US/Canada, so pass just one.\n- **Entitlements.** Every call is scoped to the account's package: ZoomInfo returns only the fields\n  and datasets the account is entitled to; unentitled fields come back empty.\n\n## Troubleshooting\n\n### \"ZoomInfo Gatekeeper Not Configured\" during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Ensure `ZOOMINFO_CLIENT_ID` / `ZOOMINFO_CLIENT_SECRET`\nare set in the root `.dev.vars`, then restart the dev server.\n\n### `invalid_scope` on the authorization page\n\nA requested scope isn't recognized by ZoomInfo's authorization server. Confirm the app has the eight\nscopes listed above enabled and that no others are being requested.\n\n### Redirect URI mismatch\n\nThe redirect URI sent must exactly match one registered on the app. Confirm the portal has\n`${BASE_URL}/gatekeeper/zoominfo/oauth` (for local dev,\n`http://localhost:8787/gatekeeper/zoominfo/oauth`, no trailing slash).\n\n### \"ZoomInfo did not return a refresh token\"\n\nThe OAuth app isn't issuing refresh tokens. Enable the refresh-token grant for the app in the\nDeveloper Portal and reconnect — the gatekeeper needs a refresh token to keep the long-lived session\nalive after the access token expires.\n","isInternal":false,"tokens":1305,"sizeBytes":5590},{"name":"README.md","path":"packages/integration-tests/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/integration-tests/README.md","title":"integration-tests Documentation","category":"plugin-manifest","format":"markdown","content":"# integration-tests\n\nEnd-to-end tests that drive the real Workshop and a real gatekeeper over the actual RPC API, plus the\ntoolkit those tests are built from. Part of `pnpm test`, so CI runs it like any other package.\n\n```bash\npnpm --filter @gadgets/integration-tests test:run\n```\n\n## The toolkit\n\nThree source-only modules, consumed both by the tests here and by per-vendor suites in repos that\nvendor this one as a submodule:\n\n- **`src/harness.ts`** — boots `workshop-backend` and any set of gatekeepers as real Workers under\n  [`wrangler`'s `createTestHarness()`](https://developers.cloudflare.com/changelog/post/2026-07-21-integration-test-harness/),\n  patching their checked-in `wrangler.jsonc` in memory. Parameterised over gatekeepers on purpose: a\n  suite for a new gatekeeper should be \"point the harness at the package\", not a forked copy.\n  `Harness.redeployWorkshop()` deploys another build of the Workshop over the running one, keeping\n  its storage, and `bundleBlueprints()` is a patch that changes which blueprints a build ships with.\n- **`src/network-interceptor.ts`** — `NetworkInterceptor`, mechanism only. It patches\n  `globalThis.fetch` (the harness routes Worker subrequests back through the Node process, so that is\n  enough), passes loopback through, and **throws on anything a handler didn't match** — a test cannot\n  reach the real internet. What a given vendor's endpoints answer lives in a handler module you pass\n  in, which is what makes it reusable across gatekeepers.\n- **`src/rpc-client.ts`** — speaks Cap'n Web over a WebSocket to `/api`, the same transport the\n  browser uses: sign-up, reading connected accounts, and `ObserverConfigRecorder`, which records the\n  overseer's `configure()` calls and answers from a scripted queue.\n\n## Writing a test here\n- **No test may assume a clean slate.** Everything in a file shares one harness, `it.concurrent` runs\n  the cases together, and storage is never reset. Take fresh identities from `nextUsernames()` and use\n  per-test resource URLs; account labels are allocated for you, so two tests can't pick the same one.\n- **The escape assertion lives in `afterAll`, not `afterEach`** — an `afterEach` fires while sibling\n  tests are still running, so it would inspect and clear state they are still using.\n- **A test that redeploys the Workshop starts a harness of its own.** A redeploy restarts the Workers\n  under every session they have, which would break the tests sharing the file's harness.\n\n## The fixture gatekeeper\n\n`fixtures/gatekeeper-test/` is a real Worker speaking the real gatekeeper protocol, whose verification\noutcome the tests set over an HTTP control route. It exists because the overseer cases need a\ngatekeeper that will refuse an observer *on command*, and every shipping one can do that only at a\ncost that would dominate the test:\n\n- The OAuth ones need a whole vendor auth surface mocked before an account exists at all.\n- The Context Library only refuses after an observation has been *recorded*, which takes a gadget read\n  session or a slash command — and it is a singleton, so it cannot produce two simultaneously failing\n  bindings.\n\nAdding a test hook to those workers was considered and rejected: a \"mark observed\" hook would stub the\nvery state the tracker maintains, and an injected dev credential for an OAuth gatekeeper would bypass\nexactly the flow that makes a real vendor worth testing.\n\nOne deliberate departure from a shipping gatekeeper, to keep the fixture cheap:\n\n- One control knob, `allow`. A settled denial and an expired credential reach the overseer identically\n  — both as a thrown error, which it deliberately cannot tell apart because it treats every failure as\n  repairable — so the reason string is what carries the difference. Tests cover both narratives by\n  choosing reason text.\n\n## A real gatekeeper: GitHub's hooks\n\n`__tests__/github-hooks.test.ts` runs a GitHub hook's whole life through the real Workshop and the\nreal `gatekeeper-github` — subscribe, enable, a signed delivery, the gadget's write approved, then\ndisable, connection removal and disconnect — and `github-hooks-off.test.ts` what a deployment\nwithout `WEBHOOK_ORIGIN` gets. GitHub is `__tests__/github-fake.ts`, a handler module for the\ninterceptor. The gatekeeper boots from the tree `vp run -F @gadgets/github-gatekeeper\nbuild:integration-worker` validates, which the `test` task and `test:prebuild` build first.\n\n## An upgrade: Google's hooks\n\n`__tests__/google-hooks-upgrade.test.ts` enables Gmail and Chat hooks under the release of\n`gatekeeper-google` that came before its hook delivery queue moved into gatekeeper-kit, then\nupgrades that Worker in place to this tree's build, keeping its storage. It checks that queued\ndeliveries, the hooks' capabilities and alarms, the Gmail watch and cursor and the Chat subscription\nall carry over, and that disconnecting reaches hooks the old release never told the account about.\nGoogle is `__tests__/google-fake.ts`. `vp run -F @gadgets/integration-tests build:upgrade-base`\n(`scripts/build-upgrade-base.ts`) bundles the old release from a temporary worktree of its revision.\nThe new one comes from `vp run -F @gadgets/google-gatekeeper build:integration-worker`. The `test`\ntask and `test:prebuild` build both first.\n\n## Further reading\n\n[`docs/integration-testing.md`](../../docs/integration-testing.md) covers the reasoning behind the\nshape of all this: why fake timers cannot work here, why a fixture gatekeeper rather than a real one,\nhow storage isolation works, and the capnweb, wrangler, and workerd traps to expect. Read it before\nchanging the toolkit or starting a suite of your own.\n","isInternal":false,"tokens":1293,"sizeBytes":5662},{"name":"README.md","path":"packages/mcp-shared/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/mcp-shared/README.md","title":"mcp-shared Documentation","category":"plugin-manifest","format":"markdown","content":"# MCP shared\n\nThe protocol client, policy decisions, and stateful machinery common to the two MCP-speaking\ngatekeepers. Not a Worker: a library both of them import.\n\n| Package | Endpoint comes from | Grant is scoped to |\n| --- | --- | --- |\n| [`gatekeeper-mcp`](../gatekeeper-mcp/README.md) | a user pastes a URL | the whole server, or named tools |\n| [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) | a deployment var, `MCP_PORTAL_URL` | one upstream server behind the portal, or named tools |\n\nCode lives here when two copies of it would eventually disagree and the disagreement would be a\nsecurity bug: tool classification, the scope grammar, the OAuth lifecycle, the approval-queue\nwiring. A Worker's own vendor entrypoint, Durable Object classes and migrations, `Env`, connect\nform, and configurator UI stay in the connector. Where a connector must vary shared behaviour it\ndoes so through a named hook (`staticToken`, `mintAccount`), not a private copy.\n\n## Modules\n\n| Module | Purpose |\n| --- | --- |\n| `client` | Bounded Streamable HTTP transport (`initialize`, `tools/list`, `tools/call`) using official MCP wire types |\n| `oauth` | Small adapter around the official MCP client's OAuth errors and token revocation gap |\n| `tools` | The trust boundary: read/action classification, auto-approval eligibility, approval prompts, catalog fingerprinting |\n| `schema-to-ts` | JSON Schema to TypeScript, strict `callTool` overloads plus progressive discovery |\n| `session-methods` | Installs those methods at runtime, so the generated types are not a fiction |\n| `tool-search` | The one query matcher every catalog search uses, so a query cannot mean two things |\n| `portal` | Gateway detection, tool-name to upstream-server mapping, server listing |\n| `scope` | The resource-URL scope grammar, and the check every call passes through |\n| `endpoint` | Validation and host blocklist for a user-supplied endpoint |\n| `fetch` | Every outbound request; redirects are followed by hand and each hop re-checked, including SDK OAuth fetches |\n| `account` | Durable Object base and persisted SDK OAuth state: connect, refresh, revocation |\n| `facet` | Common session, catalog, action, and sharing behavior for connector-owned Durable Object facets |\n| `catalog` | One binding's tool list: fetched, cached, scoped to the grant, classified; plus the cache for tools hydrated past that list |\n| `connection` | `withClient` — transport sessions, retries, credential-expiry reporting |\n| `action-store` | Staged to applied/rejected/failed, with a bound on what is retained and a claim so one approval is never sent twice |\n| `session` | The Gadget-facing capability, and the one path every tool call takes |\n| `sharing-policy` | The owner-only sharing rule |\n| `html` | The connect-flow pages, so both connectors look like one product |\n| `http` | Base-path, OAuth callback, and connect-link routing shared by both Workers |\n| `log` | The field vocabulary both connectors log against |\n| `user` | The common account description, revocation, and reconnect lifecycle |\n| `util` | Hex encoding, host extraction, binding-name slugs; no policy |\n| `types.d.ts` | Base types prepended to every generated per-server `.d.ts` |\n\nNothing outside `tools.ts` reads a tool's `annotations`.\n\n## Trust tiers\n\nA tier governs how far a server's own claims about its tools are believed. MCP's own guidance is\nthat a client must treat tool annotations as untrusted unless they come from a trusted server, and\nthe tier is where this deployment records which servers those are. It is named by provenance, is\ndeployment configuration rather than account state, and is read at each point of use so withdrawing\nit takes effect at once.\n\n- **`byo`** — a user typed the URL in. `readOnlyHint` classifies reads; nothing the server says can\n  auto-apply a write.\n- **`vetted`** — a deployment has asserted this endpoint's annotations can be relied on, so\n  `destructiveHint: false` plus `idempotentHint: true` may drive auto-approval. Configuring an\n  endpoint is not by itself enough to earn this: a portal aggregates upstream servers whose\n  annotations the administrator never saw, which is why `gatekeeper-mcp-portal` defaults to `byo`\n  and requires `MCP_PORTAL_TRUST_ANNOTATIONS=true`.\n\nHonouring `readOnlyHint` on `byo` is a tradeoff, not a free win: a tool the server mislabels runs\nwith no approval, where an unlabelled one would have been queued. It is accepted because prompting\non every read makes the connector unusable for its main purpose, and because the owner chose to\nconnect the server. Auto-applying a write is not accepted on those terms.\n\nAnnotations are optional in MCP and most servers publish none. Every hint is compared with\n`=== true` or `=== false`, so an unannotated tool is an action, needs approval, and can never\nauto-apply, on either tier. That matches the spec's own defaults (`readOnlyHint: false`,\n`destructiveHint: true`, `idempotentHint: false`).\n\nNeither tier can be shared. A Gadget bound to any MCP endpoint is owner-only, for reasons unrelated\nto provenance — see [`sharing-policy.ts`](src/sharing-policy.ts).\n\nWhat an account records instead of a tier is `provenance`, `\"user\"` or `\"deployment\"`, settled when\nit connects. Provenance decides whether a server may rename itself over an administrator's chosen\nlabel in an approval prompt, which is a question that should not move when an annotation setting\ndoes.\n\n## Applying an approved call\n\nThe guarantee is *at most once*, not exactly once. MCP has no idempotency key that would make a\nrepeated call harmless and no inverse operation that would undo one, so where the two conflict the\nstore prefers losing a result over repeating a write.\n\nAn approval is claimed in storage before the call is sent, which is what stops two concurrent\n`applyAction` calls from both reaching the server. Once the call returns, the record is settled in\nits own small write *before* the result is attached, so nothing about handling a server-controlled\npayload — normalizing it, encoding it, or finding it too large for the Durable Object to store — can\nlose the fact that the write already happened.\n\nFailures are split by what the server is known to have done, because that is not something the\ncaller can work out afterwards. Only a `401` or `403` proves the tool was refused before dispatch.\nGeneric HTTP and JSON-RPC errors, dropped connections, malformed replies, and oversized bodies all\nleave the outcome unknown; those are closed as failed and **not** retryable, since the request may\nalready have been carried out. The classification (`callMayHaveTakenEffect`) fails safe: anything it\ncannot positively identify as declined counts as possibly performed.\n\nThe same rule covers an activation dying between sending the call and recording the reply. The claim\nis not released for another attempt: after `APPLY_CLAIM_TIMEOUT_MS` the action is closed the same\nway, saying it may or may not have taken effect.\n\nSo the cost of a network blip is a call someone has to stage again, deliberately. That is the trade\nthis module makes everywhere: an approval is never spent twice without a person saying so.\n\nA failed call can always be discarded. The Workshop keeps a failed approval pending until the user\nretries or discards it, and a call the agent awaits blocks its chat until then, so `reject` accepts a\n`failed` record instead of refusing it. Discarding keeps the failure on record for the Gadget to\ncollect, so an unknown outcome is still reported as unknown, and rules out any further attempt.\n\n## Limits\n\nFixed rather than configurable.\n\n| Limit | Value | Where | Why |\n| --- | --- | --- | --- |\n| Described or individually granted tools per server | 200 | `tools.ts` | Bounds the picker and generated `.d.ts`; a server-wide grant can discover additional tools later |\n| Catalog size | 96 KiB UTF-8 | `client.ts` | Leaves room below Durable Object's 128 KiB per-value limit for the cache wrapper and serialization overhead |\n| Filtered discovery scan | 5,000 tools / 4 MiB | `client.ts` | Bounds work spent skipping unrelated tools while searching a large endpoint |\n| Search query / results | 200 chars / 20 tools | `tool-search.ts` | Bounds agent-supplied matching work and the summaries returned to it |\n| Hydrated definitions per facet | 200 tools / 1 MiB | `catalog.ts` | Bounds definitions fetched individually beyond the described catalog |\n| Tool description | 4 KB | `client.ts` | As above, per tool, before it reaches storage |\n| Tool input schema | 20 KB | `client.ts` | Dropped rather than clipped; half a schema is not a schema |\n| `tools/list` pages | 50 | `client.ts` | Stops a cursor that never ends; exhaustion truncates catalogs and fails exact/search discovery as a scan limit |\n| Response body | 1 MiB | `fetch.ts` | Every response is buffered whole before it can be parsed, and a `tools/call` result is otherwise unbounded |\n| Bounded outbound operation | 30 seconds | `fetch.ts` | OAuth and discovery callers opt into one deadline covering redirects, pagination, body streaming, and session retry |\n| Retained result | 128 KB | `action-store.ts` | Held until the Gadget collects it; oversized ones are replaced by a note |\n| Retained actions | 100 | `action-store.ts` | Records are for collecting a result, not an audit log |\n| Actions awaiting a decision | 50 | `action-store.ts` | These cannot be pruned, so uncapped they are an unbounded write |\n| Tool description in a prompt | 600 chars | `tools.ts` | Server-controlled text in a security decision |\n| Tool arguments in a prompt | 4000 chars | `tools.ts` | Agent-controlled text in the same decision |\n| Server name | 60 chars | `account.ts` | As above; also stripped of Markdown |\n| Redirect hops | 3 | `fetch.ts` | Each one re-checked; more is a loop, not a deployment |\n| Connect link | 10 min | `connect-nonce.ts` | Single-use, and consumed on success |\n| Unfinished connect | 1 hour | `connect-nonce.ts` | After which a half-built account deletes itself |\n\n## Build & test\n\n```\npnpm --filter @gadgets/mcp-shared build   # tsc\npnpm --filter @gadgets/mcp-shared test:run    # vitest\n```\n","isInternal":false,"tokens":2347,"sizeBytes":10134},{"name":"README.md","path":"packages/observability/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/observability/README.md","title":"observability Documentation","category":"plugin-manifest","format":"markdown","content":"# Observability\n\n`@gadgets/observability` is a shared library for code that runs in Cloudflare Workers. It is not a\nstandalone Worker and has no deployable entrypoint or Wrangler project configuration.\n\nThe package's Vitest configuration uses the Workers test pool to exercise runtime-specific APIs.\nConsumers that import `@gadgets/observability/observability-context` must enable `nodejs_als` (or\n`nodejs_compat`); the default `@gadgets/observability/logger` entry point has no such requirement.\n\nAn observability context exposes typed `.with()` and `.get()` methods, and creates loggers that\ninherit its ambient fields.\n\nThe optional `@gadgets/observability/error-reporting` entry point dispatches bounded error events to\na private Reporter bound as `ERROR_REPORTER`; `reportIssue(failureSite, caught, options?)` accepts\nambient fields under `options.attributes`, so callers can spread the context's `.get()` result and\naugment it inline. Reporting is a no-op when the binding is absent.\n\nThe optional `@gadgets/observability/metrics` entry point writes usage metrics to a Workers\nAnalytics Engine dataset bound as `METRICS`, and is likewise a no-op without the binding. Each event\nis written once to its actor's index, the canonical copy to count, and once more per owner or\nworkspace it names, so per-entity queries read their own index. The column layout lives in the import-free `metrics-schema.ts` (also\nexported as `@gadgets/observability/metrics-schema`), so dashboards can name columns rather than\n`blobN`.\n","isInternal":false,"tokens":350,"sizeBytes":1517},{"name":"README.md","path":"packages/workshop-frontend/README.md","rawUrl":"https://raw.githubusercontent.com/cloudflare/cloudflare-os/HEAD/packages/workshop-frontend/README.md","title":"workshop-frontend Documentation","category":"plugin-manifest","format":"markdown","content":"# Gadgets Workshop Frontend\n\nSingle-page app for the Gadgets Workshop UI. Built with React, Kumo, and Vite.\n\n## Development\n\n```sh\npnpm dev                # start dev server on http://localhost:3000\npnpm exec vp run build  # type-check and build for production\npnpm preview            # preview production build locally\n```\n\n`build` is a Vite+ task, not a package.json script, so that it can declare the `VITE_*` flags below\nas fingerprinted env: a cached `vp` run executes each task in a clean environment, which drops any\nambient value, and a flag the fingerprint ignores means a changed flag replays the old bundle.\n\nIt always produces a production bundle, whatever `NODE_ENV` the shell holds, and it deletes `dist/`\nbefore building — a cache hit restores archived files without deleting any, so the previous build's\nsourcemaps would otherwise linger. `vite.config.ts` documents both.\n\n## Gatekeeper user selection\n\nGatekeeper management apps can open the Workshop-owned **Select users** picker through\n`selectUsers(receiver)`. Selections are delivered through the receiver's `receive(selection)` method.\nWith directory search enabled, users select only directory results; empty or failed searches do not\noffer an exact-username/email fallback. With search disabled, users enter an exact identifier\ndirectly. Arrow keys navigate options while focus stays in the input; Tab moves to the next control\nwithout selecting a user. Picking someone does not re-run the current search: people already added\nor being added are excluded when each search is issued and hidden from results returned earlier.\nIf minting or delivery fails, the current query is refreshed so the person can be selected again,\nincluding when the server excluded them from a search made while their selection was pending.\nRefreshed results stay dismissed after blur or Escape until the user focuses, types, or uses an\narrow key to reopen them.\nEach person the app accepts is announced to assistive technology by their snapshotted name.\n\n**Done** and the close button both wait for ticket minting and selection deliveries before closing.\nIf a pending mint or delivery fails, closing is cancelled so the error remains visible and the user\ncan retry or choose **Done** again. Every picker session starts with fresh search and selection state,\nincluding when closing one and opening another are batched in the same render.\n**Stop waiting and close** cancels the picker and disposes its outstanding mint and delivery RPC\npromises as well as its receiver; frame teardown does the same. Closing and cancelling both resolve\n`selectUsers()`; after a cancellation, a delivery already sent to the app may still settle, and\ncancellation cannot undo a selection the app already accepted. `selectUsers()` rejects only when the\npicker cannot be opened or, on teardown, when the app's connection to the host closes. A superseded\nor abandoned directory search is disposed as soon as it is no longer needed.\n\n## Authentication modes\n\nThe frontend supports two authentication modes, selected at build time.\n\n### Password mode (default)\n\nUsers log in with a username and password. Account creation is available via `/signup`.\nThis is the default — no extra configuration needed.\n\n### Cloudflare Access mode\n\nWhen the backend is deployed behind [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/), Access handles identity before the user ever reaches the app. In this mode:\n\n- The password-based login page and signup page are disabled.\n- On load, the app authenticates automatically using the CF Access session that Access has already established (via `authenticateFromCfAccess()` on the server).\n\nTo build in CF Access mode, set `VITE_CF_ACCESS_MODE=true`:\n\n```sh\nVITE_CF_ACCESS_MODE=true pnpm exec vp run build\n```\n\nOr add it to a `.env` file for persistent local configuration:\n\n```sh\n# .env.local\nVITE_CF_ACCESS_MODE=true\n```\n\nThe backend also needs to be configured with the `CF_ACCESS_ISS` and `CF_ACCESS_AUD` environment variables (see the workshop-backend package) for the JWT verification to work.\n","isInternal":false,"tokens":856,"sizeBytes":4091}],"systemPromptSnippet":"<yakaai_skills repo=\"cloudflare/cloudflare-os\">\n<!-- File: AGENTS.md (Tokens: ~8012 | Category: root-instruction) -->\nThis project is building a platform for \"vibe coded\" personal applications and AI agents that run inside a strong sandbox.\n\nThe following files are commonly important to reference:\n\n* packages/workshop-shared/node_modules/capnweb/README.md: Explains how to use Cap'n Web RPC, which is used extensively for client-server communications.\n* packages/workshop-shared/src/api.ts: Defines the RPC API used between the frontend and backend.\n\nThe project structure is:\n\n* packages/workshop-frontend: The Gadgets Workshop UI.\n    * This is a pure single-page app, running entirely client-side.\n    * It speaks to the backend using an RPC API over a persistent WebSocket connection.\n    * Uses React, Kumo UI (https://kumo-ui.com/api/component-registry), Phosphor icons, and Vite.\n* packages/workshop-backend: The Gadgets Workshop server.\n    * Runs on Cloudflare Workers.\n    * This is the **kernel**: it defines the architecture and is held to a higher bar than UI/gatekeeper code. Reviewers read *every line* of `workshop-backend` and of API changes in `workshop-shared`, so keep diffs here small and elegant. Concretely: doc-comment **every** exported member of the `workshop-shared` public API (types, consts, and functions — not just interfaces); never introduce a hand-written interface that mirrors an RPC interface plus an `as unknown as` cast (derive from the real type instead, or rethink the design); and prefer reusing existing mechanisms over adding parallel ones. Capability-based security note: a resource becomes \"ambient\" (auto-injected) only by user/admin configuration — a gatekeeper must never assert its own ambience. When a change to this package is large, split it by concern into separate PRs (and at minimum group commits so `workshop-backend`/`workshop-shared` can be reviewed apart from UI), since fewer kernel lines = easier review.\n    * Installs the **bundled blueprints** from `@gadgets/bundled-blueprints` on a deployment's first `/api` request, and offers them as the deployment's standard output formats. `scripts/build-bundled-blueprints.ts` generates the gitignored `src/generated/bundled-blueprints.ts` they are installed from, which holds each blueprint's manifest and built files (`BUNDLED_BLUEPRINTS_DIR` overrides the source directory), so `build` and `test` both run the generator first; `pnpm import:bundled-blueprint` updates or adds a blueprint from a Workshop export, of either `.gadget` version. Installing one (`src/bundled-blueprints.ts`) stores it as any published blueprint is stored: a git commit of its files, as a packfile at `<blueprintId>/<commitId>` in R2, named by `metadata.commitId` in KV (see `docs/blueprints.md`). The commit is a *snapshot release* (`buildSnapshotRelease` in `src/blueprint-release.ts`): parentless, with a fixed author, date and message, so it depends on the files alone. Every deployment therefore derives the same commit from the same files, and the installer never reads what was installed before. The cost is that a bundled blueprint's versions are not chained to one another as a published blueprint's releases are. A gadget made from one still takes its updates, merged against the release the gadget last took.\n    * Agent turns emit the Workers trace spans Cloudflare's Agents dashboard reads (`src/agent-tracing.ts`): `invoke_agent` per turn, `chat` per model request, `execute_tool` per tool call, and `tool_approval` when an agent's action waits for a manual decision and when it is approved or denied. They carry token counts, including cache reads and writes, but never prompts, responses or tool data. A span belongs to the invocation that opened it, and once a Durable Object invocation has returned, the next event the object receives ends its trace. So a turn keeps all its spans only while the request that started it (usually the browser session) stays open.\n* packages/bundled-blueprints: The blueprints the deployment ships with, `blueprints/<name>/` holds each blueprint's `blueprint.json` plus its gadget code under `files/`, which may be TypeScript.\n* packages/workshop-shared: Shared API definitions between client and server.\n    * This defines the application's RPC interface.\n    * The RPC protocol is Cap'n Web, which has similar semantics to Cloudflare's Worker-to-Worker RPC system, while being able to run in a browser over WebSocket. Read the readme for details.\n* packages/configurator-ui: The authoring API (`src/index.ts`, type-only stubs) for optional gatekeeper resource configurator UI modules, the sandbox runtime that renders them (`src/runtime/`: Kumo components on `preact/compat`), and the build that bundles both into each configurator's frame (`build/`).\n    * Gatekeeper configurator UI modules are compiled by `scripts/build-gatekeeper-configurator.ts` as part of package builds. Each frame is one self-contained HTML document: the minified runtime, its Tailwind/Kumo CSS, and the module, under a content security policy that admits only those scripts by hash and allows no network access. Kumo and Base UI support only React, so the browser suite (`vitest.browser.config.ts`, Chromium) is what catches an upgrade that stops working on Preact; `build/frame.test.ts` drives a generated frame over the real Cap'n Web host protocol.\n    * `pnpm dev-server` rebuilds a gatekeeper's frames when its `src/configurator/` or the runtime's `src/runtime/` changes; restart it after editing `build/`, which the watcher has already loaded.\n* packages/ui: Shared runtime React UI used across the Workshop and gatekeeper management apps.\n    * Kumo remains the primitive and token foundation. Reusable Gadgets interaction patterns and composed controls belong here rather than being reimplemented in individual apps.\n* packages/gatekeeper-*: Gatekeeper workers for external service integrations.\n    * Each gatekeeper runs as a separate Cloudflare Worker — with one exception the prefix does not capture: a `gatekeeper-*` package with **no `wrangler.jsonc` is a library, not a worker** (`gatekeeper-kit` here; `gatekeeper-shared` in the internal repo). Deployable discovery is config-gated, not name-gated — `readDeployablePackages` in `scripts/release/manifest-lib.ts` keys solely on the presence of `wrangler.jsonc`, and `run-dev-server.ts` requires it too — so adding one to a library package is what would make it deployable, at which point `workerKind` would classify it a gatekeeper by prefix and the deploy wizard would demand `CLIENT_ID`/`CLIENT_SECRET` for it. `manifest-lib.test.ts` fails first if that ever happens.\n    * Gatekeepers handle OAuth flows and provide sandboxed access to external APIs. A connect URL is a bearer capability, so every connect/reconnect flow ends on the kit's `connectHandoffPageHtml`, which sends the popup to the Workshop's `/connect/handoff` page, and that page redeems the single-use ticket over the popup's own session together with a per-flow nonce (see `docs/connect-handoff.md`); a reconnect stages its new credentials via `gatekeeper-kit/credential-stage` until the Workshop calls `GatekeeperUser.commitReconnect(stageId)` with the id that completion reported; completion is confirmed through the ticket, never through the URL alone.\n    * A gatekeeper may declare `VendorDescription.autoProvisionsAccount`: it can mint a connected account with no OAuth flow (via `GatekeeperVendor.createAccount()`, which takes no user identity). For such gatekeepers the deployment admin picks a per-vendor mode in the admin Gatekeepers panel — **disabled** / **optional** / **enabled** (default **optional**) — resolved in `provisioning-policy.ts`: `enabled` auto-provisions the account for every user (forced, and hidden from the Connectors list), `optional` lets each user opt in from the Connectors page or during onboarding (where each starts switched on and is provisioned when the user finishes), and `disabled` offers it to no one (existing accounts go dormant). The Workshop persists the account in the user DO like any connected account (the account capability — not an asserted identity — is the authority thereafter). The **account** (a `GatekeeperUser`) declares in its `AccountDescription` whether it provides an agent **singleton** (`singleton: { tsType }`) and/or a **management UI** (`providesUi`). The Workshop auto-provides the singleton to the owner's workspaces as an **ambient gatekeeper record**, folded into each chat's env as a **named chat binding** (named by the gatekeeper's `suggestedBindingName`; see `prepareChatBindings` in overseer.ts) that the agent reads in `executeCode` (`getSession`/`getAgentCatalog`), each read recorded as an observation. It is not bound to any gadget by default — most gadgets never call it programmatically — but the agent may wire it into a gadget's binding list with `setGadgetBinding` when the gadget's persistent code needs it. The UI is hosted at `/gatekeepers/$appId` (the gatekeeper's vendor id, e.g. `/gatekeepers/context`) via `startAppUi({ isAdmin })`. The two are orthogonal — an account can declare either, both, or neither.\n* packages/mcp-shared: Shared implementation behind the two MCP gatekeepers — `gatekeeper-mcp` (endpoints a user pastes) and `gatekeeper-mcp-portal` (one admin-configured portal). Not a Worker; a library both import, holding the MCP client, the OAuth chain, the account DO base, the resource-URL scope grammar, and the queued-action store. See `packages/mcp-shared/README.md` and each connector's README.\n    * The trust boundary is `tools.ts`, and nothing outside it reads a tool's annotations: a tool the server declares `readOnlyHint: true` runs as an observation, everything else is queued for approval, and auto-*applying* a write additionally requires a `vetted` endpoint — which only the portal can produce, via `MCP_PORTAL_TRUST_ANNOTATIONS`.\n    * OAuth uses the official `@modelcontextprotocol/client`; always give SDK OAuth operations `sdkFetch(...)` so every request and redirect retains endpoint and SSRF checks.\n* packages/gatekeeper-context: The Context Library — a gatekeeper whose account provides a singleton read session + a management UI, for authoring collections of context documents that agents read as observations. Collections have one of two visibilities: **private** (created by one account and shareable with others) and **public** (created/edited only by deployment admins, readable by everyone and auto-enabled for all users; not shareable). It owns its state in three Durable Objects (`ContextCollectionDurableObject` for content and a private collection's grants, `UserLibraryDurableObject` for each account's index of collections it created or was given, `LibraryRegistryDurableObject` for the domain's public set) plus a KV namespace. All data is namespaced by a `sharingDomain` (from the binding's props, see `domain.ts`) so multiple workshops sharing one gatekeeper instance stay isolated.\n    * Sharing is whole-collection. A private collection's own grants (`viewer` / `editor` / `owner`, keyed by Context account) and its `everyoneCanView` flag are the sole authority, interpreted only by `collection-access.ts`; every listing, agent read, and observer verification re-checks them live, so a library index entry is discovery and never access. A collection always keeps an owner except when its last owner's account is disconnected while others still hold access; its editors then keep editor permissions only (nobody can delete it or grant ownership), and it is deleted once its last grant goes, even if everyone can view it. Everyone-viewable only grants read access (so sharing a workspace that used the collection is not blocked); it never adds the collection to anyone's library. Shares arrive through the Workshop user picker: the management API redeems the ticket and takes the recipient's account IDs from their Context verifiers (`getAccountId`), never from the browser, and grants all of that person's accounts together. Losing write access revokes the git mirror tokens that account minted, and tokens from before sharing count as the creator's; the collection serializes grant changes, token minting, and deletion so none can race another, and retries a failed revocation from an alarm. Disconnecting marks the account's library first, so a share racing it is refused rather than left behind. `plans/context-collection-sharing.md` is the design record.\n    * Its `GatekeeperVendor` entrypoint (bound as `GATEKEEPER_CONTEXT`) declares `autoProvisionsAccount` and mints a `ContextAccount` via `createAccount()` (no user identity is passed in; the account keys its private data by its own generated `accountId`). The account exposes the agent read session (`getSession()`), collection discovery metadata (`getAgentCatalog()`), and a management UI (`startAppUi({ isAdmin })`). The UI is a single-file React SPA in `app/` (Vite + Tailwind + Kumo) bundled by `build-app.ts` into `src/generated/app.txt`.\n* packages/gatekeeper-scheduler: Scheduled Tasks — an auto-provisioned gatekeeper whose account provides an ambient singleton for registering persistent workspace callbacks plus a read-only management UI. One account-scoped `ScheduleDriver` Durable Object stores enabled schedules and delivers them from a shared alarm; hook enablement remains in the Workshop Connections UI.\n* packages/gatekeeper-websearch: Web Search — an auto-provisioned gatekeeper whose account provides an ambient `WEB_SEARCH` singleton that searches the public web through its own Workers AI binding (`websearch()`, through the AI Gateway its `CF_AI_GATEWAY` var names, provider pinned to Ceramic). The deploy service sets that var to the instance's gateway, the backend's; anywhere else it defaults to the account's `default` gateway. Each query is recorded as an observation *before* it is sent. A workspace that has observed restricted data can still search: unlike a `webFetch` URL, the query can only go to the pinned provider. Preinstalled on every deploy like Scheduled Tasks; every search spends the account's AI Gateway credits, outside the model spend limits, unless that gateway holds a Ceramic key, which Ceramic then bills. The README is the design record.\n* packages/gatekeeper-gitlab: GitLab, for gitlab.com and self-hosted instances — a mirror of `gatekeeper-github` (project / issue / merge request resources, the same action queue and read-time simulation, the same worktree contract) in GitLab's own terms. `plans/gitlab-gatekeeper.md` is the design record, and its divergence table lists every place GitLab's API forced a different shape; the README covers instance configuration (`GITLAB_URL`, `GITLAB_API_URL`, and an optional Cloudflare Access service token).\n* packages/gatekeeper-x: X (formerly Twitter) — account / post / List / read-only profile resources over plain `fetch` (the `twitter-api-v2` package breaks on Workers). `plans/x-gatekeeper.md` is the design record. X's API is pay-per-use and bills every resource a read returns, so cost is part of the design and easy to undo by accident:\n    * Every read goes through the gatekeeper's `#read`, which reserves against the connection's daily read limit (`X_DAILY_READ_LIMIT`, counted per UTC day in the `UserAccount` DO across all of the connection's bindings) and settles with what X returned. Listings use `XCursor`, one X request per `next()`, never the kit's `TokenCursor`, which refills a short page with up to ten more billed requests.\n    * Only bookmark, mute and hide are `autoApprovable`: X's developer policy forbids automating likes, follows, reposts and posts. Do not widen that list.\n    * A connection is pinned to the X user it was made as (a reconnect as anyone else is refused and its new grant revoked), and actions are fenced on that X user's ID rather than the connection generation, so a same-user reconnect to widen scopes does not strand pending actions.\n    * Publishing is `claimBeforeApply`, and a send whose outcome X never reported is reconciled inline on the next apply (an attempt marker, then a look at the account's posts from around the attempt, binding only a unique match on text, links, media and reply parent), because the kit's `ActionOutcomeUnknownError` is terminal. Creating a List is reconciled the same way. Rejecting a publish or List creation that may already be on X is refused.\n    * X is renaming \"tweet\" to \"post\" on the wire and both spellings occur; `x-normalize.ts` reads both. The configurators copy URL grammar from `x-urls.ts`, and `__tests__/configurator-url.test.ts` keeps the copies in step.\n* packages/gatekeeper-cloudflare: Cloudflare OAuth, serving three unrelated purposes from one connected account — sign-in (`AUTH_GATEKEEPERS`), AI Gateway billing, and **Workers Observability** read-only telemetry resources. Two resource granularities (whole account, or one Worker) both map to the single indivisible `workers-observability.read` scope, so the capability boundary is the *binding*, not the grant.\n    * Layering: `observability-api.ts` is the only place that talks HTTP; `observability-parse.ts` validates every response (no blind casts); `observability-session.ts` is the agent-facing session, where every read funnels through one `#observe` so no path returns data unaudited; `observability-discovery.ts` derives field names/values from a sampled events query.\n    * Scopes fail closed to `BILLING_SCOPES`, and an omitted `resourceUrlPatterns` (\"all resource types\") is distinct from `[]` (\"none\"): a billing-only connection must not silently acquire telemetry access, and recording a wider grant than was made would make `ensureResources` short-circuit into a binding that 403s with no way to re-prompt.\n    * Three provider behaviours return wrong-but-plausible data with no error, so none is trusted (all verified against a live account): `telemetry/keys`/`values` **ignore** the `filters` they accept, so a constrained discovery call is answered from a filtered `telemetry/query` sample or it would disclose the whole account; an unknown filter key matches nothing, which bites because a log's own fields are returned nested under `source` but indexed under their *bare* name (`{event: \"x\"}` → `source.event`, queried as `event`, hence `observabilityFieldKey` accepting the `source.` alias wherever a caller names a field); and `/accounts` pages at 20 by default, so it must be walked to the end.\n    * Worker-scoped bindings prepend an immutable `$metadata.service` filter *and* re-filter the response, since a filter the provider silently ignored would leak another Worker's telemetry. A dropped event proves the filter was not applied, so the provider's own `count` is then withheld rather than reported (it would count the whole account) and the drop is logged at `error`; `statistics` is kept, since it describes what the query cost rather than how much matched. Pagination cursors come from the provider's raw events rather than the surviving ones, so a fully-foreign page can't stall pagination and hide the caller's own older data. Trace *summaries* are account-only (their shape describes the whole cross-service trace); a Worker binding can still fetch its own events for a known trace id. `calculate()` is the one read with no second line of defence — an aggregate can't be un-mixed — so it rests solely on the injected filter; that is accepted and documented on the method, with the group-by fix left as a follow-up.\n    * A provider error message can quote a caller-supplied filter value back, so only its numeric `codes` are logged — filter *values* stay out of the audit trail (`summarizeFilter`), and the message travels to the caller who caused it.\n    * Tests are two vitest projects: `vitest.config.ts` (Node, pure logic) and `vitest.worker.config.ts` (workerd, for `RpcTarget`/`RpcStub`/Durable Objects). The workerd suite reaches the gatekeeper through a `TestHooks` Durable Object because a `DurableObjectClass` carrying `ctx.props` is only reachable via `ctx.facets` — the way the overseer instantiates it.\n    * `src/configurator/*.tsx` duplicate the resource-URL grammar from `resources.ts` and **must**: `build-gatekeeper-configurator.ts` transpiles each per-file, stripping only `@gadgets/configurator-ui` and type-only imports, so they cannot import runtime helpers. `__tests__/configurator-url.test.ts` keeps the copies in step, and `configurator-fields.test.ts` drives `render` against a mocked runtime — the runtime's `clearFields` only drops an autocomplete's typed query, so a dependent field must *also* be nulled through `setValues` or the stale value silently survives into the resource URL.\n* packages/router: The public origin of a deployed gadgets instance. Serves the workshop-frontend assets and routes by path prefix: `/api/*` and `/blueprint-screenshot/*` to the workshop backend, `/gatekeeper/<name>/*` to whichever gatekeepers are bound (discovered by scanning its own `GATEKEEPER_*` service bindings, so installing a gatekeeper is purely a binding change). The same worker doubles as the dev router (`pnpm dev-server`): with no `ASSETS` binding it proxies frontend requests to the Vite dev server instead.\n\nFrontend conventions (Workshop, gatekeeper management apps, and shared UI):\n\n* IMPORTANT: Load the `frontend-conventions` skill before creating, materially changing, moving, or reviewing React frontend code anywhere under `packages/`. The bullets below are the mandatory summary; the skill contains the complete conventions and examples.\n\n* Organize code by product ownership before implementation type. Keep feature-owned components, hooks, tests, and utilities together; start directories flat and introduce responsibility-named subdirectories only when a subsystem grows.\n* Use PascalCase filenames for components and camelCase filenames for hooks and non-component modules. Colocate `*.test.ts(x)` files with their subject.\n* Keep small stateless render helpers private. Extract a component or hook when it owns meaningful state, effects, interactions, accessibility behavior, or a reusable responsibility.\n* Cross-application UI belongs in `@gadgets/ui`. An app's local `components/` directory is shared only within that app. Product-specific compositions stay with the product even when they use shared primitives.\n* Use Kumo components and semantic Kumo tokens by default. Check Kumo and `@gadgets/ui` before creating a control or interaction pattern. Do not add custom color literals, feature-local token systems, or wrappers that only restyle Kumo.\n* Consult the installed Kumo API before implementing or changing Kumo UI: run `npx @cloudflare/kumo docs <Component>` (for example, `npx @cloudflare/kumo docs Select`).\n* Tailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and typography. Custom CSS is for technical behavior Kumo and utilities cannot express.\n* Represent props that are valid only together as an object or discriminated union. Controlled values require a change callback. Name callbacks `on<Action>` and pass domain values rather than React setters or browser events.\n* Add slots, variants, DOM passthrough, imperative refs, and other customization surface only for current callers. Do not generalize based on speculative reuse.\n* Treat Effects as synchronization with external systems, not as derived-state machinery. Keep state close to its owner, calculate render data during render, clean up subscriptions, and avoid chains of Effects.\n* Prefer named arrow-function components and hooks. Do not add `useMemo` or `useCallback` without a concrete identity or performance need.\n* Preserve keyboard behavior, focus management, accessible names, and announcements when building or extracting interactions.\n* Tests should protect observable behavior, product rules, accessibility, state transitions, races, and failure paths. Do not test framework behavior or implementation details merely for coverage.\n* Prefer code that communicates intent through names and types. Comments should explain non-obvious constraints and reasons, not narrate the next line.\n\nDeployment admin settings (the `/admin` panel) follow a few conventions worth knowing when extending them:\n\n* `packages/workshop-backend/src/storage-schema/admin-settings-storage.ts` defines `AdminConfig` (its helpers live in `src/admin-config.ts`) — the deployment's \"soft\" customizations: agent instructions, banners/theme, and which gatekeeper connectors/resources are offered (plus the three-state mode for auto-provisioning gatekeepers, see `provisioning-policy.ts`). Connectors/resources default to enabled and the admin UI opts them *out*; auto-provisioning gatekeepers default to *optional*. **Authentication/authorization config (sign-in providers via `AUTH_GATEKEEPERS`, password login via `DISABLE_PASSWORD_AUTH`) is deliberately NOT here** — it stays env-var driven (`auth/config.ts`) so it can't be changed by a compromised admin session.\n* The `AdminSettings` durable object owns the authoritative `AdminConfig` and mirrors it to a single reserved KV key (`.adminConfig`, see `isReservedBlueprintKey()`), so hot-path code (connect/agent) reads it with one cheap KV get via `readAdminConfig(env)`. The DO is the only writer (`updateAdminConfig(patch)`).\n* Admin operations are exposed as an `AdminApi` capability obtained via `AuthenticatedApi.getAdminApi()` (returns null for non-admins). The `#isAdmin()` check happens once when the capability is minted, so the individual methods don't re-check.\n* `user.ts:#assertResourceEnabled()` is the single core chokepoint where disabled gatekeepers/resources are enforced before a capability is minted. Both mints call it: `getGatekeeperClassFor()` (gadget/agent code can't reach it directly) and `createResourceGatekeeper()` (reached only through the agent's `createExternalResource` tool).\n* In AI Gateway mode the deployment's models are admin settings too (`AdminConfig.modelModes`, `addedModels`, `modelSettings`, `addedProviders`, `userModelsEnabled`). `GatewayModels` in `ai-gateway.ts` is the only place that merges them with `SUGGESTED_MODELS`, and *disabled* is enforced where a model is about to run: `user.ts:getChatContext()` and `LanguageModelGatekeeper.startSession()`.\n\nWorker configs: each Worker's `cloudflare.config.ts` (`@cloudflare/config` format, built from the shared factory in `scripts/worker-config.ts`) is the source of truth. The `wrangler.jsonc` beside it is committed but generated by `pnpm configs:generate` (`scripts/generate-worker-configs.ts`) and never edited by hand, because Wrangler's new-config support cannot drive the multi-worker dev server, `wrangler types`, the vitest pools or the integration harness. Settings `@cloudflare/config` has no field for (`build`, module `rules`, KV `preview_id`, `assets.directory`) go in the config's `wrangler` export, and Durable Object migrations stay tagged in its `migrations` export, because the release manifest ships them to the deploy service verbatim. `pnpm configs:check` (part of `pnpm lint` and CI) fails on a stale or hand-written file; `pnpm dev-server` regenerates before starting.\n\nRelease pipeline (`scripts/release/`) — how customer instances get deployed:\n\n* `build-release.ts` bundles every deployable worker byte-identically (wrangler dry-run with the pinned wrangler), builds the Access-mode frontend asset build, and generates the release manifest — the contract between this repo's CI and the deploy service, produced by `manifest-lib.ts` from each package's wrangler.jsonc with account-specific values replaced by placeholders (`$ACCOUNT_ID`, `$WORKER_NAME(...)`, `$SECRET(...)`, `$PUBLIC_BASE_URL`, ...).\n* `upload-release.ts` mirrors the release to R2 content-addressed, manifest last; with `--candidate` the manifest lands under `candidates/<id>/` (invisible to the deploy service) so e2e can verify it, and `promote-release.ts` then copies it to `releases/<id>/` — publishing is that single all-or-nothing manifest copy. The copy is not isolated against concurrent promotions, so CI serializes promote runs (a GitLab resource group) and the script's newer-release guard skips candidates that a later release has already superseded.\n* The manifest is covered by a golden-file test; after an intentional manifest change, regenerate with `UPDATE_GOLDEN=1 node --test scripts/release/manifest-lib.test.ts` and review the golden diff.\n* Running the flow by hand (upload and promote need `R2_ENDPOINT`, `R2_BUCKET`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`):\n    * `node scripts/release/build-release.ts --out release-out` — build everything into `release-out/` (id defaults to `r<CI_PIPELINE_IID>-<sha7>` in CI, `dev-<timestamp>` locally; override with `--release-id <id>`).\n    * `node scripts/release/upload-release.ts --release release-out --candidate` — mirror to R2; omit `--candidate` to publish directly (bypasses the gate — CI never does this).\n    * `node scripts/release/promote-release.ts --release-id <id>` — copy the verified candidate's manifest into `releases/<id>/`.\n* Deploy-wizard configuration: an installable gatekeeper's user-supplied inputs default to OAuth `CLIENT_ID`/`CLIENT_SECRET` secrets; a per-package `deploy-inputs.json` overrides them, and `NO_DEFAULT_CRED_INPUTS` in `manifest-lib.ts` opts out gatekeepers that take no third-party OAuth app credentials (the wizard blocks Install on unfilled secret inputs, so a spurious default makes a gatekeeper uninstallable). Backend instance-state vars (`ADMINS`, `CLOUDFLARE_OS_DEPLOYMENT`, ...) are injected by the deploy service at PUT time, never manifest-templated.\n\nDevelopment checks:\n\n- Run `pnpm lint` before pushing; it includes script types, generated-config checks, and the workspace build.\n- `pnpm build` runs type-checks and codegen. For one package use `pnpm exec vp run -F <package> build`; most builds are tasks, not package.json scripts.\n- Use `pnpm --filter <package> test:run` while iterating and `pnpm test` for workspace verification. Cached tasks may replay results; cold-checkout tests must generate their own prerequisites on both entrypoints.\n- Cached Vite+ tasks strip undeclared environment variables. Declare output-affecting variables in `cache.env`; a path value does not fingerprint the external files it names.\n- For slow CI/container runs, keep the local watchdog defaults and explicitly choose longer limits. See [testing guidance](docs/testing.md) for commands and timeout policy.\n- Read [build-system guidance](docs/build-system.md) when changing task graphs, codegen, caching, or dev-server startup; [integration testing](docs/integration-testing.md) covers the workerd harness.\n\nIMPORTANT: This repository uses pnpm, not npm. Always use pnpm.\n\nIMPORTANT: Remember when using RPC to use promise pipelining whenever possible. Cap'n Web implements promise pipelining (similar to Cap'n Proto). This means that if an RPC returns a stub, it's not necessary to await the RPC -- the promise itself can be used in place of the stub. Also, Cap'n Web lets you use the promise for a future result (even if it isn't a stub) in the arguments for another call; the promise will be replaced with its resolution on the server side before delivering the arguments. See the Cap'n Web README.md for more details.\n\nIMPORTANT: When using React's useState(), the state value cannot be an RPC stub. At runtime, all stubs appear to be callable (because the system doesn't actually know if the stub points to a function on the server side or not). But the setter returned by useState() has different behavior if passed a function (including any callable object): it calls the function in order to get the state. In order to avoid this problem, whenever a useState() state will contain an RpcStub, it's important to wrap the stub in an object, and set the state to that object instead.\n\nIMPORTANT: RPC stubs must be disposed to prevent resource leaks on the server side. Call `stub[Symbol.dispose]()` when the stub is no longer needed (or use a `using` declaration where possible). In particular, when a React component obtains a stub in a useEffect, the cleanup function should dispose the stub.\n\nIMPORTANT: All RPC interface implementations should use the annotation `@validateRpc()` to apply capnweb-validate, which installs auto-generated runtime type validation matching the interface's TypeScript signatures. Do not write redundant validation code that duplicates the checks capnweb-validate already covers.\n\nIMPORTANT: Server-side logging uses `@gadgets/observability/logger` (frontend browser `console.*` is out of scope):\n- Define a package-owned field type and module-scoped logger with a stable dot-separated `component`\n  and, for gatekeepers, `vendorId`:\n  `const logger = createLogger<GitHubLogFields>({ component: \"gatekeeper.github\", vendorId: VENDOR_ID });`.\n- Emit concrete event names and relevant typed fields, for example:\n  `logger.warn(\"failed to notify credential expiry\", { event: \"credentials.expiry.notify.failed\", error: err });`.\n  Each call emits one indexed object; module/child fields such as `vendorId` are inherited.\n- Use immutable `logger.with(fields)` for object-owned or nearby context. Prefer module/object loggers\n  over logger parameters, and do not replace a shallow child logger with ambient context just to\n  remove a local variable.\n- For bounded operation context needed by deep helpers, independent loggers, or other observability\n  consumers, use `createObservabilityContext` from `@gadgets/observability/observability-context`.\n  Re-establish it per operation;\n  it does not cross RPC, hibernation, or restart, and requires `nodejs_als` or `nodejs_compat`.\n- Pass caught values as `error`. The helper stringifies `Error` instances and primitives, uses an\n  own string `message` for plain objects, omits `undefined`, and adds stacks to all `Error` logs.\n  Keep this normalization deliberately small; do not traverse causes or copy arbitrary properties.\n- Extend field vocabularies locally. Levels: `error` needs attention, `warn` continues best-effort,\n  `info` is notable lifecycle, and `debug` is noisy breadcrumbs. Never log secrets, prompts, headers,\n  tokens, or request/response bodies.\n- To also dispatch a failure to the optional external issue Reporter (in addition to logging it),\n  call `reportIssue(failureSite, caught, options?)` from\n  `@gadgets/observability/error-reporting`. Attach ambient fields from the package's observability\n  context and augment them with capture-site fields:\n  `reportIssue(\"overseer.catalog-fallback\", err, { handled: true, attributes: { ...obsContext.get(), gatekeeperId } });`.\n  It is a no-op when the `ERROR_REPORTER` binding is absent (local dev / deployments without an issue\n  destination). Only bounded scalars are retained as attributes; reported context obeys the same\n  no-secrets rules as log fields.\n- Usage metrics go to the optional `METRICS` Analytics Engine dataset through\n  `@gadgets/observability/metrics`, whose column layout (`metrics-schema.ts`) is also what\n  dashboards query; Workshop product events reach it through `recordAnalytics`.\n\nIMPORTANT: Frontend error reporting is a separate, opt-in path:\n- `@gadgets/error-reporting` owns the vendor-neutral browser/Worker event contract and tolerant,\n  bounded normalization. `VITE_FRONTEND_ERROR_REPORTING=true` enables trusted frontend producers\n  and their hidden source maps at build time; deployments without reporting should leave it unset.\n- The Workshop browser sends best-effort reports to the same-origin `POST /api/client-errors`\n  endpoint. The backend dispatches only when both `FRONTEND_ERROR_REPORTER` and\n  `FRONTEND_ERROR_RATE_LIMITER` are bound; otherwise the endpoint is an intentional no-op.\n- Gatekeeper management/configurator UIs run as Workshop-owned opaque-origin `srcDoc` frames. They\n  send bounded reports with `postMessage`; the host accepts them only from the known frame window\n  with origin `null`, adds host-owned surface/vendor context, and performs the same-origin POST.\n  Do not add direct cross-origin reporting from a gatekeeper Worker domain.\n- Frontend reports never convey authority. `reportedUserId` is supplied by the client and unverified\n  — the name records that it is a report, not a finding — so it is a diagnostic label only and must\n  never be read to make a decision. `pageLocation` is origin and pathname only, rebuilt by\n  `normalizePageLocation` rather than trusted from producers, because a share link's fragment is a\n  bearer capability and an `href` also retains credentials; non-`http(s)` URLs are dropped entirely.\n- Install automatic capture only in trusted first-party surfaces, never gadget/user-authored code.\n  Exception messages and stacks reach the external Reporter, so never intentionally put secrets,\n  prompts, tokens, headers, or request/response bodies in thrown errors or report metadata.\n\n<!-- File: .agents/skills/frontend-conventions/SKILL.md (Tokens: ~1103 | Category: anthropic-skill) -->\n---\nname: frontend-conventions\ndescription: Use for creating, modifying, moving, or reviewing React frontend code anywhere in packages/*, including Workshop pages, gatekeeper management apps, shared UI, components, hooks, forms, interactions, styling, accessibility, and frontend tests.\n---\n\n# Frontend Conventions\n\nApply these conventions to the Workshop SPA, gatekeeper management SPAs, and `@gadgets/ui`.\nPackage-level `AGENTS.md` files add product-specific rules but do not replace this guidance.\n\n## Ownership And Organization\n\nOrganize code by product ownership before implementation type. Feature directories own product\nbehavior and may contain components, hooks, tests, and utilities that change for the same reason.\n\nStart directories flat. Do not introduce `components/`, `hooks/`, `helpers/`, or `tests/`\nsubdirectories merely to classify files. Introduce a responsibility-named subsystem directory only\nwhen several files form a coherent unit or the flat directory becomes difficult to scan.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Colocate `*.test.ts(x)` files with their subject. Feature organization does not replace\nthe one-component-per-file model.\n\nKeep product behavior with its product even when another feature consumes it. Promote code only as\nhigh as its ownership requires:\n\n- Code shared within one feature belongs at the nearest common feature directory.\n- Feature-independent code shared across unrelated areas of one app may live in that app's\n  `components/` or `hooks/` directory.\n- Runtime UI shared by independent frontends belongs in `@gadgets/ui`.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase domain behavior.\n\n## Components And Hooks\n\nCreate a separate component when it owns meaningful state, effects, interactions, accessibility\nbehavior, or reusable responsibility; represents a distinct UI concern; or obscures its parent's\nmain flow. Keep small stateless render helpers private until they develop an independent concern.\n\nExtract a hook when it owns a coherent behavior or external synchronization lifecycle, not simply\nto shorten a file. Keep code together when an extracted child would mostly forward markup or depend\non the parent's refs, setters, and synchronization callbacks.\n\nPrefer named arrow-function components and hooks. Type props directly rather than using `React.FC`.\nGive wrappers such as `memo` and `forwardRef` stable DevTools names.\n\n## Component APIs\n\nRepresent props that are valid only together as an object or discriminated union. A controlled\nvalue requires a change callback; otherwise expose an uncontrolled initial value. Do not copy a\ncontrolled prop into local state with an Effect.\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events.\nAdd `children`, slots, variants, `className`, DOM passthrough, and imperative refs only for current\ncallers, not speculative reuse.\n\nUse context for genuinely application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props.\n\n## Kumo And Styling\n\nUse Kumo components and semantic tokens by default. Check Kumo and `@gadgets/ui` before creating a\ncontrol or interaction pattern. A shared Gadgets component should compose Kumo behavior, not merely\nrename or restyle a primitive.\n\nDo not add custom color literals, arbitrary Tailwind colors, feature-local token systems, or local\nreplacements for Kumo surfaces, borders, text, status, focus, and interaction tokens unless the user\nexplicitly requests them. Existing legacy colors are not precedent.\n\nTailwind is appropriate for structure, spacing, sizing, positioning, responsive behavior, and\ntypography. Use custom CSS only for technical behavior Kumo and utilities cannot express. Global\nKumo token theming is an application-level decision and must not be changed during ordinary feature\nwork.\n\nWhen Kumo is unsuitable, identify the concrete behavioral or accessibility gap before introducing\na shared abstraction.\n\n## React\n\nTreat Effects as synchronization with external systems, not as derived-state machinery or a way to\nsequence user interactions. Calculate render data during render, keep state near its owner, prefer a\ncomponent `key` for identity resets, and use `useSyncExternalStore` for suitable external stores.\n\nEffects that fetch or subscribe must clean up stale work and remain correct when restarted. Avoid\nchains of Effects and do not synchronize two pieces of React state when one can be derived.\n\nDo not add `useMemo` or `useCallback` without a concrete identity or performance need. Preserve\nkeyboard behavior, focus management, accessible names, announcements, and mouse/touch/hybrid input\nparity. RPC stubs must follow the disposal and React state rules in the root `AGENTS.md`.\n\n## Comments\n\nPrefer names and types that communicate intent. Comments should explain non-obvious constraints,\nsecurity or performance reasons, and deliberate departures from conventions. Do not narrate the\nnext line. Remove or update comments when their constraint changes.\n\n## Tests\n\nTests should protect observable behavior, product rules, accessibility, state transitions, races,\nand failure paths. Do not test React, JavaScript, Kumo, or another framework's own behavior merely\nfor coverage. Avoid assertions coupled only to implementation details or trivial passthrough.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage only\nwhen an extraction exposes important previously untested logic.\n\n<!-- File: .agents/skills/write-gatekeeper/SKELETON.md (Tokens: ~5979 | Category: anthropic-skill) -->\n# Gatekeeper Implementation Skeleton\n\nReplace all `My`/`my`/`MY` prefixes with the service name.\n\n## Main implementation (`src/<name>.ts`)\n\n```typescript\nimport { WorkerEntrypoint, DurableObject, RpcTarget, RpcStub } from \"cloudflare:workers\";\nimport {\n  GatekeeperUser,\n  GatekeeperUserVerifier,\n  GatekeeperVendor as GatekeeperVendorIface,\n  Gatekeeper,\n  HookController,        // Remove if no hooks\n  HookInitiator,         // Remove if no hooks\n  HookTargetMetadata,    // Remove if no hooks\n  ResourceDescription,\n  ApprovalQueue,\n  ObservationDescription,\n  VendorDescription,\n  GatekeeperConnectCallback,\n  AccountDescription,\n  SupportedResource,\n  ResourceConfiguratorFrame,\n} from '@gadgets/workshop-shared/gatekeeper';\nimport { MySession, MyHook as MyHookIface } from \"./types\";  // Remove MyHook if no hooks\nimport TYPES_CODE from \"./types.txt\";\nimport MY_CONFIGURATOR_HTML from \"./generated/my-configurator-ui.txt\";\n\nconst NONCE_BYTES = 32;\nconst NONCE_LIFETIME_MS = 10 * 60 * 1000;  // 10 minutes\n\nfunction hexEncode(bytes: Uint8Array): string {\n  return [...bytes].map(b => b.toString(16).padStart(2, \"0\")).join(\"\");\n}\n\nfunction generateNonce(): string {\n  return hexEncode(crypto.getRandomValues(new Uint8Array(NONCE_BYTES)));\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n  let encoder = new TextEncoder();\n  let bufA = encoder.encode(a);\n  let bufB = encoder.encode(b);\n  if (bufA.byteLength !== bufB.byteLength) return false;\n  return crypto.subtle.timingSafeEqual(bufA, bufB);\n}\n\ntype Env = Cloudflare.Env & {\n  BASE_URL?: string,\n};\n\nfunction getBaseUrl(env: Env) {\n  return (env.BASE_URL || \"http://localhost:8787/gatekeeper/<name>\").replace(/\\/+$/, \"\");\n}\n\nfunction getBasePath(env: Env) {\n  const path = new URL(getBaseUrl(env)).pathname;\n  return path === \"/\" ? \"\" : path;\n}\n\nconst SELF_CLOSING_HTML = `<!DOCTYPE html>\n<html lang=\"en\">\n  <body>\n    <script type=\"text/javascript\">window.close();</script>\n    <p>Authorization complete. You may close this tab and return to the Gadgets Workshop.\n  </body>\n</html>`;\n\nconst MY_RESOURCE: SupportedResource = {\n  urlPattern: \"https://example.com/*\",\n  title: \"My Resource\",\n  description: \"Access a specific resource.\",\n};\n\n// ---------------------------------------------------------------------------\n// HTTP handler — serves the browser-based auth flow.\n// For a complete OAuth example, see gatekeeper-google/src/google.ts.\n\nexport default {\n  async fetch(req: Request, env: Env, ctx: ExecutionContext) {\n    let url = new URL(req.url);\n    let basePath = getBasePath(env);\n    if (!url.pathname.startsWith(basePath + \"/\") && url.pathname !== basePath) {\n      throw new Error(`Request path ${url.pathname} does not match BASE_URL path ${basePath}`);\n    }\n    let relPath = url.pathname.slice(basePath.length);\n    let path = relPath.slice(1).split(\"/\");\n\n    if (path.length === 2 && path[0].length === 64 && path[1].length === NONCE_BYTES * 2) {\n      // Auth initiation: the user has visited the connect URL.\n      let doId = path[0];\n      let nonce = path[1];\n      let stub = ctx.exports.UserAccount.get(ctx.exports.UserAccount.idFromString(doId));\n      if (!await stub.verifyNonce(nonce)) {\n        // Show a friendly error page for expired/replayed links.\n        return new Response(\"TODO: error HTML\", {\n          headers: { \"Content-Type\": \"text/html; charset=utf-8\" }\n        });\n      }\n\n      // TODO: Redirect to external auth provider, or present an auth form.\n      // For OAuth, generate a second nonce for the `state` parameter here —\n      // see gatekeeper-google for the full pattern.\n      throw new Error(\"TODO: implement auth initiation\");\n    } else {\n      return new Response(\"Not Found\", {status: 404});\n    }\n  }\n};\n\n// ---------------------------------------------------------------------------\n// Vendor — top-level API exposed to the Workshop\n\nexport class GatekeeperVendor extends WorkerEntrypoint<Env> implements GatekeeperVendorIface {\n  async describe(): Promise<VendorDescription> {\n    return {\n      displayName: \"My Service\",\n      url: \"https://example.com\",\n      logo: { url: \"TODO: data URL or hosted image URL\" },\n      color: \"#f0f4ff\",\n      tagline: \"Short summary of what this connector enables\",\n      description:\n          \"Explain what Gadgets can do with this connector in plain language.\",\n    };\n  }\n\n  // This skeleton has no independently grantable resources, so it ignores\n  // `options.resourceUrlPatterns` and stores an empty requested-resource list. Gatekeepers with\n  // grantable resources should follow gatekeeper-google.\n  async connectAccount(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      _options?: {resourceUrlPatterns?: string[]}): Promise<{url: string}> {\n    let userObjectId = this.ctx.exports.UserAccount.newUniqueId();\n    let nonce = generateNonce();\n    await this.ctx.exports.UserAccount.get(userObjectId).setCallback(callback, nonce, []);\n    return { url: `${getBaseUrl(this.env)}/${userObjectId.toString()}/${nonce}` };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserAccount DO — stores per-user credentials (tokens, API keys, etc.).\n// For a full OAuth implementation with two-phase nonces and reconnect support,\n// see gatekeeper-google.\nexport class UserAccount extends DurableObject<Env> {\n  async setCallback(\n      callback: Fetcher<GatekeeperConnectCallback>,\n      nonce: string,\n      requestedResourceUrlPatterns: string[]) {\n    // Self-destruct if the connect flow is never completed.\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.setAlarm(Date.now() + 3600 * 1000);\n    }\n    this.ctx.storage.kv.put(\"callback\", callback);\n    this.ctx.storage.kv.put(\"nonce\", { value: nonce, expiresAt: Date.now() + NONCE_LIFETIME_MS });\n    this.ctx.storage.kv.put(\"requestedResourceUrlPatterns\", requestedResourceUrlPatterns);\n  }\n\n  // Verify and consume the nonce from the initiation URL. Prevents replay.\n  // Returns false if the nonce is invalid or expired.\n  async verifyNonce(nonce: string): Promise<boolean> {\n    let stored = this.ctx.storage.kv.get<{value: string, expiresAt: number}>(\"nonce\");\n    if (!stored || Date.now() >= stored.expiresAt || !constantTimeEqual(stored.value, nonce)) {\n      return false;\n    }\n    this.ctx.storage.kv.delete(\"nonce\");\n    return true;\n  }\n\n  // Called when the user completes authorization. Store credentials and notify the workshop.\n  async completeConnection(/* auth result params */) {\n    let callback = this.ctx.storage.kv.get<Fetcher<GatekeeperConnectCallback>>(\"callback\");\n    if (!callback) {\n      throw new Error(\"Authorization timed out. Please try again.\");\n    }\n\n    // TODO: Store credentials obtained from the auth flow\n    this.ctx.storage.kv.put<string>(\"credentials\", \"TODO\");\n\n    let props: MyUserImplProps = { userObjectId: this.ctx.id.toString() };\n    try {\n      await callback.complete(this.ctx.exports.MyUserImpl({ props }));\n    } catch (err) {\n      this.ctx.storage.kv.delete(\"credentials\");\n      throw err;\n    }\n  }\n\n  async getAccessToken(): Promise<string> {\n    // TODO: Return a cached short-lived token, refreshing credentials if needed.\n    let token = this.ctx.storage.kv.get<string>(\"credentials\");\n    if (!token) throw new Error(\"No credentials set.\");\n    return token;\n  }\n\n  async alarm() {\n    if (!this.ctx.storage.kv.get<string>(\"credentials\")) {\n      this.ctx.storage.deleteAll();\n    }\n  }\n\n  async revoke() {\n    // TODO: Revoke credentials with the external service\n    this.ctx.storage.deleteAlarm();\n    this.ctx.storage.deleteAll();\n  }\n}\n\n// ---------------------------------------------------------------------------\n// UserImpl — maps resource URLs to gatekeeper DO classes\n\ntype MyUserImplProps = {\n  userObjectId: string;\n};\n\nexport class MyUserImpl extends WorkerEntrypoint<Env, MyUserImplProps>\n                         implements GatekeeperUser {\n  async describe(): Promise<AccountDescription> {\n    // TODO: Fetch account info from external service using stored credentials\n    return {\n      displayName: \"TODO\",\n      avatar: { url: \"\" },\n    };\n  }\n\n  async getSupportedResources(): Promise<SupportedResource[]> {\n    return [MY_RESOURCE];\n  }\n\n  async startResourceConfigurator(resourceUrlPattern: string): Promise<ResourceConfiguratorFrame> {\n    if (resourceUrlPattern !== MY_RESOURCE.urlPattern) {\n      throw new Error(`Unsupported resource configurator type: ${resourceUrlPattern}`);\n    }\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    let userAccount = this.ctx.exports.UserAccount.get(id);\n    let ui = new MyConfiguratorUI(async () => {\n      // TODO: Return a short-lived token or construct a read-only API client.\n      // Keep this helper private; do not expose it as an RPC method.\n      return await userAccount.getAccessToken();\n    });\n    return {\n      iframeHtml: MY_CONFIGURATOR_HTML,\n      ui: new RpcStub(ui),\n    };\n  }\n\n  async getGatekeeperClassFor(url: string): Promise<{\n    class: DurableObjectClass<Gatekeeper<any>>;\n    resource: SupportedResource;\n  }> {\n    // Parse URL to determine resource type and extract identifiers.\n    // Return a DO class with props baked in via ctx.exports.<ClassName>({props}).\n    // The Overseer will instantiate this class as a facet.\n    let props: MyGatekeeperImplProps = {\n      userObjectId: this.ctx.props.userObjectId,\n      // TODO: resource-specific fields extracted from URL\n    };\n    return {\n      class: this.ctx.exports.MyGatekeeperImpl({ props }),\n      resource: MY_RESOURCE,\n    };\n  }\n\n  async revoke(): Promise<void> {\n    let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId);\n    await this.ctx.exports.UserAccount.get(id).revoke();\n  }\n\n  async ensureResources(_resourceUrlPatterns: string[]): Promise<{url?: string}> {\n    return {};\n  }\n\n  // Mint a verifier representing this account (see Observers in SKILL.md). The overseer only ever\n  // hands it back to a gatekeeper of THIS vendor, so MyGatekeeperImpl.addObserver may trust it.\n  // For a strategy-D (low-stakes) gatekeeper, MyVerifier has no methods and this still works.\n  async getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n    let props: MyVerifierProps = { userObjectId: this.ctx.props.userObjectId };\n    return this.ctx.exports.MyVerifier({ props });\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Verifier — answers \"can this observer access X?\" against the OBSERVER's own credentials.\n// For strategy D, replace this with a no-op public method; an empty WorkerEntrypoint is not registered\n// in ctx.exports. Keep/extend it for strategy B (single-unit ACL) or C (data-set tracking). See SKILL.md.\n\ntype MyVerifierProps = {\n  userObjectId: string;\n};\n\n// A vendor-specific interface adding non-standard methods to the opaque GatekeeperUserVerifier.\n// addObserver casts the Fetcher back to this; the overseer's same-vendor guarantee makes that safe.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    let account = this.ctx.exports.UserAccount.get(\n        this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId));\n    void account; void resourceId;\n    // TODO: query the service with the observer's own token. Return true on success; return false\n    // for access errors (e.g. 401/403/404); rethrow anything else so the open fails loudly rather\n    // than silently denying.\n    return false;\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Resource configurator — narrow iframe helper API\n\nconst configuratorTokenGetters = new WeakMap<object, () => Promise<string>>();\n\nclass MyConfiguratorUI extends RpcTarget {\n  constructor(getToken: () => Promise<string>) {\n    super();\n    configuratorTokenGetters.set(this, getToken);\n  }\n\n  async listResources(query: string): Promise<{ value: string; title: string }[]> {\n    let getToken = configuratorTokenGetters.get(this);\n    if (!getToken) throw new Error(\"Configurator is not initialized.\");\n    let token = await getToken();\n    void token;\n    // TODO: Call read-only external APIs and return bounded search results.\n    return [{ value: query || \"example\", title: query || \"Example\" }];\n  }\n\n}\n\n// ---------------------------------------------------------------------------\n// Hook type — remove this section (and the HookController/HookInitiator/HookTargetMetadata\n// imports, the `subscribe` method, the `hookTsType` field, and MyHookControllerImpl) if the\n// gatekeeper doesn't push events.\n//\n// The hook interface from types.d.ts is implemented by the Gadget as an RpcTarget. Intersect it\n// with RpcTarget so it satisfies the HookController/HookInitiator generic constraints (Hook\n// extends RpcTarget).\n//\n// Note that you can also use a plain function as a hook type, e.g. `RpcStub<() => Promise<void>>`.\n// In that case you would not need to merge the type with `RpcTarget`.\n\ntype MyHook = RpcTarget & MyHookIface;\n\n// ---------------------------------------------------------------------------\n// GatekeeperImpl DO — per-resource instance, runs as a facet of the Overseer\n\ntype MyGatekeeperImplProps = {\n  userObjectId: string;\n  // ... resource-specific fields (e.g., documentId, repoOwner)\n};\n\nexport class MyGatekeeperImpl extends DurableObject<Env, MyGatekeeperImplProps>\n    implements Gatekeeper<MySession> {\n\n  async describe(): Promise<ResourceDescription> {\n    return {\n      url: \"TODO: canonical resource URL\",\n      title: \"TODO\",\n      snippet: \"TODO\",\n      suggestedBindingName: \"MY_RESOURCE\",  // Based on type, not instance\n      tsType: \"MySession\",\n      hookTsType: \"MyHook\",  // Remove if hooks are not supported\n    };\n  }\n\n  async getTypeScriptTypes(): Promise<string> {\n    return TYPES_CODE;\n  }\n\n  async startSession(approvalQueue: RpcStub<ApprovalQueue>): Promise<MySession> {\n    return new MySessionImpl(\n      approvalQueue.dup(),  // Always dup() before storing\n      this.ctx,\n      // ... API client, props, etc.\n    );\n  }\n\n  async applyAction(actionId: number): Promise<void> {\n    // Look up the action by ID from this gatekeeper's own storage, then perform it\n    // against the external service.\n    // TODO: Implement action lookup and execution.\n    throw new Error(`Unknown action: ${actionId}`);\n  }\n\n  async rejectAction(actionId: number): Promise<void | { restart?: boolean }> {\n    // Clean up simulation state for this action.\n    // Return { restart: true } if the session can't recover from rejection.\n  }\n\n  revertAction(actionId: number):\n      Promise<void | { message?: string; canRetry?: boolean; restart?: boolean }> {\n    // TODO: Undo the action (look up what was done from own storage)\n    throw new Error(\"Revert not implemented\");\n  }\n\n  // Observers (see SKILL.md \"Observer verification\"). This skeleton shows strategy B — ACL check on\n  // a single atomic resource. The overseer calls addObserver on EVERY open by every observer, so it\n  // re-verifies live access; throw to deny.\n  //   - Strategy A (private-only): `async addObserver() { throw new Error(\"...not shareable...\"); }`\n  //   - Strategy D (low-stakes):   make both methods no-ops.\n  //   - Strategy C (data-set tracking): record observed sets + store verifiers, and route every read\n  //     through an authorizeSetObservation helper that sets `excludeObservers` (see SKILL.md).\n  async addObserver(_id: string, user: Fetcher<GatekeeperUserVerifier>): Promise<void> {\n    let verifier = user as unknown as Fetcher<MyVerifierApi>;\n    if (!(await verifier.hasResourceAccess(/* this.ctx.props.resourceId */ \"TODO\"))) {\n      throw new Error(\n        \"This collaborator does not have access to the bound resource, so they cannot observe \" +\n        \"data the Gadget read from it.\");\n    }\n  }\n\n  // Idempotent: ignore unknown ids. A no-op for strategy A/B (nothing is tracked).\n  async removeObserver(_id: string): Promise<void> {}\n}\n\n// ---------------------------------------------------------------------------\n// HookController — remove if the gatekeeper doesn't push events.\n//\n// A WorkerEntrypoint the overseer uses to enable/disable the hook after the user approves it.\n// It is constructed with `props` carrying the specifics of that particular registration, so it\n// needs no other state. If your gatekeeper offers several kinds of hooks, give each its own\n// controller class.\n\n// Bind-time details for a single hook registration, baked into the controller's props.\ntype MyHookProps = {\n  // TODO: e.g. an event kind, a filter, a sub-resource id, etc. — whatever the registration\n  // method received and the controller/event source will need later.\n  filter?: string;\n};\n\ntype MyHookControllerImplProps = MyGatekeeperImplProps & MyHookProps;\n\nexport class MyHookControllerImpl extends WorkerEntrypoint<Env, MyHookControllerImplProps>\n    implements HookController<MyHook> {\n  // Called when the user enables the hook. Store `initiator` somewhere it can be reached when an\n  // event arrives — typically an event-source DO. Don't store other state until now; everything\n  // else is already in `this.ctx.props`. If already enabled, replace the previous initiator.\n  //\n  // `target` identifies where the hook delivers (workspace, and gadget when pinned to one). Store\n  // it too if you display or link to the target; otherwise ignore it, but keep the parameter\n  // declared — RPC argument validation rejects arguments the receiver doesn't declare.\n  async enable(initiator: Fetcher<HookInitiator<MyHook>>, target: HookTargetMetadata): Promise<void> {\n    // TODO: persist `initiator` (e.g. forward it to an event-source DO keyed by props).\n  }\n\n  // Called when the hook is disabled or deleted. Forget the stored initiator and clean up all\n  // related state. May never be called again, though the overseer may later call enable() afresh.\n  async disable(): Promise<void> {\n    // TODO: forget the stored initiator.\n  }\n}\n\n// Event delivery (sketch). When the external event arrives — e.g. in the event-source DO that\n// holds the `initiator` — invoke the hook like so:\n//\n//   async onEvent(initiator: Fetcher<HookInitiator<MyHook>>, event: MyEvent) {\n//     // startHook() begins a fresh session and returns the callback (re-bound to it) plus an\n//     // ApprovalQueue. `using` disposes the result (and its stubs) at end of scope.\n//     using result = initiator.startHook();\n//\n//     // A hook event is almost always an observation. (Register actions too if invoking the\n//     // callback can cause side effects.) Pipeline through the not-yet-resolved promise.\n//     await result.approvalQueue.authorizeObservation({\n//       title: \"TODO: short event summary\",\n//       description: \"TODO: details about the event being delivered\",\n//     });\n//\n//     // Deliver the event to the Gadget's callback.\n//     await result.callback.onMyEvent(event);\n//   }\n\n// ---------------------------------------------------------------------------\n// SessionImpl — the RPC interface exposed to the Gadget\n\nclass MySessionImpl extends RpcTarget implements MySession {\n  #approvalQueue: RpcStub<ApprovalQueue>;\n  #ctx: DurableObjectState<MyGatekeeperImplProps>;\n\n  constructor(\n      approvalQueue: RpcStub<ApprovalQueue>,\n      ctx: DurableObjectState<MyGatekeeperImplProps>) {\n    super();\n    this.#approvalQueue = approvalQueue;\n    this.#ctx = ctx;\n  }\n\n  [Symbol.dispose]() {\n    this.#approvalQueue[Symbol.dispose]();\n  }\n\n  // Example: observation (read). Fetch data, then authorize before returning.\n  async getData(): Promise<string> {\n    let result = \"TODO: fetch from service or cache\";\n\n    await this.#approvalQueue.authorizeObservation({\n      title: \"Read data\",\n      description: \"Fetched data from the service.\",\n    });\n\n    return result;\n  }\n\n  // Example: action (side effect). Submit for approval; do NOT perform here.\n  // Assign a sequential action ID, store the action details in the gatekeeper's\n  // own storage, then submit the ID to the approval queue. If you read current state to build\n  // the description or capture revert info, do NOT log it as an observation: nothing is\n  // returned to the caller.\n  async updateData(newValue: string): Promise<void> {\n    let actionId = /* assign next sequential ID and store action details */ 0;\n\n    await this.#approvalQueue.submitAction(actionId, {\n      title: \"Update data\",\n      description: `Update value to: ${newValue}`,\n      implementsRevert: true,\n    });\n\n    // TODO: Update cache/simulation state so subsequent reads reflect this\n  }\n\n  // Example: hook registration — remove if the gatekeeper doesn't push events.\n  // `callback` is a persistent stub the Gadget created with ctx.restore(). Construct a controller\n  // whose props capture the specifics of THIS registration (e.g. `filter`), then hand it, the\n  // callback, and a user-facing description to the overseer via bindHook(). For multiple hook\n  // kinds, pick the appropriate controller class here. Do NOT store the callback yourself — it is\n  // bound to this session and would be revoked when the session ends.\n  async subscribe(callback: RpcStub<MyHook>, filter?: string): Promise<void> {\n    let controller = this.#ctx.exports.MyHookControllerImpl({\n      props: { ...this.#ctx.props, filter },\n    });\n    await this.#approvalQueue.bindHook(controller, callback, {\n      title: \"TODO: short hook title\",\n      description: `TODO: what events this hook delivers${filter ? ` (filter: ${filter})` : \"\"}`,\n    });\n  }\n}\n```\n\n## `cloudflare.config.ts`\n\n```typescript\nimport {\n  DEFAULT_GATEKEEPER_WRANGLER, OBSERVABILITY, defineGadgetsWorker, type DurableObjectMigration,\n} from \"@gadgets/scripts/worker-config\";\n\nexport default defineGadgetsWorker({\n  name: \"gatekeeper-<name>\",\n  entrypoint: \".wrangler/validate/src/<name>.ts\",\n  compatibilityFlags: [\"allow_irrevocable_stub_storage\"],\n  observability: OBSERVABILITY,\n});\n\nexport const wrangler = DEFAULT_GATEKEEPER_WRANGLER;\n\nexport const migrations: DurableObjectMigration[] = [\n  { tag: \"v0\", new_sqlite_classes: [\"UserAccount\", \"MyGatekeeperImpl\"] },\n];\n```\n\nRun `pnpm configs:generate`; the generated `wrangler.jsonc` is committed and never edited by hand.\n\nOnly Durable Object classes go in `new_sqlite_classes`. `MyVerifier` and `MyHookControllerImpl` are `WorkerEntrypoint`s, so they need no `migrations` entry — but, like all entrypoints, they must be `export`ed from the worker's main module (so `ctx.exports.MyVerifier(...)` resolves). If your hook uses a dedicated event-source DO to hold the `initiator`, add that DO here too.\n\n## Creating the `types.txt` symlink\n\n```bash\ncd packages/gatekeeper-<name>/src\nln -s types.d.ts types.txt\n```\n\n## Resource configurator files\n\nAdd per-resource configurator types and UI modules:\n\n```\nsrc/configurator/my-configurator-types.d.ts\nsrc/configurator/my-configurator-types.txt -> my-configurator-types.d.ts\nsrc/configurator/my-configurator-ui.tsx\n```\n\n`my-configurator-types.d.ts` should describe the iframe helper API. The UI module's own `resourceUrl()` hook returns the resource URL to Workshop.\n\n```typescript\nexport interface MyConfiguratorUI {\n  listResources(query: string): Promise<{ value: string; title: string }[]>;\n}\n```\n\nAdd package scripts:\n\n```json\n{\n  \"scripts\": {\n    \"deploy\": \"vp run --no-cache build:configurator && wrangler deploy\"\n  },\n  \"dependencies\": {\n    \"@gadgets/configurator-ui\": \"workspace:*\"\n  },\n  \"devDependencies\": {\n    \"@gadgets/scripts\": \"workspace:*\"\n  }\n}\n```\n\n`@gadgets/scripts` has to be declared, not just imported. The shared task configs and the\n`gadgets-*` bins that run them belong to that package, and the workspace root depends on it too —\nso its bins are already on `PATH` and a `vite.config.ts` importing it resolves even with no\ndeclaration here. That is a phantom dependency: it works until this package is built somewhere that\ndoes not happen to hoist it.\n\nNo `build` script: `build` is one of the shared Vite+ tasks below, and vp forbids a task and a\nscript sharing a name. Build this one package with `pnpm exec vp run -F <package-name> build` —\n`pnpm --filter` cannot see a task, so `pnpm --filter <package-name> build` reports nothing to run;\nthe workspace-wide `pnpm build` picks it up as usual. `deploy` goes through the task rather than\ncalling\n`build-gatekeeper-configurator.ts` itself, so the codegen command lives in one place and cannot\ndrift from the task that declares its env — `wrangler deploy` stays outside vp, since it has side\neffects and needs real credentials.\n\nAlso add a `vite.config.ts` re-exporting the shared `build` and `build:configurator` Vite+ tasks —\nthe latter is what the `pnpm dev-server` pre-flight builds (`vp run -r --cache build:configurator\n--dev`). Both are tasks rather than package.json scripts so `VITE_FRONTEND_ERROR_REPORTING` is\npassed through and cache-fingerprinted; a `build` script running the builder with `&&` would bypass\nthat and silently bake the wrong value into the shipped HTML (do not add either script back):\n\n```typescript\n// Vite+ per-package settings. The build:configurator task definition is shared by all gatekeepers\n// with a configurator UI and ships as an export of `@gadgets/scripts`, alongside the builder it\n// runs.\nexport { default } from '@gadgets/scripts/gatekeeper-configurator'\n\n// ...or, if the gatekeeper has tests, `withTests` instead: the same settings plus the shared vitest\n// `test` task. Add a `\"test:run\": \"vitest run\"` script too, for iterating without the cache.\nexport { withTests as default } from '@gadgets/scripts/gatekeeper-configurator'\n```\n\nKeep tokens and broad API clients out of public `RpcTarget` properties; use closures, `#private`, or `WeakMap` state and expose only narrow read-only helper methods.\n\n<!-- File: .agents/skills/write-gatekeeper/SKILL.md (Tokens: ~9240 | Category: anthropic-skill) -->\n---\nname: write-gatekeeper\ndescription: Guides implementation of Gatekeeper Workers that bridge Gadgets to external services. Covers auth, capability-based API design, approval queue integration, caching, and action simulation. Load when creating, modifying, or reviewing the implementation of a gatekeeper.\n---\n\n# Writing a Gatekeeper\n\nA Gatekeeper is a Cloudflare Worker that mediates all access between a Gadget and an external service. It implements a three-tier hierarchy:\n\n- **Vendor** (`GatekeeperVendor`, a `WorkerEntrypoint`) — top-level entry for the service. One per service.\n- **User** (`GatekeeperUser`, a `WorkerEntrypoint` with `ctx.props`) — a human user's authenticated connection.\n- **Instance** (`Gatekeeper<Session>`, a DO facet of the Overseer) — per-resource, per-Gadget binding that provides the Session API.\n\nRead `packages/workshop-shared/src/gatekeeper.ts` for the canonical interfaces and detailed JSDoc.\n\n## Seven responsibilities\n\n1. **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.\n\n2. **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.\n\n3. **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.\n\n4. **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. 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)).\n\n5. **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.\n\n6. **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.\n\n7. **Observer verification** — When a Gadget is shared, collaborators may \"observe\" data the Gadget previously read through the gatekeeper. The gatekeeper must ensure each collaborator could access that data themselves, via `getVerifier()` / `addObserver()` / `removeObserver()`. The interface methods are mandatory — a gatekeeper won't type-check without them — so include at least minimal versions in Phase 1; but *choosing and implementing the right strategy* is a Phase 2 security concern, like logging/approvals. See [Observer verification](#observer-verification).\n\n## Phase 1: Core implementation\n\nIn the first phase, focus only on responsibilities 1 - 3, though keeping in mind that 4 - 7 will need to be implemented later. Note the observer methods of responsibility 7 (`getVerifier`/`addObserver`/`removeObserver`) are required for the code to type-check, so the skeleton includes minimal versions; you flesh out the actual strategy in Phase 2.\n\n### Step 1: Understand the external service\n\nStudy the service's API docs. Identify:\n- Auth model (OAuth 2.0, API keys, etc.)\n- Resources to expose and what access granularities make sense\n- Which operations are observations (read-only) vs. actions (side effects)\n\n### Step 2: Design the Session types\n\nCreate `src/types.d.ts` defining the Session interface (and Hook interface if the service pushes events — see [Hooks](#hooks-push-notifications)).\n\nBefore 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.\n\nDesign principles:\n- One interface per logical resource type, not a god-object\n- Methods return structured data, not raw API responses\n- 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\n- Simplify API complexities that are not likely to matter to agents and gadgets; design for a more novice user and common use cases\n- Consider what URL patterns `getGatekeeperClassFor()` should match — each pattern maps to a resource granularity\n- Include JSDoc comments; these types serve as the agent's API documentation. See [Documenting the API](#documenting-the-api-typesdts) below.\n\n#### Documenting the API (`types.d.ts`)\n\nThis 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.\n\nDo 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`.\n\nThe 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.\n\n### Step 3: STOP — Present API for review\n\n**Do not proceed without operator approval.**\n\nPresent 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.\n\nAlso 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.\n\n### Step 4: Implement\n\nSee [SKELETON.md](SKELETON.md) for a complete implementation template.\n\nPackage structure:\n```\npackages/gatekeeper-<name>/\n├── src/\n│   ├── configurator/         # Optional resource-picker UI modules and UI-facing types\n│   ├── <name>.ts              # Vendor, UserAccount, UserImpl, GatekeeperImpl, SessionImpl\n│   ├── types.d.ts             # Session/Hook types (compile-time)\n│   ├── types.txt -> types.d.ts  # Symlink (runtime, for getTypeScriptTypes())\n│   └── <name>-api.ts          # (optional) Helper wrapping the service's HTTP API\n├── cloudflare.config.ts      # wrangler.jsonc is generated from it\n├── package.json\n└── tsconfig.json\n```\n\n### Step 5: Register\n\nThere 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`).\n\n### Step 6: Add resource selection UI\n\nAdd 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.\n\n- 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.\n- Return `iframeHtml` of the selection UI and `ui` for any RPCs that UI needs.\n- When the user selects \"Add connection\", Workshop asks the iframe for the selected resource URL.\n- Group types (`SupportedResource.groupedWith`) when they are one choice at different granularities, such as a whole drive, a folder or a file. Each type is still granted separately, and a grouped card asks for the missing ones in one consent request, so group only types that need the same authorization. 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.\n\nKeep the iframe-facing capability narrow, only what's necessary to provide desired interface to help user find and select the resource.\n\n#### Optional helper: `@gadgets/configurator-ui`\n\nFor 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`.\n\nComponents: `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; breadcrumbs, optional search, paging and `loadItem`, which pre-filling anything but the root needs; nodes name icons the configurator supplies in `icons`; 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), `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). The runtime strips control and bidi-formatting characters from titles, subtitles and meta it shows, since those come from third parties.\n\nIf you use this:\n\n- UI modules live in `src/configurator/*-ui.tsx`.\n- `resourceUrl()` returns the selected resource URL.\n- 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.\n- `src/configurator/*-types.d.ts` describes the iframe-facing `ui` API.\n- 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.\n- 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`.\n- `scripts/build-gatekeeper-configurator.ts` generates `src/generated/*.txt`, using the runtime and build in `packages/configurator-ui` (`src/runtime/`, `build/`).\n- Nothing invokes `build-gatekeeper-configurator.ts` by hand. `vite.config.ts` re-exports the\n  shared `build` and `build:configurator` Vite+ tasks from\n  `scripts/gatekeeper-configurator-vite-config.ts`; `build` is just `tsc` and depends on\n  `build:configurator`, which carries `VITE_FRONTEND_ERROR_REPORTING` in its fingerprint, and\n  `deploy` runs `vp run --no-cache build:configurator && wrangler deploy` — deploys never replay a\n  cached artifact. There is no `build` script\n  and no direct builder call, because a script running the builder gets vp's stripped environment\n  and bakes the wrong flag into the shipped HTML.\n\n##### Pre-filling the form from a known resource URL\n\nWhen 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:\n\n- 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.\n- 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`):\n\n  ```ts\n  initialValuesFromResourceUrl({ resourceUrl }) {\n    const [owner, repo] = new URL(resourceUrl).pathname.split(\"/\").filter(Boolean);\n    return owner && repo ? { repoFullName: `${owner}/${repo}` } : {};\n  },\n  ```\n\n  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.\n\n### Step 7: STOP — Ask operator whether to proceed to phase 2\n\nThe operator may prefer to implement phase 2 later, perhaps in a new context. Stop here and ask the operator whether to proceed.\n\n## Phase 2: Logging, approvals, caching, simulation, and observers\n\nIn this phase, we focus on responsibilities 4-7. These are typically added as a second pass, after the core gatekeeper works. They may be implemented in a separate session.\n\n### Logging and approvals\n\nGo through all the API methods and decide where to insert calls to the `ApprovalQueue`.\n\n- Any operation which reads external data and returns it to the caller (but with no side effects) must call authorizeObservation().\n- Any operation which has visible side effects on the world must call submitAction(), and must not actually apply the action until approved.\n\nStudy the `ApprovalQueue` API in `gatekeeper.ts` for details.\n\n#### What counts as an observation\n\nThe 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.\n\nDo **not** call `authorizeObservation()` for:\n\n- **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\".\n- **Session setup.** `startSession()` resolving the account, a bound label, and so on returns only a session stub.\n- **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.\n- **Information that leaks only through a validation error** (\"label already exists\", \"branch is not a fast-forward\").\n\n**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.\n\nReturning 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.\n\n**Exception: observer tracking.** Under observer strategy C (below), `authorizeObservation()` does more than log. Its `excludeObservers` and set-tracking side effects gate which collaborators may see the workspace, and `containsRestrictedData` / `ownerInvitesOnly` change workspace state. A call that carries any of these is a security control, and removing it is a security decision, not a logging cleanup (see the Drive session's \"Check Google Drive folder\" fences).\n\nIt's critically important that you add `ApprovalQueue` to all API operations that interact with the outside world, otherwise the gatekeeper security model is broken.\n\n### Caching\n\nStore fetched data in the gatekeeper's DO storage (`this.ctx.storage`) to avoid redundant API calls. The cache also enables a better API shape when the service's native data model is awkward — e.g., Gmail's list API returns thread IDs without metadata, but with caching the Session can return richer summaries directly.\n\n- Use TTLs or revision IDs to keep the cache fresh.\n- Cache transformed data (e.g., Markdown) rather than raw API responses when the transformation is expensive.\n\nPROTIP: The relatively new API `this.ctx.storage.kv` provides synchronous versions of the traditional Durable Object storage API, e.g. `get` and `put`. Use these instead of the old asynchronous methods. (Note that the synchronous API does not provide \"batch\" versions of `get()` and `put()`, but you don't really need them since simply making multiple calls is efficient.)\n\nPROTIP: `this.ctx.storage.sql` gives you access to a full, private SQLite database. Use this when the full power of SQL is useful, but prefer KV for simple things.\n\nThe full Durable Objects storage API (including synchronous KV and SQLite) is documented at: https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/\n\n### Simulation\n\nWhen `submitAction()` has been called but `applyAction()` hasn't, reads should reflect the pending action. This allows the calling agent or gadget to be unaware of the approvals mechanism, and proceed with follow-on work immediately. The end user is able to approve a whole batch of changes at once, later on.\n\nTwo possible implementation approaches include:\n\n1. **Mutate the cache** — Apply the action's effects to cached data on submit. On `rejectAction()`, invalidate or rebuild the cache. Simple; works well when the cache is already a transformed view. Don't forget to re-apply any queued actions when updating the cache.\n\n2. **Overlay at read time** — Store pending actions separately; merge them into read results on demand. Cleaner separation; better when the overlay logic is straightforward.\n\nChoose based on the service's data model and the complexity of simulating each action type.\n\nKeep in mind that the agent calling the API (or the agent writing a gadget to call it) is generally not aware that actions do not take place immediately. If the simulation is correct, the agent doesn't need to be aware. If the simulation has gaps, you may want to mention it in your API's doc comments, so that the calling agent knows to work around them — but ideally there are no gaps and the calling agent does not need to think about it.\n\nFor concrete examples, see the Google gatekeeper's Google Docs simulation/cache handling and BigQuery dry-run scope enforcement.\n\n### Observer verification\n\nThis is responsibility 7. When a Gadget is shared, each non-owner collaborator becomes an **observer** of every gatekeeper bound to the Gadget, and may see data the Gadget previously read. The gatekeeper's job is to refuse — or forward-restrict — observers who couldn't access that data themselves.\n\nThree methods implement this (full JSDoc in `gatekeeper.ts`):\n\n- `GatekeeperUser.getVerifier()` — mints a `GatekeeperUserVerifier` (a persistent service stub) representing *this* user's account. The overseer mints one per open and **only ever passes it back to a gatekeeper of the same vendor**, so the gatekeeper may trust whatever it learns from it.\n- `Gatekeeper.addObserver(id, verifier)` — must **throw** if the user represented by `verifier` is not allowed to observe everything read through this gatekeeper so far. The overseer calls it on **every open by every authorized observer** (re-verification, so revoked access is caught at the next open); cache as needed if the check is expensive. `id` is an opaque, stable per-(user,gadget) string.\n- `Gatekeeper.removeObserver(id)` — idempotent; drop a tracked observer.\n\n#### The verifier \"non-standard method\" pattern\n\n`GatekeeperUserVerifier` has no methods of its own — it's an opaque token. To actually answer \"can this observer access X?\", define a vendor-specific interface that **extends `GatekeeperUserVerifier`** with your own methods, implement it on a `WorkerEntrypoint` that queries the service using the **observer's own token**, and cast the `Fetcher` back to that interface inside `addObserver`. The overseer's same-vendor guarantee is what makes the cast safe.\n\n```typescript\n// In types/impl: a verifier interface with non-standard methods.\nexport interface MyVerifierApi extends GatekeeperUserVerifier {\n  hasResourceAccess(resourceId: string): Promise<boolean>;\n}\n\ntype MyVerifierProps = { userObjectId: string };\n\nexport class MyVerifier extends WorkerEntrypoint<Env, MyVerifierProps>\n    implements MyVerifierApi {\n  async hasResourceAccess(resourceId: string): Promise<boolean> {\n    // Query the service with the OBSERVER's own token (this.ctx.props.userObjectId).\n    try {\n      await myApiForObserver(this.ctx).getResource(resourceId);\n      return true;\n    } catch (error) {\n      // Distinguish \"no access\" from \"transient failure\":\n      //   - auth/permission/not-found (401/403/404) → false (they can't see it)\n      //   - anything else → rethrow, so the open fails loudly rather than silently denying\n      if (isNoAccessStatus(statusOf(error))) return false;\n      throw error;\n    }\n  }\n}\n\n// In GatekeeperUser:\nasync getVerifier(): Promise<Fetcher<GatekeeperUserVerifier>> {\n  return this.ctx.exports.MyVerifier({ props: { userObjectId: this.ctx.props.userObjectId } });\n}\n```\n\n`MyVerifier` is a `WorkerEntrypoint`, so it needs **no migration entry**, but (like all entrypoints) it must be `export`ed from the worker's main module so `ctx.exports.MyVerifier(...)` resolves.\n\n#### Choosing a strategy (per resource type / binding)\n\nStrategy is chosen **per `Gatekeeper` DO class / binding**, not per package — one package may use several (e.g. Google: Gmail=A, Doc=B, BigQuery=C).\n\n- **A — Private-only.** `addObserver()` always throws; `removeObserver()` is a no-op. `getVerifier()` must still exist (the overseer mints it) but is never consulted. Use when the resource is too sensitive to share and there is no per-observer access oracle (e.g. a personal Gmail mailbox). For truly sensitive data, also mark each observation with `ObservationDescription.containsRestrictedData: true`: because nobody can open the workspace without passing `addObserver()`, and with strategy A nobody ever does, the first such observation makes the workspace effectively unshareable — and it permanently sets the workspace's `containsRestrictedData` flag, whose restricted mode blocks all actions and public web fetches, so the data cannot leak back out through other gatekeepers.\n- **B — ACL check (single unit).** The binding is one atomic resource; sub-resources inherit its ACL. `addObserver()` calls a verifier method to confirm the observer can access it and throws otherwise; `removeObserver()` is a no-op; nothing is tracked and no `excludeObservers` is ever needed. Use for repo / document / page / team / single-project bindings.\n- **C — Data-set tracking.** The binding spans sub-resources with **distinct ACLs**, and there is a **per-observer access oracle** for each. The DO logs the data sets actually observed and the current observers; `addObserver()` verifies the observer against **every** logged set (plus a coarse membership baseline) and **stores their verifier**; each later observation that first touches a **new** set re-checks all stored observers and sets `excludeObservers` for any who fail. Use for workspace / organization / dataset-spanning bindings.\n- **D — Low-stakes.** `addObserver()` / `removeObserver()` are no-ops; `getVerifier()` returns a trivial verifier with a no-op public method such as `verify(): void {}` (an empty `WorkerEntrypoint` is not registered in `ctx.exports`). Use when any collaborator may observe (personal, low-stakes services).\n\nThe **B-vs-C decision** (the \"broad binding\" lens): use C only when **both** (1) the binding spans sub-resources with distinct ACLs *and* (2) there's a per-observer oracle to check each against. If one ACL covers everything → B. If there's no oracle → A or D.\n\n#### Implementing strategy C\n\nRoute **every data-revealing observation** through a helper that takes the set id(s) the observation reveals, instead of calling `authorizeObservation()` directly:\n\n```typescript\n// On the Gatekeeper DO. `setIds` are the data sets this observation reveals.\nasync authorizeSetObservation(\n    queue: RpcStub<ApprovalQueue>, setIds: string[], description: ObservationDescription) {\n  const check = setIds.length > 0\n      ? await this.#prepareSetObservation(setIds)\n      : { pendingSets: [], excludeObservers: undefined };\n  await queue.authorizeObservation({ ...description, excludeObservers: check.excludeObservers });\n  for (const setId of check.pendingSets) this.#markSetObserved(setId);\n}\n\nasync #prepareSetObservation(setIds: string[]) {\n  const pendingSets = [...new Set(setIds)].filter(id => !this.#isSetObserved(id));\n  if (pendingSets.length === 0) return { pendingSets, excludeObservers: undefined };\n  // This synchronous state change is visible to addObserver() before verifier RPCs can interleave.\n  for (const setId of pendingSets) this.#markSetPendingIfUnknown(setId);\n  const excluded = new Set<string>();\n  for (const [id, verifier] of this.#listObservers()) {\n    for (const setId of pendingSets) {\n      if (!(await verifier.hasSetAccess(setId))) { excluded.add(id); break; }\n    }\n  }\n  return {\n    pendingSets,\n    excludeObservers: excluded.size > 0 ? [...excluded] : undefined,\n  };\n}\n```\n\nKey points for C:\n\n- **Use two durable states: pending and observed.** Mark unknown sets pending before the first await, recheck pending sets on every retry, and promote them only after `authorizeObservation()` succeeds. A failed authorization leaves them pending. `addObserver()` must check both states and loop until no unchecked sets remain before synchronously storing the verifier; this also closes admission races in either request ordering.\n- **The session impls must route through this helper**, not `approvalQueue.authorizeObservation()`. If sessions hold the raw queue (not the DO), thread a small prepare hook/callback into each session and any sub-sessions it spawns, and expose a completion step that promotes its pending sets after authorization. For a single broad binding the hook is active; for the narrow (B) sibling binding it is absent (passthrough). See Linear/Notion for the shared-session-impl case and Supabase for the context-object case.\n- **One observation may reveal several sets** (e.g. a workspace-wide list whose rows belong to different sub-resources). Pass all of them; union the exclusions over the newly-seen ones. Reads that reveal *no* set (workspace name, member directory, a bare \"open\") pass an empty list and rely on the membership baseline.\n- **`addObserver` baseline:** verify the coarse membership (e.g. same org/workspace) that gates the set-independent reads, then verify each already-observed set, then store the verifier. Fail closed if a needed identity is unknown (e.g. an account connected before you began persisting the workspace id → force a reconnect).\n\n#### `excludeObservers` semantics (why conservative is safe)\n\nWhen `authorizeObservation()` is given `excludeObservers`, the overseer **blocks the observation** if any named observer is still authorized, and only lets it proceed (tearing down the observer) if they've already lost access. So erring toward listing an observer is never a leak — at worst it blocks an observation that could in principle have been allowed. The leak-relevant gate is always the live sharing graph, so stale observer state self-heals on the next open.\n\n## Hooks (push notifications)\n\nSome services can push events to the Gadget (inbound email, webhooks, chat messages, etc.). A gatekeeper exposes this as a **hook**: the Gadget registers a callback, and the gatekeeper later invokes it when an event arrives. Hooks are persistent — they survive across sessions and server restarts — and are subject to the same observation/action approval model as everything else.\n\n`gatekeeper-email` is the canonical reference implementation. Read it alongside the `HookController`, `HookInitiator`, and `ApprovalQueue.bindHook()` JSDoc in `gatekeeper.ts`.\n\n### The pieces\n\n- **Hook interface** (in `types.d.ts`): the methods the Gadget implements to receive events, e.g. `EmailHook.receiveEmail(email)`. It is implemented by the Gadget as an **`RpcTarget`** (or a plain function), *not* a `WorkerEntrypoint`. Reference it from `describe()` via `hookTsType`.\n- **Session method**: a method like `subscribe(callback)` that the Gadget (or, more commonly, an agent in a one-off `executeCode` call) uses to register interest. The `callback` is a **persistent stub** (created by the Gadget with `ctx.restore()`), so it can be stored and re-invoked long after the session ends.\n- **`HookController`** (a `WorkerEntrypoint` you implement): lets the overseer `enable()` / `disable()` the hook. All the state it needs must live in its `props`, so it is constructed via `this.ctx.exports.MyHookControllerImpl({props})` **at bind time**, immediately before calling `bindHook()` — see below.\n- **`HookInitiator`** (provided to you by the overseer): you call `startHook()` on it when an event arrives.\n\n### Lifecycle\n\n1. **Register.** The Gadget calls your Session method (e.g. `subscribe(callback, filter)`). Inside it, construct a `HookController` whose `props` capture the specifics of *this* registration, then call `approvalQueue.bindHook(controller, callback, description)`. The overseer stores the callback and records the hook (initially **disabled**). Do **not** store the callback yourself — it is bound to the current session and would be revoked when the session ends.\n2. **Enable.** When the user approves the hook in the Workshop UI, the overseer calls `controller.enable(initiator, target)`. Store the `initiator` Fetcher somewhere it can be reached when events arrive (e.g. an event-source DO). `target` identifies where the hook delivers (workspace, plus gadget when the hook is pinned to one); persist it alongside the initiator if you display or link to the target — the IDs are fixed when the hook is bound, so there is nothing to refresh. A gatekeeper that doesn't need it still has to declare the parameter, since RPC argument validation rejects arguments the receiver doesn't declare. Avoid storing any other state until enabled; everything else should already be in the controller's `props`.\n3. **Deliver.** When the event occurs, call `initiator.startHook()`. This returns `{callback, approvalQueue}` bound to a fresh session. Call `authorizeObservation()` (a hook event is almost always an observation; register actions too if the callback's return value triggers side effects), then invoke the `callback` to deliver the event to the Gadget.\n4. **Disable / delete.** The overseer calls `controller.disable()`. Forget the stored `initiator` and clean up all related state — `disable()` may never be called again, though the overseer may later call `enable()` afresh.\n\nBecause the callback is a persistent stub tied to a session, the gatekeeper never stores it directly; the overseer hands it back (re-bound to a new session) each time you call `startHook()`. See the SKELETON for the full code shape.\n\n### Documentation\n\nWhen defining a session interface with hooks, it's important to include comments that clearly state when a method expects to be passed a *persistent* stub created with `ctx.restore()`, as opposed to a regular RpcStub. The caller needs to do extra work to make sure the stub they provide you is persistent.\n\n## Reviewing a gatekeeper\n\nReviews tend to stop at general correctness. The most important review is of the agent-facing API, so pressure-test it:\n\n- **Shape.** Read `types.d.ts` as the agent does, as its only documentation. Does the shape make sense? What would you do differently?\n- **User stories.** List the most common things a user would ask an agent to do with this service and walk each through the API. Flag any that are impossible, need sequential round trips that promise pipelining can't remove, or need knowledge the JSDoc doesn't give. Flag dead ends too: anything the grant includes that the agent can't navigate to, such as child pages it can list but not open.\n- **Seams.** Check that resource types, bindings and sessions partition authority along the lines users actually grant:\n  - Each `getSupportedResources()` granularity is a grant a user would make, and together they cover the common ones (broad and narrow).\n  - A narrow binding's session reaches nothing outside its resource: sub-resources come from stubs on their parent, and no method or option field (`parentId`, `projectId`) takes an ID that escapes the binding.\n  - Each method sits on the object whose authority it needs. A cross-resource operation (move, copy, link) must not quietly require, or grant, a broader binding than the user picked.\n  - Each binding's types expose only what that binding can do and stay small, and its observer strategy fits how the binding is partitioned (see [Choosing a strategy](#choosing-a-strategy-per-resource-type--binding)).\n\n## Tips\n\n- `types.txt` must be a **symlink** to `types.d.ts`, never a copy. Because it is delivered verbatim, `types.d.ts` must not import from other modules — inline any shared types (see [Documenting the API](#documenting-the-api-typesdts)).\n- Call `.dup()` on `approvalQueue` stubs before storing in a session, since Cap'n Web automatically disposes all stubs in parameters to an RPC call when the call returns.\n- `suggestedBindingName` in `describe()` reflects the resource **type** (e.g. `\"GMAIL_INBOX\"`), not the specific instance.\n- For read-only or push-only gatekeepers, `applyAction()` / `rejectAction()` / `revertAction()` can simply throw (they'll never be called since the gatekeeper never submits actions).\n- For `WorkerEntrypoint` and `DurableObject` subclasses, pass credentials and resource IDs via `ctx.props`, not constructor arguments. RPC stubs pointing to these types can be stored in long-term storage and restored later, creating a new instance based on the same `props`.\n- If the gatekeeper implements multiple unrelated resource types with disjoint APIs, each may have its own `.d.ts` file, so that the `getTypeScriptTypes()` method of the specific `Gatekeeper` implementation only returns the types that matter for it. The `getTypeScriptTypes()` method on the top-level `GatekeeperVendor` should return the concatenation of all of these.\n- All DO classes must appear in `cloudflare.config.ts`'s `migrations` export under `new_sqlite_classes`.\n- Set a self-destruct alarm in `UserAccount.setCallback()` in case the OAuth flow is never completed.\n- `authorizeObservation()` may be called *after* fetching data (so the description can include details about what was fetched) but must be awaited *before* returning anything to the caller.\n- `getVerifier()` / `addObserver()` / `removeObserver()` are **mandatory** — the gatekeeper won't type-check without them. Even a read-only or push-only gatekeeper needs them (sharing is independent of whether the gatekeeper has actions). Pick a strategy per [Observers](#observer-verification): a low-stakes one can be A or D; otherwise B/C.\n\n## Reference implementations\n\n- `packages/gatekeeper-google/` — OAuth, multiple resource types (Gmail, Google Docs, BigQuery), actions, caching/simulation examples, multiple Session types. **Observers:** all three strategies in one package — Gmail=A (always throw), Doc=B (single-unit ACL via `GoogleVerifier.hasDocAccess`), BigQuery=C (dataset tracking via `hasDatasetAccess`).\n- `packages/gatekeeper-email/` — Hook-based push notifications, no actions, email address claiming. **Observers:** strategy D (low-stakes no-ops + trivial verifier).\n- `packages/gatekeeper-github/` — **Observers:** clean strategy B example — `GitHubVerifier.hasRepoAccess` plus a one-method `addObserver`.\n- `packages/gatekeeper-gitlab/` — A **mirror** of gatekeeper-github for a provider with instance configuration (self-hosted URLs, an optional Cloudflare Access service token on upstream requests) and **rotating credentials** (expiring access tokens, single-use refresh tokens refreshed under a mutex). Reads the same `plans/worktrees.md` §3 git contract through `gatekeeper-kit`'s `git-transport`/`git-objects`/`git-diff`. **Observers:** strategy B with a role-aware probe (`membershipGrantsFullRead`): Reporter membership whatever the visibility, since a public project can still hold confidential issues and internal notes, read from `members/all?user_ids[]=` because the project's `permissions` object omits group-inherited access. Its `plans/gitlab-gatekeeper.md` divergence table is a worked example of porting one gatekeeper's API to another provider's vocabulary.\n- `packages/gatekeeper-supabase/` — **Observers:** strategy C with a per-session context object (`authorizeProjectObservation`) — good when sessions already hold a shared context.\n- `packages/gatekeeper-linear/` & `packages/gatekeeper-notion/` — **Observers:** strategy C where the page/team session impls are shared between the narrow (B) and broad (C) bindings, threading an `observe` hook through sub-sessions; both also handle one observation revealing multiple sets.\n- `packages/workshop-shared/src/gatekeeper.ts` — Canonical interfaces with detailed JSDoc (`getVerifier`, `addObserver`, `removeObserver`, `GatekeeperUserVerifier`, `ObservationDescription.excludeObservers`).\n\n<!-- File: packages/gatekeeper-kit/AGENTS.md (Tokens: ~466 | Category: anthropic-skill) -->\n# Gatekeeper Kit contributor notes\n\n`@gadgets/gatekeeper-kit` is a library, not a deployable Worker. Do not add a `cloudflare.config.ts`:\nthe `wrangler.jsonc` generated from it is what makes release tooling treat this package as a\ngatekeeper deployment.\n\n## Package boundaries\n\n- Only Layer 1 leaf modules are shipped. Layer 2 remains a proposal in\n  [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md).\n- Keep leaf modules independently usable. Do not make one depend on a future assembly layer.\n- Add a leaf, or a leaf option, only for behavior several gatekeepers share under one contract.\n  Behavior only one gatekeeper has belongs in that gatekeeper. Support it with a plain TypeScript\n  hook or callback on the leaf, or by implementing the canonical interface by hand. Do not add a\n  variant for a single provider.\n- Accept the narrowest structural KV surface a module needs. Pass stable `ctx.storage.kv` objects to\n  modules that coordinate work by storage identity.\n- Treat shipped storage keys and prefixes as compatibility. Use existing key and prefix options when\n  porting a gatekeeper with a different layout.\n- Public modules are explicit subpath exports in [`package.json`](package.json). Document new public\n  symbols with JSDoc and add the subpath to the README inventory.\n\n## Documentation ownership\n\n- [`README.md`](README.md) maps the package and its modules.\n- [`USAGE.md`](USAGE.md) owns cross-module integration guidance and operational sharp edges.\n- Exported-symbol JSDoc owns the exact API contract visible in editors.\n- [`../../plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md) is the design record, including the\n  unshipped Layer 2 proposal. Do not duplicate that proposal into current usage documentation.\n\n## Verification\n\nUse Node tests for pure logic. Use the workerd project for persisted RPC stubs, `RpcTarget`,\nDurable Object behavior, and `crypto.subtle.timingSafeEqual`.\n\nFrom the repository root:\n\n```sh\npnpm --filter @gadgets/gatekeeper-kit test:run\nvp run -F @gadgets/gatekeeper-kit build\n```\n\n<!-- File: packages/ui/AGENTS.md (Tokens: ~274 | Category: anthropic-skill) -->\n# Shared UI\n\n`@gadgets/ui` is the shared runtime React UI layer for the Workshop and gatekeeper management apps.\nKumo owns low-level controls and semantic tokens; this package owns reusable Gadgets interaction\npatterns and composed components.\n\n## Ownership\n\n- Add a component here when at least two independent frontends need the same feature-independent\n  behavior or when consistency across those frontends is an explicit product requirement.\n- Keep product data fetching, routing, RPC, permissions, and domain workflows in the consuming app.\n- Check Kumo before adding a primitive. Do not wrap Kumo solely to restyle or rename it.\n- Export source directly. This package has no publish or emitted-build step and is marked private.\n- Keep React and Kumo as peer dependencies so consumers use one runtime and design-system version.\n- A Tailwind consumer must include `packages/ui/src` as an `@source`, because the shared components'\n  utility classes are compiled by the consuming app.\n\n## APIs And Tests\n\n- Prefer a headless behavior primitive plus a Kumo adapter when both are real consumers' needs.\n- Keep the headless model free of presentation policy and styled-only fields where practical.\n- Preserve accessibility and input parity across mouse, keyboard, touch, and hybrid devices.\n- Colocate tests and test observable contracts rather than package boundaries or framework behavior.\n\n<!-- File: packages/workshop-frontend/AGENTS.md (Tokens: ~2629 | Category: anthropic-skill) -->\n# Workshop Frontend\n\nThis package is the Workshop single-page application. It uses React, TanStack Router, Tailwind,\nand Kumo. Follow the repository-wide guidance in `../../AGENTS.md` in addition to this file.\n\n## Architecture\n\nOrganize new code by product ownership first and implementation type second.\n\n```text\nsrc/\n  routes/       TanStack route declarations and route-level wiring\n  pages/        Substantial route-level screens that compose one or more features\n  features/     Product features and their owned UI, hooks, and logic\n  components/   UI and application primitives shared across unrelated features\n  hooks/        Hooks shared across unrelated features\n  utils/        Feature-independent utilities\n```\n\nThe existing tree predates this convention. Apply it to new code and to code being substantially\nreworked; do not migrate unrelated files opportunistically.\n\n### Features\n\nFeature directories own product behavior. A feature may contain components, hooks, tests, and\nutilities that belong to that behavior.\n\nStart a feature directory flat:\n\n```text\nfeatures/chat/composer/\n  ChatComposer.tsx\n  ComposerAddMenu.tsx\n  ComposerAddMenu.test.tsx\n  useComposerDraft.ts\n  composerTokens.ts\n```\n\nDo not create `components/`, `hooks/`, `helpers/`, or `tests/` subdirectories merely to classify\nfiles by implementation type. File names already communicate those types, and tests should usually\nremain beside their subject.\n\nIntroduce a subdirectory when a coherent internal subsystem has several files or the flat directory\nbecomes difficult to scan. Name it after its responsibility, such as `draft/`, `tokens/`,\n`attachments/`, or `slash-commands/`; avoid generic `helpers/` buckets.\n\nOrganize by the reason files change together. Feature-based organization does not replace the\none-component-per-file model.\n\n### Components\n\nCreate a separate component file when the component has at least one of these properties:\n\n- It owns meaningful state, effects, or interaction behavior.\n- It is independently testable or reusable.\n- It represents a distinct UI responsibility.\n- Keeping it inline makes the parent difficult to understand.\n- It has grown supporting types or logic that obscure the parent's main flow.\n\nKeep small stateless rendering helpers private in their parent's file when they are only used there\nand remain easy to understand. Extract them beside the parent when they develop their own behavior\nor tests.\n\nUse PascalCase filenames for components and camelCase filenames for hooks and non-component\nmodules. Prefer direct imports; do not add a barrel file solely to shorten paths.\n\n### Pages And Routes\n\nFiles under `src/routes/` define routes. Keep them focused on route concerns: parameters, search\nvalidation, loaders, navigation, and composing the route's page.\n\nA page is a composition boundary, not necessarily a feature. A page may combine several features,\nand a feature may appear on several pages.\n\nA small page may remain in its route file. Move a substantial page implementation to\n`src/pages/<page>/`, where it can compose feature components. Components and hooks meaningful only\nto that page stay in the page directory rather than global `components/` or `hooks/`. Apply the same\nflat-first and responsibility-based subdivision rules used for features. Keeping page support code\noutside `src/routes/` also keeps it out of TanStack Router's file scanning.\n\nName every route-level screen component under `src/pages/` with a `Page.tsx` suffix, such as\n`HomePage.tsx` or `WorkspacePage.tsx`. Supporting components keep names that describe their own\nresponsibility, such as `HomeTaskSuggestions.tsx`.\n\nDo not move product behavior into a page merely because the page currently consumes it. Behavior\nwith independent product meaning or use across pages belongs to `src/features/<feature>/`.\n\nDefault uncertain code to the page and promote it when ownership broadens. The current Home page is\nan example of code that predates this rule: `components/AppShell/HomeTaskSuggestions.tsx` and\n`components/MeshBackground.tsx` have `routes/index.tsx` as their only production consumer. They are\nHome-specific composition and presentation rather than shared AppShell primitives, so their target\norganization would be:\n\n```text\npages/home/\n  HomePage.tsx\n  HomeTaskSuggestions.tsx\n  MeshBackground.tsx\n```\n\nIf task suggestions later become an independently meaningful workflow used by other pages, promote\nthat code to a feature such as `features/task-suggestions/`. Do not begin there based only on\nspeculative reuse.\n\nDo not edit `src/routeTree.gen.ts`; it is generated.\n\n### Shared Code\n\n`src/components/` and `src/hooks/` are shared application infrastructure, not default destinations.\nCode should begin under the feature that owns its behavior.\n\nReuse alone does not make code generic:\n\n- If multiple parts of one feature use an item, move it to their nearest common feature directory.\n- If another feature consumes a concept still owned by the original feature, keep it exported from\n  that feature.\n- Promote an item to global `components/` or `hooks/` only when unrelated features use the same\n  feature-independent abstraction and its API no longer depends on its original feature.\n- Do not merge components merely because they look similar. Avoid generic prop-heavy abstractions\n  that erase important domain behavior.\n\nMove code only as high in the hierarchy as its ownership requires. For example, a skill pill shared\nby the composer and chat history belongs to `features/chat/`, while a generic application icon\nbutton belongs to `components/`.\n\n### Component APIs And Composition\n\nRepresent props that are valid only together as one object or a discriminated union. Do not permit\npartial configurations:\n\n```tsx\n// Avoid: callers can provide a count with no way to dismiss it.\ncount?: number;\nonDismiss?: () => void;\n\n// Prefer: the capability is either complete or absent.\nnotice?: { count: number; onDismiss: () => void };\n```\n\nA controlled value requires a change callback. Otherwise, the component owns the value. Do not copy\na controlled prop into local state with an Effect:\n\n```tsx\n// Controlled\nvalue: string;\nonValueChange: (value: string) => void;\n\n// Uncontrolled\ninitialValue?: string;\n```\n\nName callbacks `on<Action>` and pass domain values rather than React setters or browser events. For\nexample, expose `onModelChange(modelId)` rather than `setSelectedModel` or `onChange(event)`.\n\nAdd `children`, named slots, variants, `className`, DOM prop passthrough, or imperative refs only\nwhen a current caller needs that control. Do not add them in anticipation of reuse.\n\nUse context for application-wide values such as authentication, theme, and toasts. Pass\ninstance-specific feature data and actions through props; do not introduce context merely to avoid\npassing them through one or two component levels.\n\nExtract a component or hook when the extracted unit owns a complete concern, such as state and its\ntransitions, an Effect lifecycle and cleanup, an accessible interaction, or a pure transformation\nthat can be tested independently. Keep code together when the child would mostly forward markup or\nwould need the parent's refs, setters, and synchronization callbacks to function.\n\n## Kumo And Styling\n\nUse Kumo components and Kumo semantic design tokens by default. Check Kumo before creating a custom\ncontrol, interaction pattern, or visual primitive. Existing Workshop wrappers may be used when they\nprovide established application behavior that Kumo does not provide directly.\n\nCustom colors and design tokens outside Kumo are prohibited unless the user explicitly requests\nthem. In particular, do not add:\n\n- Hex, RGB, HSL, or OKLCH color literals in component styles.\n- Arbitrary Tailwind color values.\n- New application-specific color variables or token families.\n- Feature-local replacements for Kumo surfaces, borders, text, status, focus, or interaction tokens.\n\nUse Kumo semantic classes such as `bg-kumo-base`, `text-kumo-subtle`, and `border-kumo-line` rather\nthan palette colors. Global Kumo token theming is a deliberate application-level decision and must\nnot be introduced or changed as part of ordinary feature work.\n\nLegacy custom tokens and color declarations in the current codebase are not precedent for new code.\nDo not expand their use. Migrate them only as part of explicitly scoped cleanup.\n\nTailwind remains appropriate for structure: layout, spacing, sizing, positioning, responsive\nbehavior, and typography. Use custom CSS only for technical behavior that Kumo and utilities cannot\nexpress, such as editor integration or measured overlays; custom CSS does not relax the color and\ntoken rules.\n\nWhen a Kumo component is unsuitable, record the concrete behavioral or accessibility gap before\nadding a shared Workshop abstraction. Do not wrap Kumo solely to restyle it.\n\n## React\n\n- Define components and hooks as named `const` arrow functions by default. This keeps declarations\n  before use and avoids relying on function-declaration hoisting. Type props directly rather than\n  using `React.FC`. Ensure components wrapped in `memo`, `forwardRef`, or similar APIs retain an\n  explicit name for React DevTools and stack traces, using `displayName` when inference is unclear.\n- Treat Effects as an escape hatch for synchronizing React with an external system, not as a\n  general state-management tool. Follow React's\n  [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect) guidance.\n- Do not use an Effect to derive render data from props or state. Calculate it during render instead.\n- Do not use an Effect for logic caused by a user interaction. Run it in the event handler that\n  knows what happened.\n- Do not use an Effect to keep two pieces of React state synchronized. Prefer a single source of\n  truth, derived values, controlled components, or lifting state.\n- Prefer a component `key` when an identity change should reset the component's state.\n- Prefer `useSyncExternalStore` for external store subscriptions when it fits.\n- Effects that fetch or subscribe must clean up stale work and subscriptions. Components must remain\n  correct when Effects are restarted or mounted again in development.\n- Avoid chains of Effects that update state only to trigger another Effect.\n- Keep state as close as possible to the behavior that owns it.\n- Extract a hook when it represents a coherent behavior or is reused, not simply to shorten a file.\n- Do not add `useMemo` or `useCallback` by default; use them when identity or expensive computation\n  has a concrete effect, and follow existing compiler guidance.\n- Preserve keyboard behavior, focus management, and accessible names when extracting interactive UI.\n- RPC stubs must follow the disposal and React state rules in the repository-wide `AGENTS.md`.\n\n## Comments\n\nPrefer code that communicates its intent through names, types, and structure. Do not add comments\nthat narrate what the next line or block already says.\n\nComments are appropriate for gotchas, non-obvious invariants, external constraints, security or\nperformance reasoning, and deliberate departures from a convention. Explain why the surprising\nchoice is necessary, not the mechanics visible in the code. Remove or update comments when the\nconstraint they describe no longer exists.\n\n## Tests\n\nColocate unit tests with their subject using `*.test.ts` or `*.test.tsx`. Use a feature-level\n`__tests__/` directory only for scenarios that span several modules and have no single owner.\n\nTests should either protect behavior whose regression would be important, or verify real logic\nintroduced by the change. Do not test React, JavaScript, TypeScript, Kumo, or another framework's\nown behavior merely because it appears in the implementation.\n\nTest observable contracts, product rules, state transitions, accessibility behavior, race handling,\nand failure paths. Avoid assertions coupled only to implementation details, trivial passthrough\nmarkup, or behavior already guaranteed by types and the underlying platform. A test should have a\nclear failure whose meaning matters to this application.\n\nBehavior-preserving moves should keep tests unchanged apart from imports. Add focused coverage when\nan extraction exposes important previously untested logic; do not add tests solely because a file\nor component boundary now exists.\n\nUseful commands from the public workspace root:\n\n```bash\npnpm --filter @gadgets/workshop-frontend test:run\npnpm exec tsc -p packages/workshop-frontend/tsconfig.json\npnpm exec tsc -p packages/workshop-frontend/tsconfig.vite.json\npnpm exec vp run -F @gadgets/workshop-frontend build\n```\n\nRun `pnpm lint` from the public workspace root before pushing when the broader workspace state\nallows it.\n\n<!-- File: packages/bundled-blueprints/blueprints/workspace-docs/files/README.md (Tokens: ~1707 | Category: plugin-manifest) -->\n# Docs — real-time collaborative rich-text Gadget\n\nA single-document, Google-Docs-style rich-text editor with Durable Object persistence, live block updates, collaborator presence, and conflict-safe concurrent editing.\n\n## Shared libraries\n\nThis blueprint is built on the shared gadget libraries in `packages/bundled-blueprints/libraries`\n(see that directory's README). The build inlines what it imports into the `client.js` and `server.js` it ships,\nso a gadget created from it carries its own copy of the libraries as of its creation.\n\n- `@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the `el` element builder, the shared icon table, the\n  toolbar's buttons, groups, colour pickers and dropdown, the in-page prompt that stands in for the\n  sandbox's blocked `window.prompt`, the save-status dot, and the reading and downscaling of a\n  picked, dropped or pasted image.\n- `@gadgets/bundled-blueprints/libraries/sync/client` runs the browser's half of the collaboration loop: the debounced,\n  serialized, retrying save scheduler, the presence roster and its throttled reporter, and the\n  `RpcTarget` the server calls back.\n- `@gadgets/bundled-blueprints/libraries/sync/server` runs the object's half: the mutation queue, the subscriber registry with its\n  presence announcements, and the per-block optimistic concurrency.\n\nEverything on top of them is this gadget's own: the editing commands, the paste sanitizer, the block\nmodel and its ordering rule, where a remote caret is drawn, and the export formats.\n\nIn the repository the source is TypeScript under `blueprints/workspace-docs/files/` (`client.ts`,\n`server.ts`, the shared `lib/protocol.ts` contract and the `lib/markdown.ts` export), which the build\nbundles into the `client.js` and `server.js` this gadget runs.\n\n## Architecture\n\n- **server.js** is the authoritative collaboration coordinator. It stores one atomic `document:v2` snapshot containing the title, global revision, ordered blocks, per-block versions, and modification time.\n- **client.js** builds the entire UI around a `contenteditable` surface. Every top-level document element has a stable `data-block-id`. Local typing is optimistic and changed blocks are sent after a short ~220 ms debounce.\n- Clients subscribe with a Cap'n Web `RpcTarget`. The server broadcasts accepted block operations and ephemeral presence events to every connected client.\n\n## Real-time data model\n\nThe persisted document resembles:\n\n```js\n{\n  revision: 42,\n  title: \"Project plan\",\n  blocks: [\n    { id: \"b_…\", html: \"<h1 data-block-id=\\\"b_…\\\">Project plan</h1>\", version: 3 },\n    { id: \"b_…\", html: \"<p data-block-id=\\\"b_…\\\">Draft…</p>\", version: 8 }\n  ],\n  lastModified: 1700000000000\n}\n```\n\nClients send compact operation batches containing only changed/upserted blocks, version-checked deletions, the current block order, and title. The Durable Object:\n\n1. checks each changed block's `baseVersion`;\n2. accepts non-conflicting changes;\n3. increments accepted block versions and the document revision;\n4. persists the resulting authoritative snapshot; and\n5. broadcasts only the accepted operation.\n\nA remote operation patches only affected DOM blocks. The whole editor is not replaced, so typing and selection in unrelated paragraphs remain stable. Ordering is last-writer-wins, while content and deletion changes use per-block optimistic concurrency.\n\n### Same-block concurrency\n\nA remote update to the block currently being edited is queued rather than replacing the user's caret and draft. If the local block is clean on blur, the remote version is applied. If it is locally dirty, the draft is rebased onto the latest server block version and saved as the next version. Server conflicts return the authoritative block/version, and the client automatically retries its visible local draft—no local text is silently discarded.\n\nThis is block-granularity collaboration rather than a character-level CRDT. It provides smooth simultaneous editing across paragraphs with much less complexity. A CRDT/OT layer would still be required for merged character-by-character edits by multiple people inside the exact same paragraph.\n\n## Presence\n\nSelection changes are throttled to ~70 ms and sent as ephemeral presence messages containing collaborator ID, name, color, and anchor/focus block-and-text offsets. Presence is never persisted. A collaborator appears as a slim named caret; selected text is shown with a subtle translucent highlight, including selections spanning multiple blocks. Both are rendered in a fixed overlay without modifying document HTML. There are no header avatars or active-block outlines. Page close uses a best-effort leave message, RPC disconnects broadcast leave events, and client heartbeats expire stale cursors when browsers do not complete unload networking.\n\nRemote carets and selection highlights live in a separate overlay, so presence decoration never enters the editable DOM or persisted document HTML. Serialization also strips transient local image-selection decoration.\n\n## Programmatic population and backward compatibility\n\nAgents and importers should call `setDocument({ title, blocks, senderId })` to populate or replace the document atomically. It works whether the editor has already been opened or not, persists the full snapshot, increments the revision, and broadcasts the result to connected clients. This avoids requiring callers to choose between initialization and incremental operations.\n\n`initializeBlocks()` remains the idempotent bootstrap/migration API. It also handles the startup race where a newly opened browser creates an empty revision-1 shell just before generated content arrives: that exact untouched shell can be replaced by a non-empty initialization payload. It will not overwrite later empty documents, which may represent an intentional user edit.\n\nIf only the former `content`, `title`, and `lastModified` KV keys exist, the first v2 client loads that HTML, normalizes its top-level nodes, assigns stable block IDs, and calls `initializeBlocks()`. Existing documents therefore migrate without data loss.\n\n## Formatting and media\n\nThe toolbar supports paragraph styles, font family/size, bold, italic, underline, strikethrough, text/highlight color, alignment, lists, indentation, links, images, horizontal rules, clear formatting, and undo/redo. Font sizes are in points, as in Google Docs and Word: body text is 12 pt, in the editor and in the HTML and PDF exports alike, so a sized run keeps its proportion to the text around it.\n\nPasted Google Docs/Word HTML is rebuilt into clean semantic markup. Images from file selection, drag/drop, and clipboard are downscaled and embedded as data URLs. Because collaboration transmits changed blocks, an embedded image is no longer resent when unrelated paragraphs change. A future asset store could replace data URLs with image IDs if very large media-heavy documents are needed.\n\n## Implementation notes\n\n- Durable Object storage is the source of truth; in-memory subscriber and presence maps are intentionally ephemeral.\n- Structural DOM normalization handles root text and `<br>` nodes and repairs duplicate/missing block IDs.\n- Remote ordering moves only blocks that are out of place, avoiding unnecessary selection disruption.\n- `execCommand` is deprecated but remains the compact, broadly compatible formatting mechanism for this sandboxed editor.\n\n## Export formats\n\n- **Markdown** exports the persisted rich-text blocks as Markdown, preserving headings, bold,\n  italic, strikethrough (whether written as tags or as the inline styles the toolbar applies, with\n  an inline style overriding its tag and its ancestors, as on the page), links, images, lists,\n  block quotes, code, horizontal rules and line breaks. A paragraph whose emphasis changes inside\n  a word, where Markdown's asterisks could pair differently than written, writes its emphasis as\n  inline `<strong>`, `<em>` and `<del>` tags instead. Markdown has no syntax for underline,\n  colour, size, font or alignment, so those runs export as plain text. An image exports with its\n  `src`, which for an embedded image is its whole data URL.\n- **HTML** and **PDF** use the editor's print layout and omit editing chrome and presence UI.\n\n<!-- File: packages/bundled-blueprints/blueprints/workspace-sheets/files/README.md (Tokens: ~3067 | Category: plugin-manifest) -->\n# Sheets\n\nA lightweight, persistent spreadsheet Gadget with a familiar grid interface, formulas, formatting, multiple sheets, and live synchronization.\n\n## Features\n\n- Editable spreadsheet title and multiple sheet tabs\n- Add, rename, duplicate, and delete sheets\n- Cell and range selection with keyboard navigation\n- Formula bar and A1-style references\n- Cross-sheet references such as `Sheet2!A1` and `'Sales Data'!B4`\n- 100+ spreadsheet functions across math, statistics, logic, text, lookup, date/time, and information categories\n- Number, currency, percent, scientific, date, and time formatting\n- Bold, italic, underline, strikethrough, text color, fill color, alignment, and wrapping\n- Row and column insertion, deletion, and resizing\n- Range sorting, AutoSum, copy/paste via TSV, and local undo/redo for cell edits\n- Automatic persistent saving with optimistic per-cell conflict detection\n- Real-time operation and presence synchronization in the server architecture\n- Excel workbook and per-sheet CSV export\n\n## Using the spreadsheet\n\n- Click a cell to select it.\n- Double-click a cell, press **F2**, or begin typing to edit it.\n- Start formulas with `=`, for example:\n  - `=SUM(A1:A10)`\n  - `=IF(B2>100,\"High\",\"Low\")`\n  - `=VLOOKUP(E2,A2:C20,3,FALSE)`\n  - `=Sheet2!A1*2`\n- Use the name box to jump to a cell or range such as `D12` or `A1:C8`.\n- Right-click the grid for cut/copy/paste and row or column actions.\n- Double-click a sheet tab to rename it; right-click it to duplicate or delete it.\n\n## Keyboard shortcuts\n\n| Shortcut | Action |\n| --- | --- |\n| Arrow keys | Move the active cell |\n| Shift + Arrow | Extend the selection |\n| Enter / F2 | Edit the active cell |\n| Tab / Shift + Tab | Move right / left |\n| Delete / Backspace | Clear selected cells |\n| Ctrl/Cmd + C, X, V | Copy, cut, paste |\n| Ctrl/Cmd + Z | Undo |\n| Ctrl/Cmd + Y or Ctrl/Cmd + Shift + Z | Redo |\n| Ctrl/Cmd + B | Bold |\n| Ctrl/Cmd + I | Italic |\n| Ctrl/Cmd + U | Underline |\n| Ctrl/Cmd + A | Select the whole sheet |\n| Ctrl/Cmd + Arrow | Jump across populated or empty regions |\n| Alt + Enter | Insert a line break while editing |\n\n## Programmatic population\n\nThe spreadsheet can be read and populated through the Gadget's server RPC methods. This is useful for scripts, agents, migrations, or seeding a workbook without using the grid UI.\n\n### Read the workbook\n\nCall `getDocument()` to retrieve the complete persisted workbook:\n\n```js\nconst doc = await gadget.getDocument();\n```\n\nThe returned object contains `revision`, `title`, `sheetOrder`, `sheets`, `cells`, and `lastModified`. Cells are grouped by sheet ID and keyed by A1 reference:\n\n```js\nconst firstSheetId = doc.sheetOrder[0];\nconst a1 = doc.cells[firstSheetId].A1;\n// { value: \"Revenue\", fmt: { b: true }, version: 1 }\n```\n\n### Write cells\n\nUse `applyOperation()` with per-cell operations. For a new cell, use `baseVersion: 0`; when replacing or deleting an existing cell, use its current `version` from `getDocument()`.\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\nconst cells = doc.cells[sheetId];\n\nconst result = await gadget.applyOperation({\n  senderId: \"seed-script\",\n  cellOps: [\n    {\n      sheetId,\n      ref: \"A1\",\n      value: \"Revenue\",\n      fmt: { b: true, bg: \"#fff3a3\" },\n      baseVersion: cells.A1?.version ?? 0,\n    },\n    {\n      sheetId,\n      ref: \"B1\",\n      value: \"=SUM(B2:B10)\",\n      fmt: { nf: \"currency\", d: 2 },\n      baseVersion: cells.B1?.version ?? 0,\n    },\n  ],\n});\n```\n\nA cell's `value` is always stored as text. Begin a value with `=` to create a formula. To delete a cell completely, send both `value: null` and `fmt: null` with the current `baseVersion`.\n\nCheck `result.status` after writing. It is `applied`, `unchanged`, or `conflict`. On a conflict, inspect `result.conflicts`, re-read the document, and retry against the latest cell versions rather than overwriting blindly.\n\n### Replace a whole sheet\n\nFor bulk imports, `sheetReplacements` is more efficient than sending many individual cell operations:\n\n```js\nawait gadget.applyOperation({\n  senderId: \"csv-import\",\n  sheetReplacements: [{\n    sheetId,\n    cells: {\n      A1: { value: \"Name\", fmt: { b: true }, version: 1 },\n      B1: { value: \"Total\", fmt: { b: true }, version: 1 },\n      A2: { value: \"Example\", fmt: null, version: 1 },\n      B2: { value: \"1250\", fmt: { nf: \"currency\", d: 2 }, version: 1 },\n    },\n  }],\n});\n```\n\nA whole-sheet replacement overwrites that sheet's complete cell map, so merge with existing data first if it must be preserved. It does not use per-cell conflict checks.\n\n### Change workbook structure\n\nPass a complete structure snapshot to change the title, sheet order, names, dimensions, or sizing metadata. Because structure uses last-writer-wins semantics, start with the latest document and preserve every sheet that should remain:\n\n```js\nconst doc = await gadget.getDocument();\nconst sheetId = doc.sheetOrder[0];\n\nawait gadget.applyOperation({\n  senderId: \"setup-script\",\n  structure: {\n    title: \"Quarterly plan\",\n    sheetOrder: doc.sheetOrder,\n    sheets: {\n      ...doc.sheets,\n      [sheetId]: {\n        ...doc.sheets[sheetId],\n        name: \"Summary\",\n        colWidths: { ...doc.sheets[sheetId].colWidths, 0: 180 },\n      },\n    },\n  },\n});\n```\n\nOmitting an existing sheet from `sheetOrder` deletes it and its stored cells. Adding a new sheet ID creates empty storage for it. When adding a sheet and its initial data together, include both `structure` and a matching `sheetReplacements` entry in the same operation.\n\nFormatting keys accepted by the server are `b` (bold), `i` (italic), `u` (underline), `s` (strikethrough), `c` (text color), `bg` (fill color), `a` (`l`, `c`, or `r` alignment), `nf` (number format), `d` (decimal places), `fs` (font size), and `wrap`. Colors must be hexadecimal strings such as `#1d1d20`.\n\n## Architecture\n\nBoth sides are built on the shared gadget libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the `client.js` and `server.js` it ships.\n\n`@gadgets/bundled-blueprints/libraries/ui/client` draws the chrome: the element builder, the shared\ntoolbar icons and controls (icon and segment buttons, groups, colour pickers, the dropdown behind the\nnumber-format menu), the in-page prompt that stands in for the `window.prompt` the sandbox blocks,\nand the save-status dot. `@gadgets/bundled-blueprints/libraries/sync/client` and `.../sync/server` are the\ncollaboration loop: the debounced, serialized, retrying save scheduler, the presence roster and\nheartbeat, the subscriber object the server calls back on, and in the Durable Object the mutation\nqueue and the subscriber registry with its presence announcements (whose broadcasts are never\nawaited, so a callback may re-enter the queue). `@gadgets/bundled-blueprints/libraries/zip/server`\nprovides the streaming ZIP writer used by XLSX export. The cell model, the formula engine, the grid,\nsheet tabs and the export-specific code are this gadget's own.\n\nIn the repository the source is TypeScript under `blueprints/workspace-sheets/files/`\n(`client.ts`, `server.ts`, `lib/protocol.ts`, `lib/formula.ts`, `lib/xlsx.ts`), which the build bundles\ninto the `client.js` and `server.js` shipped here.\n\n### `client.js`\n\nBuilds the entire browser interface in JavaScript. It contains:\n\n- Grid rendering and selection behavior\n- Cell editing, formatting, sorting, and structural operations\n- Formula tokenization, parsing, evaluation, and display formatting\n- Clipboard and keyboard support\n- A local model whose saves are queued per cell and flushed by the library's scheduler\n- RPC callbacks for live server operations and presence events\n\nFormula evaluation happens in the browser. The engine caches computed cells, detects circular references, supports ranges and cross-sheet references, and displays standard errors including `#DIV/0!`, `#VALUE!`, `#REF!`, `#NAME?`, `#N/A`, `#NUM!`, and `#CYCLE!`.\n\n### `server.js`\n\nExports the Durable Object class `Gadget`, which is the authoritative persistence and synchronization layer. It:\n\n- Stores spreadsheet metadata and each sheet's cells in Durable Object storage\n- Serializes mutations and document snapshots through the library's mutation queue\n- Applies per-cell optimistic concurrency using cell versions\n- Uses last-writer-wins semantics for document structure\n- Broadcasts operations and presence events through the library's subscriber registry after the queue releases, best-effort and without awaiting them, so a callback may itself read or write the document and a hung subscriber holds up only its own client\n- Sanitizes titles, dimensions, cell contents, references, and formatting\n- Advertises and produces the server-side workbook and CSV exports\n\n### XLSX and ZIP modules (bundled into `server.js`)\n\nThe XLSX module converts a complete document snapshot into a streaming XLSX workbook: sparse\nworksheet XML with inline strings, deduplicated styles, and frozen panes. The ZIP module is a\ndependency-free streaming ZIP writer (raw DEFLATE via `CompressionStream`, incremental CRC32, data\ndescriptors). Both are bundled into `server.js`.\n\n## Storage model\n\nThe server stores:\n\n- `meta`: revision, title, sheet order, sheet metadata, and modification time\n- `cells:<sheetId>`: a map of A1 references to `{ value, fmt, version }`\n\nDefault sheets have 100 rows and 26 columns. Server validation permits up to 50,000 rows and 702 columns per sheet. Cell values are limited to 8,192 characters, and titles are limited to 200 characters.\n\n## Collaboration notes\n\nThe server and client synchronization code support multiple connected clients and live updates. Each\ntab introduces itself with the guest name and colour the sync library derives from its client ID, and\nreports its selected range on the library's throttle and heartbeat. Remote collaborator badges and\nselection overlays are currently disabled in the UI, so the app presents as a single-user spreadsheet\neven though remote operations still synchronize.\n\n## Current limitations\n\n- There is no file import workflow; clipboard operations use tab-separated text.\n- Structural edits and sorting clear local undo/redo history.\n- Formula reference adjustment during row/column changes is limited to references on the current sheet.\n- Formula support is broad but is not intended to be fully compatible with Excel or Google Sheets.\n- Frozen row/column metadata exists in the model, but the current UI does not expose controls for it.\n\n## CSV export\n\nUp to 31 worksheets are exposed as individual **CSV** export options, leaving one of the platform's\n32 format slots for XLSX. CSV files contain the stored cell values through the worksheet's used\nrange. Formula cells are exported as their raw formulas (for example, `=SUM(A1:A10)`), not as\nbrowser-computed display values. Fields use standard CSV quoting and CRLF line endings.\n\n## XLSX export\n\n**Excel Workbook** exports one XLSX file with the worksheets in workbook order. Cells keep their\nfont, color, fill, alignment, number format, decimal places and wrapping; sheets keep column widths,\nrow heights and frozen panes. Pixel sizes are converted to points (rows, fonts) or approximate\ncharacter widths (columns), so layout is close but not pixel-identical.\n\nCell values follow the grid's own literal rules: a leading apostrophe forces text, a leading `=` is\na formula (whatever the number format), `TRUE`/`FALSE` are booleans, and numbers may carry a sign,\ncommas, a leading `$` or a trailing `%`. Everything else is text; date-looking text is not parsed.\n\nFormulas are written without cached results and the workbook requests a full recalculation on open,\nso Excel evaluates them itself. To keep them valid there, the exporter rewrites cross-sheet\nreferences to the exported worksheet names, prefixes OOXML \"future functions\" (`IFS`, `CONCAT`, ...)\nwith `_xlfn.`, renames `ERRORTYPE()` to `ERROR.TYPE()`, and drops whitespace between a function\nname and its `(`. A formula that is empty, structurally unbalanced (unterminated string or quoted\nname, mismatched parentheses or brackets — the grid's parser tolerates these) or that would exceed\nExcel's 8,192-character limit after rewriting is exported as text, since one such formula makes\nExcel report the whole workbook as damaged. Formula semantics are otherwise not translated (for\nexample `^` associativity differs, and a reference outside the grid such as `XFE1` is empty here\nbut `#NAME?` in Excel), and compatibility with Excel is not claimed beyond this.\n\nWorksheet names are made Excel-safe: invalid characters become `_`, blank names become `Sheet`,\nnames are cut to 31 characters, and case-insensitive collisions get ` (2)`, ` (3)`, ... suffixes.\nReferences resolve to the first worksheet with a matching source name, as in the grid.\n\nFilters, charts, comments and pivot tables are not exported; cells already materialized from them\nexport as ordinary values.\n\n<!-- File: packages/bundled-blueprints/blueprints/workspace-slides/files/README.md (Tokens: ~5331 | Category: plugin-manifest) -->\n# Workspace Slides\n\nA composable, multiplayer slide deck builder. The deck itself is data —\nslides are arrays of typed blocks — and the editor lets anyone add, remove,\nreorder, and edit slides in real time. The visual language (tokens,\ncomponents, typography) is shared across every block. Edit mode uses light,\nneutral sidebars so the slide canvas remains the visual focus.\n\n## Files\n\n- `server.js` — Durable Object that owns the deck document and broadcasts\n  every mutation to connected viewers. The server is a generic store; it\n  doesn't know what any component does.\n- `client.js` — Design tokens, the component registry, slide renderer,\n  block interactions (drag, resize, inline text edit), and the builder\n  shell (slide list, inspector, palette, control bar, present mode).\n\nIn the repository these two modules are TypeScript (`client.ts`, `server.ts`\nand the shared `lib/protocol.ts` types under\n`blueprints/workspace-slides/files/`), which the build bundles into the\n`client.js` and `server.js` installed here.\n\n## Gadget libraries\n\nThree pieces of this deck are not its own: the element builder, the two\nsteps that read an uploaded image, and the object the Durable Object calls\nback. They are shared with the other document-style gadgets and are\nimported from the libraries in `packages/bundled-blueprints/libraries`, which the build\ninlines into the two files installed here:\n\n```ts\nimport { el, loadImage, readFileAsDataURL } from \"@gadgets/bundled-blueprints/libraries/ui/client\";\nimport { createSubscriber } from \"@gadgets/bundled-blueprints/libraries/sync/client\"; // client.ts\nimport { SubscriberRegistry } from \"@gadgets/bundled-blueprints/libraries/sync/server\"; // server.ts\n```\n\n- **`ui`** — `el(tag, props, children)` builds every element in\n  `client.js`; `readFileAsDataURL` and `loadImage` are the two halves of\n  `fileToImageDataURL`, whose encoding rule (SVG verbatim, PNG kept as PNG,\n  the original on any canvas failure) stays here because it is the deck's.\n  The library's own icons, toolbar controls and prompt are for the gadgets\n  with a formatting bar; this deck draws its own `ICONS` as complete SVGs\n  and has no toolbar of that shape, so it imports none of them.\n- **`sync`** — `createSubscriber(RpcTarget, { deckChanged })` builds the\n  callback target (the RPC layer only calls prototype methods, which is\n  what the library gets right), and `SubscriberRegistry` holds the\n  connected browsers in the Durable Object. A deck has no presence — every\n  viewer sees the same slides and no cursors are shared — so the registry\n  is built with no presence hooks and is a plain fan-out.\n\nEverything else here — the tokens, the `COMPONENTS` registry, the layout and\nsnapping code, the deck document and its undo history — is this blueprint's.\n\n## Slide formats\n\nThe deck is built in a clean, **branded corporate style** (an orange accent\npalette on white slides) and supports two ready-made slide formats. Both live on the fixed\n`1200 × 675` reference canvas and are made entirely of normal editable\nblocks, so anything a template drops in can be moved, restyled, or deleted\nafterward.\n\n### 1. Title / cover slide (`makeTitleSlide()` in `client.js`)\n\nThe orange opening slide.\n\n- Full-bleed orange cover artwork (`background.coverOrange`).\n- White brand **logo** (a clean editable wordmark with an optional accent\n  dot) at `x 36, y 56`, width `267`.\n- **Title** — Inter, white, ~58px, weight 700, at `x 33, y 197`, width\n  `687`. Kept in the left portion of the slide, max three lines.\n- **Subtitle** — Inter, white, ~17px, weight 600, at `x 36, y 533`, width\n  `553`, max two lines. Delete the block if no subtitle is needed.\n\nThis is also what the seed deck's first slide and the server's\n`newBlankSlide()` produce.\n\n### 2. Basic content slide (`makeContentSlide()` in `client.js`)\n\nThe standard white content shell for every non-cover idea.\n\n- Pure white background, no inset surface, no shadow, no border.\n- **Eyebrow** (`sectionLabel`) — Ruby `#FF6633`, uppercase, SemiBold, at\n  `x 36, y 35`. Optional; delete when not useful. Eyebrows are always Ruby.\n- **Headline** (`title`) — black `#000000`, ~28px, weight 600, at\n  `x 35, y 76`, width `984`, max two lines, written as an assertion.\n- **Logo** — the brand wordmark (the `logo` component in its `dark`\n  variant: black wordmark + orange accent dot), at `x 1013, y 40`, sized\n  via `scale: 0.62`.\n- **Content region** — body starts at `x 36, y 204`. The default narrative\n  uses a constrained `760px` reading measure at 19px, weight 400,\n  line-height 1.6. The add-slide controls also provide exact-grid\n  **2-column** (`x 36/609`, `w 553`) and **4-column**\n  (`x 34/328/622/916`, `w 260`) compliant structures.\n- **Bottom brand bar** — full-width 12px Tangerine `shape` at `y 663`, touching the bottom edge.\n\n### Creating slides in either format\n\n- **In the editor:** enter edit mode (**E**). The left panel footer has\n  two buttons — **Title** and **Content** — that insert the matching\n  template after the current slide. Each calls\n  `addSlide(makeTitleSlide())` / `addSlide(makeContentSlide())`.\n- **Programmatically (over the `GADGET` binding):** call\n  `addSlide(atIndex, slideObject)` with a `{ background, blocks }` object.\n  The block/prop shapes in `makeTitleSlide` / `makeContentSlide` are the\n  canonical reference for the two styles; the server mints block ids on\n  insert.\n\nBrand style tokens worth reusing: Ruby `#FF6633`, Tangerine `#F6821F`,\nMango `#FBAD41`, black `#000000`, muted gray `#747474`, Inter throughout.\nSlide-facing component controls are intentionally limited to this palette\nand the approved 400/500/600/700 weights. Cards and diagram boxes default\nto flat white surfaces, thin `#E5E5E5` rules, minimal corner radii, and no\nshadows. Generic colored tones and the pill component are not offered in\nthe component library. The `bulletList` component implements the corporate\nhanging-indent treatment: one item per source line, 6px Tangerine dots,\n12px dot-to-copy gap, 19px primary or 17px compact Inter Regular text, and\ncontrolled 10px/8px item spacing.\n\n## Initial blueprint\n\nNew Gadget instances start from the current four-slide overview of the\nbuilder: the orange “Compose your deck or build with agent” cover, a six-part\nfeature overview, a connected-charts example showing how an agent can use an\napproved internal system of record as a data source, and a two-path get-started\nslide for editing directly or asking the agent. The complete blueprint\nis defined by `INITIAL_DECK` plus `KEY_TAKEAWAYS_SLIDE` in `server.js`; both\nfirst-time initialization and `resetAll()` clone that blueprint. The immutable default\nobjects in `server.js` are cloned whenever storage is first seeded and by\n`resetAll()`, so later edits cannot mutate the blueprint.\n\n## Data model\n\nThe deck is one JSON document:\n\n```js\n{\n  slides: [\n    {\n      id: \"abcd1234\",\n      background: { color: \"#f5f1eb\", inset: true, dotGrid: 0.45 },\n      blocks: [\n        { id: \"...\", type: \"title\", x: 48, y: 78, w: 1040,\n          props: { text: \"Hello\", fontSize: 46, highlight: \"Hello\" } },\n        ...\n      ],\n    },\n    ...\n  ],\n}\n```\n\nEvery block is `{ id, type, x, y, w?, h?, props }`. Position is in the\nfixed `1200 × 675` slide coordinate system; the stage scales uniformly to\nfit the viewport.\n\n## Component registry\n\n`COMPONENTS` in `client.js` is the catalog. Each entry declares:\n\n| field            | purpose                                                 |\n| ---------------- | ------------------------------------------------------- |\n| `name`           | Display name (palette + inspector header).              |\n| `defaultBlock()` | Returns `{ x, y, w?, h?, props }` for a freshly added block. |\n| `fields`         | Inspector schema (`text`, `multiline`, `number`, `select`, `checkbox`, `color`, `image`, `svg`). Set `advanced: true` on a field to tuck it under the inspector's \"Advanced\" disclosure (along with position/size). |\n| `resizableW/H`   | Whether the corner handle adjusts that dimension.       |\n| `fullBleed`      | If true (arrow), the block ignores wrapper position and renders an absolutely-positioned overlay. |\n| `render(props, ctx)` | Returns the DOM. `ctx.inlineText(elem, propKey, transform?)` wires up contentEditable in edit mode. |\n\nComponents exposed in the palette (generic, reusable):\n\n`title`, `subtitle`, `text`, `sectionLabel`, `card`, `box`, `tonePill`,\n`image`, `svg`, `divider`, `shape`, `arrow`.\n\nComponents defined in `COMPONENTS` but intentionally **not** in the\npalette — they're brand marks used by the seed deck only:\n\n`gadgetsMark`, `logo`. The `logo` component renders a clean editable\nwordmark (`text` prop, default `Workspace`) with an optional orange accent\ndot (`accentDot` prop); change the wordmark to rebrand the deck. They still\nrender anywhere a block of that type appears; they just aren't offered as\nbuilding blocks.\n\n### Adding a new component\n\n1. Add a new entry to `COMPONENTS` with a `render`, `defaultBlock`, and\n   `fields`. Use `ctx.inlineText(elem, \"propKey\")` for any text the user\n   should be able to edit directly in the slide.\n2. Add the type to `PALETTE_ORDER` so it shows up in the library.\n3. Add the type to one of the `COMPONENT_CATEGORIES` groups, and add a\n   short description to `COMP_DESC` plus a 14×14 currentColor SVG icon\n   to `COMP_ICON`. (The library renders the icon + name + description as\n   one row per component.)\n\nThat's it — no other code needs to change. The inspector is auto-generated\nfrom `fields`, drag/resize/selection all work automatically.\n\n## Editing\n\nPress **E** (or click the pencil) to enter edit mode. Edit mode reveals:\n\n- **Left panel** — a light, neutral sidebar with a **slide search** (matches\n  any string value in any block's props), a scrollable thumbnail list,\n  and a split **Add slide** control. The main action inserts the standard\n  narrative slide; its caret menu offers title, narrative, 2-column, and\n  4-column layouts (see \"Slide formats\" above). Each row\n  shows its slide\n  number, the thumbnail, and a one-line label derived from the slide's\n  first title (or subtitle / text). Click to jump, drag to reorder\n  (disabled while filtering), hover to reveal duplicate/delete. Press\n  Enter in the search box to jump to the first match; Escape clears.\n- **Right panel** — three stacked sections, in order:\n  1. **Component library** — a contained, filterable card. The search\n     input doubles as the section title; type to filter (matches on\n     name, description, and type id), Enter inserts the first match,\n     Escape clears. Without a query, components are grouped by category\n     (Text / Containers / Decoration / Diagram); with a query, results\n     are flat. The list has its own internal scroll so the library\n     never grows past ~280px tall.\n  2. **Slide** — compact background settings for the current slide\n     (page color, dot grid opacity, inset toggle).\n  3. **Selected block inspector** — icon + name header, schema fields\n     (label-on-the-left rows, multiline + color stack vertically),\n     a 4-button icon row (duplicate, bring forward, send backward,\n     delete), and an \"Advanced\" disclosure that holds position/size\n     and any fields tagged `advanced: true`. When nothing is selected\n     this is replaced by a quiet \"Nothing selected\" hint.\n- **Stage** — every block is selectable (hover dashes, click to select);\n  drag the body to move, the bottom-right handle to resize. Text inside a\n  block is contentEditable; press Enter or click away to save. While\n  dragging, the block softly snaps when its left / right / center edges\n  line up with another block's edges or center, or with the slide bounds\n  / center; a thin orange guide line shows which alignment is locked.\n  Hold **Alt / Option** while dragging to disable snapping entirely.\n\nPress **F** to enter presentation mode (chrome hidden, stage fills the\nviewport, no rounded corners). **Esc** or **F** exits.\n\n## Realtime\n\nEvery mutation is sent through RPC to the Durable Object and re-broadcast\nas a full deck snapshot via the subscriber callback. The callback signature\nis `deckChanged(deck, meta)` where `meta = { canUndo, canRedo }` reflects\nthe current server-side history state. The client preserves the currently-\nselected slide & block across updates when possible.\n\nThe registry behind `#broadcast` isolates subscribers from each other: one\nwhose call fails is dropped and its stub released rather than failing the\nmutation being broadcast, and so is one whose connection breaks.\n\n## Undo / redo\n\nThe undo stack lives on the server (in `Gadget`, in memory only — lost on\nDO restart, capped at `MAX_UNDO` entries). It's **global**: there's one\nshared history for the deck, and pressing undo rolls back the most recent\nmutation made by any connected client. Every call to `#save` snapshots\nthe previous deck onto the undo stack and clears the redo stack;\n`undo()` / `redo()` bypass `#save` (so they don't recurse) and broadcast\nthe restored deck through `#broadcast`. Clients learn whether undo/redo\nare currently available from the `meta` arg piggy-backed on every\nbroadcast, plus an initial `getUndoState()` call at boot.\n\n## Keyboard\n\n- `←` / `→` / `Space` / `PageUp` / `PageDown` — navigate slides\n- `Home` / `End` — first / last slide\n- `E` — toggle edit mode\n- `F` — toggle present mode\n- `Delete` / `Backspace` (edit mode) — delete the selected block\n- `Ctrl/Cmd + C` / `X` / `V` (edit mode) — copy / cut / paste the selected\n  block. The clipboard is in-memory (per tab); paste inserts onto the\n  *current* slide with a small position offset so the new block doesn't\n  sit perfectly on top of its source, and the new block becomes selected.\n- `Ctrl/Cmd + D` (edit mode) — duplicate the selected block in place\n  (same as the inspector's duplicate icon, doesn't touch the clipboard).\n- `Ctrl/Cmd + Z` — undo; `Ctrl/Cmd + Shift + Z` (or `Ctrl + Y`) — redo.\n  Also available as two icon buttons in the floating control bar (only\n  shown while in edit mode; the keyboard shortcuts work anywhere).\n- `Esc` — close jump menu, then deselect, then exit present mode\n\n## Motion\n\nAll UI motion uses a single `--ease` token\n(`cubic-bezier(0.23, 1, 0.32, 1)` — a strong ease-out) and a small\n`--ease-in-out`. Press feedback on buttons is handled with two CSS\nselectors registered in `mountShell`:\n\n- `[data-press]:active { transform: scale(0.97); }` — generic buttons.\n- `[data-icon-btn]:active { transform: scale(0.92); }` — pill icon\n  buttons in the control bar.\n\nAdd either attribute to a button to get the right press feel for free.\nAvoid `transition: all`; always specify the exact properties so unrelated\nproperty changes don't accidentally animate.\n\n## Inspector form controls\n\nAll inspector inputs (text, number, select, textarea, checkbox, color)\nshare a small set of CSS classes defined in `mountShell`:\n\n- `.field-input` — the base look (subtle dark fill, transparent border,\n  orange ring on focus). Applied to `<input>`, `<select>` and\n  `<textarea>`.\n- `.field-textarea` — adds the multiline sizing on top of `.field-input`.\n- `.field-select` — adds an inline SVG chevron and resets the native\n  `appearance` so the select matches text inputs.\n- `.field-check` — a custom-painted checkbox (orange when checked).\n\n`fieldRow(label, control, { stacked })` lays out a label + control as a\ntwo-column grid by default; pass `{ stacked: true }` for full-width\ncontrols (multiline only). All field factories (`textField`,\n`numberField`, etc.) use this and the classes above, so you almost never\nneed to touch styles when adding a new field type.\n\n## Notes\n\n- The brand logo and gadgets mark are inlined as SVG; they live in\n  the `logo` / `gadgetsMark` components and are used only by the seed\n  deck. The palette doesn't expose them as reusable blocks. To rebrand,\n  edit the `logo` block's `Wordmark` field (and toggle its accent dot).\n- **Image** blocks store their data inside `props.src` as either an\n  external URL (loaded normally by `<img>` — note this is one of the few\n  network paths the sandbox still allows) or as an inlined `data:` URI.\n  Uploads are read with the `ui` library's `readFileAsDataURL`, and raster\n  images larger than `MAX_IMAGE_DIM` (1600px on the longest side) are\n  downscaled on canvas before being inlined, so the deck JSON doesn't\n  balloon. SVG files are passed through verbatim. The custom `image` field\n  type in `renderField` renders the preview + upload/clear buttons + URL\n  input.\n- **SVG** blocks store raw markup in `props.markup`. `render()` parses\n  it with the DOM parser, strips `<script>` elements and `on*` attributes,\n  then forces `width=\"100%\" height=\"100%\"` and a `preserveAspectRatio`\n  derived from the `fit` prop so the SVG scales to the block bounds.\n  Agents can author / replace SVG blocks programmatically over the\n  `GADGET` binding: call `addBlock(slideId, { type: \"svg\", x, y, w, h,\n  props: { markup: \"<svg ...>...</svg>\" } })` or `updateBlock(...,\n  { props: { markup: \"...\" } })`.\n- Arrows are special-cased: they render a full 1200×675 SVG overlay and\n  store endpoints in `props.x1/y1/x2/y2`. Endpoint handles appear when an\n  arrow is selected. **Important**: full-bleed blocks must NOT make their\n  root element pointer-clickable, or they'll eat clicks for everything\n  rendered before them on the slide. `renderBlock` keeps the SVG itself\n  `pointer-events: none` and adds an invisible thick hit-line on top of\n  the visible arrow stroke so clicking near the line still selects it.\n- Alignment snaps while dragging are implemented in `attachBlockInteractions`\n  via three helpers: `computeSnapTargets` (gathers x/y edges + centers from\n  every other non-arrow block on the slide, plus slide bounds + center),\n  `applySnap` (independently snaps each axis to the nearest target within\n  `SNAP_THRESHOLD` slide-units), and `drawSnapGuides` (renders thin orange\n  guide lines into a `[data-snap-layer]` div inside the slide frame). The\n  snap layer is torn down on `pointerup`. Blocks without explicit `w`/`h`\n  (titles, pills, etc.) are measured via `offsetWidth/Height` so their\n  rendered bounding box still participates correctly.\n- Slide thumbnails in the left panel are produced by `renderSlideThumbnail`,\n  which calls the normal `renderSlide` with `editMode` temporarily forced\n  off and wraps the resulting 1200×675 frame in an `overflow:hidden`\n  container scaled with `transform: scale(width / 1200)`.\n- The slide search at the top of the left panel is built once at shell\n  mount (`buildSlideSearchHeader`) and persists across re-renders, so\n  typing into it never steals focus. `renderSlideList` reads\n  `slideSearch` and walks `filteredSlideIndices()` to decide what to\n  show. Drag-to-reorder is disabled when a filter is active since the\n  visible positions don't map 1:1 to absolute deck indices.\n- Reordering uses native HTML5 drag-and-drop (`draggable=\"true\"` +\n  `dragstart`/`dragover`/`drop`). A floating 2px orange line shows the\n  drop position; the actual move is done optimistically on the client and\n  then sent to the server via `gadget.moveSlide`.\n- The sandbox prohibits modal dialogs and external fetches; all asset\n  data must be inlined.\n- The server still has a `resetAll` RPC method — it's no longer exposed\n  in the UI but remains for use from the `GADGET` binding if you ever\n  need to nuke the deck.\n\n## Export formats\n\nThe deck supports **HTML**, **PDF**, and **PowerPoint (`.pptx`)** exports. HTML and PDF use the\nbrowser print renderer. PowerPoint export runs on the server and creates a conventional OPC /\nPresentationML package through `@gadgets/bundled-blueprints/libraries/pptx/server`. The build inlines\nthat library into `server.js`; there is no separately shipped `pptx.js` or runtime package dependency.\n\nText, cards, boxes, pills, basic shapes, dividers, arrows, and the two brand marks become editable\nnative PowerPoint objects rather than a screenshot. `ExportHandler` passes the original deck to\n`deckToPptx(deck, adaptBlock)`, with an adapter that expands each `logo` into a text wordmark and\nellipse accent dot. Other repository-authored TypeScript blueprints can import the same renderer\nand supply their own adapters. Source block order remains the shape z-order.\n\nAuthored quotas are checked before adaptation: 500 slides, 1,000 blocks per slide, and 10,000 blocks\nper deck. Adapted output has separate bounds of 2,000 blocks per slide and 20,000 per deck, so a\nlogo's accent dot does not consume another authored block. Text and image budgets still apply.\n\nThe starter chart, Edit/agent icons, and bottom brand bars use native blocks and survive export.\nThe orange cover treatment exports as native objects; SVG blocks and dot-grid backgrounds are omitted.\n\nThe 1200 x 675 canvas maps to standard widescreen PowerPoint at 12,192,000 x 6,858,000 EMU. The\nrenderer asks consumers to grow auto-height blocks. Cards budget text boxes and gaps within their\nauthored padded height, then ask consumers to shrink the text to fit rather than match browser\nclipping. PNG and JPEG data URLs are embedded after signature, dimension, and resource limit checks.\nImage corner radii apply when the picture fills its block, including matching-aspect `contain`\nimages; letterboxed pictures remain rectangular. Remote raster images become visible placeholders.\nSVG image data URLs and `svg` blocks are omitted entirely.\n\n<!-- File: packages/bundled-blueprints/libraries/README.md (Tokens: ~1238 | Category: plugin-manifest) -->\n# Gadget libraries\n\nShared code the bundled blueprints import instead of carrying their own copies. A blueprint's\n`client.ts` and `server.ts` import a library by this package's name and the subpath its\n`package.json` exports, `@gadgets/bundled-blueprints/libraries/<name>/client` and\n`.../<name>/server`; the blueprint build (`../src/files.ts`) resolves each to the library's entry\nhere and inlines what the entry uses into the `client.js` / `server.js` the blueprint ships, the way\nit inlines a blueprint's own `lib/` modules. The blueprint stays self-contained, and a gadget created from\nit carries its own copy of the library as of its creation; nothing resolves the package name\nat runtime, and a gadget the agent writes cannot import one.\n\nNothing here is deployed on its own. The package name is how tsc, vitest and an editor resolve the\nimport, through `exports`, with no alias or `paths` block; the build resolves the same name to this\ndirectory itself, so a blueprint tree with no `node_modules` above it builds too.\n\n## Layout\n\n```\n<name>/\n  client.ts        entry of .../libraries/<name>/client, inlined into a blueprint's client.js\n  server.ts        entry of .../libraries/<name>/server, inlined into a blueprint's server.js\n  src/**           the modules the two entries import; never shipped on their own\n  __tests__/**     vitest, jsdom by default, `// @vitest-environment node` for a pure module;\n                   `server.test.ts` / `<topic>.server.test.ts` for a test of the server side\n  README.md        what the library does and how it is put together\n```\n\nA library may import another (`@gadgets/bundled-blueprints/libraries/ui/client` from a library built\non it, say); the blueprint build resolves that import the same way. A client entry may not import a\nlibrary's server side, and the build rejects a blueprint that does: it would drag a Durable Object\ninto the iframe. A blueprint imports its own files by relative path and a library by package\nsubpath; everything else the bundle inlines fails the build -- a relative or absolute path into\n`libraries/` (`../../../libraries/ui/src/dom.ts`), a library's `src/` module by package path, the\npackage root, or a bare specifier some `node_modules` above the blueprint happens to satisfy -- so\nthe exported subpath is the only door.\n\n## Rules for a library\n\n- **es2022.** A blueprint's bundles target `es2022`, and the gadget loader's `compatibilityDate` is\n  `\"2026-02-01\"` (`loadGadgetWorker` in overseer.ts). A library that needs a newer runtime feature\n  bumps that date for every gadget, and says so in its README.\n- **No npm.** What a library imports is inlined into a blueprint's files, which nothing audits\n  afterwards, so the blueprint build rejects an input from `node_modules`. A library is written\n  against the platform alone, like a gadget.\n- **Readable.** The inlined code is what the agent reads and edits in an instantiated gadget. The\n  bundle is not minified, so a library's names and structure survive into it and should read well\n  on their own; its comments do not survive, because esbuild drops ordinary comments whatever the\n  minify settings, so the doc comments in `src/` are for this repository's readers.\n- **Lint applies.** Unlike a blueprint's `files/`, which the repo's lint ignores as user-authored\n  gadget source, this is platform code and is checked like the rest of the repo.\n\n## Tests\n\n```\npnpm --filter @gadgets/bundled-blueprints test:run   # the libraries' suites, with the blueprints'\nvp run -F @gadgets/bundled-blueprints build          # the type-check programs\n```\n\nThe package's `tsconfig.client.json` checks each client entry under the DOM lib,\n`tsconfig.server.json` each server entry under the Workers types, `tsconfig.tests.json` the tests\nunder DOM and Node types and `tsconfig.server-tests.json` the server-side tests under Workers and\nNode types; a `src/` module is checked under whichever of those imports it, which is what keeps a\nDurable Object from seeing `document` and iframe code from importing `cloudflare:workers`. A server\ntest runs against a stub of `cloudflare:workers` that the package's vitest config aliases in.\n\n## Libraries\n\n- [`ui`](ui/README.md) -- DOM helpers for document-style gadgets: the `el` builder, SVG icons and\n  toolbar controls, the in-page prompt, the save-status dot, relative time, and image reading and\n  downscaling. Client-only.\n- [`sync`](sync/README.md) -- collaboration plumbing: on the server a mutation queue, a subscriber\n  registry with presence seeding and broadcast, and versioned upserts; on the client a save scheduler\n  with retry backoff, a presence roster and a subscribe helper. The Docs, Sheets and Slides\n  blueprints build on it.\n- `zip` -- a server-only streaming ZIP32 writer shared by the Sheets XLSX exporter and PPTX library.\n- `pptx` -- a server-only PresentationML renderer for block-based slide decks, inlined into each\n  importing blueprint's `server.js`, not shipped as a separate `pptx.js`. `deckToPptx(deck, adaptBlock)`\n  validates authored quotas before a per-block adapter replaces brand-specific blocks; adapted\n  output is bounded separately. Installed gadgets cannot resolve these package imports at runtime.\n\n<!-- File: packages/bundled-blueprints/libraries/sync/README.md (Tokens: ~1389 | Category: plugin-manifest) -->\n# `sync` — collaboration plumbing\n\nEvery collaborative gadget here has the same skeleton: one Durable Object owns the authoritative\nstate and many browsers edit it live -- a mutation queue in the object, a set of subscriber stubs\nit broadcasts to, presence seeded on join and dropped on leave, per-item optimistic concurrency,\nand in the browser a debounced save loop with retry, a throttled presence heartbeat and an\n`RpcTarget` for the callbacks. This library is that skeleton once, with none of the flesh: it knows\nnothing about blocks, cells or slides, and a gadget keeps its own model, protocol, DOM and export\nformats on top of it.\n\n## Server (`@gadgets/bundled-blueprints/libraries/sync/server`)\n\n- **`MutationQueue`** -- `run(work)` chains mutations so that each loads the state the previous\n  one stored, whatever order their RPCs interleave in; a rejection reaches its caller alone.\n- **`SubscriberRegistry<Callbacks, Info>`** -- `add(subscriber, info)` `dup()`s the stub the RPC\n  layer delivered, registers `onRpcBroken` cleanup and returns the kept handle; `broadcast(send)`\n  delivers to everyone at once and never waits: a failing subscriber is dropped and disposed\n  (never failing the mutation), a hung one holds up nothing but itself, and a callback may call back\n  into the object -- read the document, queue a mutation -- without deadlocking on the mutation that\n  is telling it about the last one; deliveries are issued synchronously in call order, so each\n  subscriber hears events in order. `remove(handle)`, `has(handle)`, `members()`, `size`. Given\n  `PresenceHooks` -- `join(subscriber, who)` and `leave(subscriber, who)` in the gadget's own\n  callback vocabulary -- it seeds a newcomer with everyone already here (all at once; one that\n  fails a seed is dropped, and its leave announced, since it was a member from the moment it was\n  added), announces the newcomer to all, and announces whoever drops out. A gadget with no presence passes none and gets a fan-out.\n- **`applyVersioned(items, batch, options?)`** -- the per-item concurrency rule over any\n  `{ id, version }`: an upsert whose `baseVersion` is stale is rejected with the authoritative\n  item, one for a deleted item is rejected as `missing` (the client may re-create it with\n  `baseVersion` 0), an identical upsert is no change, deletions are version-checked, and accepted\n  items take the next version. Returns the new item map, `accepted`, `deletedIds`, `conflicts`,\n  `changed` and a `status`; `operationStatus(changed, conflicts)` recomputes the status for a\n  gadget whose batch also carries ordering or a title. `normalizeBaseVersion` reads a base version\n  off the wire.\n- **`normalizeCollaborator`** bounds what a client says about itself before it is repeated.\n\nThe entry imports nothing from `cloudflare:workers`: the stubs are whatever the gadget's own\n`subscribe` receives, seen through the structural `SubscriberStub` (`dup`, `onRpcBroken`, the\ndisposer).\n\n## Client (`@gadgets/bundled-blueprints/libraries/sync/client`)\n\n- **`SaveScheduler`** -- `schedule(delay?)` debounces (`DEBOUNCE_MS`), `flush()` serializes and\n  a flush during a save folds into one more. The gadget supplies `save()`, which sends what is\n  dirty and answers `\"saved\"`, `\"conflict\"` (rebased, re-sent at once) or `\"pending\"` (a draft\n  waits on something else; the gadget schedules again), and `isDirty()`. A failed save is retried\n  after `retryDelay(failures)`: `RETRY_BASE_MS` doubling to `RETRY_MAX_MS`, reset by a success,\n  with the failure left on the status line until the retry starts. Statuses (`SaveStatus`) are\n  `saving`, `saved`, `conflict`, `offline`, with `synced` left for the gadget to report on a live\n  update. `readOnly` makes it a no-op.\n- **`PresenceRoster<Cursor>`** -- `apply(event)` takes join, cursor and leave events (ignoring the\n  client's own), keeping a position through a re-join; `people()`, `entries()`, `get(clientId)`;\n  `expire(now?)` drops anyone silent for `STALE_MS`. `Cursor` is the gadget's position type,\n  carried unread.\n- **`PresenceReporter<Update>`** -- `schedule()` sends at most once per `THROTTLE_MS`, `sendNow()`\n  sends at once, `startHeartbeat(onBeat?)` re-sends every `HEARTBEAT_MS` and then runs the\n  gadget's tick (expire the roster, redraw), returning the stop function.\n- **`createSubscriber(RpcTarget, callbacks)`** -- an instance of the host's `RpcTarget` whose\n  prototype carries each callback, since the RPC layer exposes prototype methods and never own\n  properties. The class comes from the gadget's bootstrap, which a library cannot import, so the\n  gadget passes it in the way it passes its `gadget` stub.\n- **`collaboratorFor(clientId)`** -- the guest identity a tab declares for itself.\n\nNothing on the client side touches the DOM; every module runs in Node under its tests.\n\n## Using it\n\nA gadget keeps its RPC surface and its stored shape; the library implements the loop. On the\nserver, every mutation runs through `queue.run`, one registry with hooks that build the gadget's own\n`presence` events holds the subscribers and broadcasts to them, and `applyVersioned` applies the\nupsert/delete rule with the gadget's sanitizer in front and its ordering after. In the browser, a\n`SaveScheduler` whose `save()` is the gadget's payload builder drives saving, a roster holds the\ncollaborators, a reporter sends presence and the heartbeat, and `createSubscriber` builds the\ncallbacks class. Each part stands alone; a gadget may use any subset.\n\n## Tests\n\n`__tests__/` runs in Node (`// @vitest-environment node`): one suite per module, plus\n`server.test.ts`, which assembles the server entry into the smallest gadget and drives it with\nstubs shaped like the RPC layer's.\n\n<!-- File: packages/bundled-blueprints/libraries/ui/README.md (Tokens: ~1329 | Category: plugin-manifest) -->\n# `ui` — DOM helpers for document-style gadgets\n\nThe Docs, Sheets and Slides blueprints share the same few helpers: an element builder, an SVG icon\nfactory, toolbar buttons and a dropdown, an in-page prompt (the sandboxed iframe blocks\n`window.prompt`), a save-status dot, relative timestamps, and the reading and downscaling of a\npasted image. This library is those helpers once, imported as\n`@gadgets/bundled-blueprints/libraries/ui/client`. It is DOM-only: nothing here talks RPC\nor touches storage, and nothing here knows what a click means -- every control takes the gadget's\naction as an argument.\n\n`@gadgets/bundled-blueprints/libraries/ui/server` exists so that every library has both entries and a\nblueprint's server may import any of them uniformly. It exports one flag, `clientOnly`, and nothing\nelse.\n\n## What it exports\n\n| Export | What it is |\n| --- | --- |\n| `el(tag, props?, children?)` | Element builder. `class`, `text`, `html`, a `style` object and `on*` listeners are the conveniences; `null`/`undefined`/`false` props are skipped, `true` is an empty attribute; children are nodes, strings, numbers or nothing, singly or in an array. |\n| `icon(paths)` | The 24-unit stroked SVG the toolbars draw, `aria-hidden`. |\n| `ICONS` | Path markup for the icons at least two gadgets share (undo, redo, bold, italic, underline, strike, textcolor, the three alignments, ul, ol, link, image, clear). A gadget spreads its own over it. |\n| `iconBtn(paths, title, onClick, label?)`, `segBtn(paths, title, onClick)` | `button.icon-btn` / `button.seg-btn`, `type=button`, titled and `aria-label`led, mousedown prevented so the editor's selection survives; `label` shows text instead of an icon. |\n| `group(prio, items, first?)` | `div.tgroup[.p1/.p2/.p3]` led by a `div.tdiv` divider unless `first`. |\n| `colorBtn(paths, title, defaultColor, onChange)` | `div.color-btn`: icon, `span.bar` swatch, native colour input; a pick recolours the bar and calls `onChange(hex)`. |\n| `customSelect({className?, title?, options, value, showUnlistedValue?, onChange})` | The toolbar dropdown: `button.cselect` plus a `div.cmenu` appended to the body while open; options may carry `style` (docs' font preview), `ex` (sheets' format example) or be `{sep: true}`. A value may be a string or a number, matched and reported as a string. Returns `{el, setValue, getValue}`. |\n| `promptInline(message, initial?, placeholder \\| options?)` | The dialog: resolves the text on OK/Enter, `null` on Cancel/Escape/backdrop. Options: `placeholder`, `okLabel`. |\n| `PROMPT_STYLES` | CSS for the dialog's classes, scoped under `.prompt-overlay`/`.prompt-card`, for a gadget whose stylesheet has none. |\n| `statusIndicator({kind?, text?, title?})` | `div.status` > `span.dot.<kind>` + text; `set(kind, text)` is a no-op when nothing changed. |\n| `relativeTime(epochMs, now?)` | `just now`, `3 min ago`, `2 h ago`, `5 d ago`, then the locale date. |\n| `prepareImage(file, options?)` | Data URL within `maxDimension` (1600px) and `maxDataUrlLength` (1.4M chars): a GIF within `maxGifDataUrlLength` (2.7M chars, about 2 MB) is kept animated, everything else becomes WebP (JPEG where WebP cannot be encoded) at falling quality, then falling size. `alt` defaults to `altFromFileName`. |\n| `readFileAsDataURL(file)`, `loadImage(src)` | Its two steps, for a gadget that wants one of them. |\n| `isImageFile`, `imageFilesFrom(transfer)`, `IMAGE_TYPES`, `DEFAULT_IMAGE_LIMITS`, `altFromFileName` | The rest of the image module. |\n\nEvery element is styled by the gadget's own stylesheet through the class names above; the library\nships no CSS but `PROMPT_STYLES`.\n\n## Behaviour worth knowing\n\n- **`el`** has no `data` prop; write a `data-x` attribute. `text` sets `textContent` before the\n  children are appended, so both may be given.\n- **`iconBtn`/`segBtn`** prevent mousedown so the editor's selection survives the click;\n  **`customSelect`**'s button does not, and a gadget whose selection has to survive it adds that\n  listener to the returned element, as Docs does on its colour button. The dropdown's label for a\n  value no option carries is the first choice's, or the value itself with `showUnlistedValue`\n  (Docs' font size, where a heading's size is off the list).\n- **`promptInline`**'s message is text, not markup.\n- **`prepareImage`** tries four encoding qualities before it shrinks the image, and keeps a GIF\n  animated only while its data URL fits `maxGifDataUrlLength`. A gadget with its own conversion\n  (Slides passes SVG through, keeps PNG as PNG and falls back to the original on any failure) uses\n  `readFileAsDataURL` and `loadImage` and does the rest itself.\n- **`relativeTime`** has no caller among the bundled blueprints yet.\n\n## Tests\n\n`__tests__/` covers every export in jsdom (`time.test.ts` in node). jsdom decodes no images and\ndraws on no canvas, so `images.test.ts` stands in an `Image` that reports the size written in its\nsource and a canvas whose data URL is as long as the requested quality and pixel count say.\n\n<!-- File: packages/bundled-blueprints/README.md (Tokens: ~3670 | Category: plugin-manifest) -->\n# Bundled blueprints\n\nThis package holds the blueprints that ship with this repo, the gadget libraries they import, and\nthe build that turns a blueprint directory into the files the Workshop backend installs. A fresh\ndeployment installs the blueprints into BLUEPRINTS KV and BLUEPRINT_CONTENT R2 on its first `/api`\nrequest, after which they are ordinary blueprints, and promotes them as its standard output formats.\n\"Bundled\" is what this package holds; \"format\" is a curation state an admin controls at runtime --\na bundled blueprint can be taken out of the formats menu, and an unbundled one promoted into it.\n\nNothing here is deployed on its own. The Workshop backend's `scripts/build-bundled-blueprints.ts`\nimports `src/` to generate the gitignored `src/generated/bundled-blueprints.ts` it compiles the\nblueprints into, and `pnpm import:bundled-blueprint` (the same package) writes into `blueprints/`.\n\n## Layout\n\n```\nblueprints/<name>/     one bundled blueprint, committed as reviewable source\n  blueprint.json       install ID, presentation, provenance, bindings, version, revision\n  files/               the gadget's code; may contain nested directories\n    README.md\n    client.ts          import { el } from \"@gadgets/bundled-blueprints/libraries/ui/client\"\n    server.ts          import { MutationQueue } from \".../libraries/sync/server\"\n    lib/protocol.ts    the document, operation and RPC types both sides share\n  __tests__/           vitest, repo-only; never part of the blueprint\nlibraries/<name>/      a gadget library: client.ts, server.ts, src/**, __tests__/** (see libraries/README.md)\nsrc/                   the build: source and archive reader (files.ts), manifest parser\n                       (manifest.ts), module generator (generate.ts); index.ts is what the backend imports\n__tests__/             the build's own tests\n```\n\n`files/` is the gadget's code. `blueprint.json` contains its install ID, presentation, provenance,\nbindings, blueprint `version`, and bundled `revision`. The build embeds the manifest and the files,\nas built, in the generated Worker module. No binary archive is committed, and none is built.\n\nInstalling a blueprint makes a git commit of its files, as publishing one from a Workshop does, and\nstores it as a packfile. The commit has no parent and a fixed author, date and message, so it\ndepends on the files and nothing else: every deployment that installs the same files installs the\nsame commit, and a blueprint whose files have not changed is the same commit however often it is\ninstalled. A bundled blueprint's versions are not chained to one another the way a published\nblueprint's releases are: each is a commit on its own.\n\n### Libraries\n\nA blueprint may import the shared **gadget libraries** in `libraries/` (see [its\nREADME](libraries/README.md)) by this package's name and the subpath its `package.json` exports:\n`@gadgets/bundled-blueprints/libraries/<name>/client` from its client, `.../<name>/server` from its\nserver. The build resolves each to the library's entry and inlines what the entry uses into the\nshipped `client.js` / `server.js`, as it does a `lib/` module, so the blueprint stays\nself-contained and a gadget created from it carries its own copy of the library as of its\ninstantiation. A client may not import a library's server side (it would drag a Durable Object into\nthe iframe), and a library, reached by that subpath, is the one thing an import may reach outside\n`files/` for. The importer has to be TypeScript: a JavaScript module ships as written, with nothing\nat runtime to resolve the package name against, so it cannot import a library, and the build rejects\nthe attempt. The Docs, Sheets and Slides blueprints are built on the `ui` and `sync` libraries, with\ntheir own domain code in `files/`.\n\n### TypeScript sources\n\n`files/` may be written in TypeScript: `client.ts` and `server.ts` are the entry points, and\n`lib/**/*.ts` holds the modules they import (by on-disk name, `./lib/protocol.ts`). The build bundles\neach entry with its imports into the `client.js` / `server.js` the blueprint ships -- readable\nrather than minified -- so the installed gadget, and the agent that later edits it, see exactly one\nJavaScript file per side, the same as for a blueprint written in plain JavaScript. Those `lib/`\nmodules are build input only and are not stored, and `.d.ts` files are dropped. The bundled Docs,\nSheets and Slides blueprints are written this way, each with a `lib/protocol.ts` holding the types\nits client and server share (imported type-only, so nothing of it ships).\n\nEverything else under `files/` (the README, assets) passes through unchanged, and may be imported\nfor its contents where the bundler can inline it: JSON is; a stylesheet is not, and\n`import \"./styles.css\"` fails the build (a gadget carries its CSS in the module that injects it). A file a bundle inlined is still shipped, because\nonly TypeScript is build input. A blueprint is written in TypeScript or in JavaScript, not both: a\n`.js` module in a tree that holds `.ts` is rejected, since it would ship as written beside bundles\nit cannot share code with. Non-module files pass through either way.\n\n`cloudflare:workers` is the only import left for the runtime to resolve, and only on the server:\nthe client is loaded as an ES module in a sandboxed iframe with nothing to resolve a bare import\nagainst, and the server as a Durable Object whose module map holds the gadget's own files and whose\nloader supplies no other `cloudflare:` module. Everything else a blueprint imports must be a file it\nowns or a library, so `import \"yjs\"` is a build error rather than a module that goes missing inside\nthe sandbox, and tree-shaking annotations are ignored, so a `\"sideEffects\": false` in an enclosing\npackage.json cannot drop a side-effect-only import from the bundle.\n\nThe build rejects a tree that would otherwise ship something other than what was written: a\nJavaScript module in a TypeScript blueprint, a `package.json` in a TypeScript blueprint (its `browser`\nfield or `imports` map would steer the bundler's resolution), an import of one of the blueprint's\nmodules that the bundler resolved to something other than the module TypeScript resolves it to (a\n`package.json` above the blueprint steering it, or an extensionless file the bundler takes first), a\n`.ts` file outside the entry/`lib/`\nlayout, TypeScript spelled `.tsx`/`.mts`/`.cts`, declarations included (neither runtime has a loader\nfor it), a `lib/` module\nno entry imports, an import that reaches outside `files/` by any path other than a library's exported\nsubpath (a relative or absolute path into `libraries/`, a `src/` module, the package root, a bare\nspecifier some `node_modules` resolves), a library import of the wrong side or of a library that does\nnot exist, a\ndynamic `import()` or `require()` of anything but a string literal, refused before the bundler runs\n(it would leave a computed path unchecked, and expand a template literal or concatenation into every\nfile the pattern matches, wherever that reaches), a module that binds the name `require` (the scan\nreads a bare `require(...)` as the module loader, as the bundler does only while the name is\nunbound), a generated `client.js`/`server.js` that collides\nwith a file or directory already in the tree, or a shipped JavaScript module that imports a library,\nwhich only the bundle can inline. Imports are read from each module's syntax tree with the\nTypeScript compiler, so a comment or a string can neither masquerade as one nor hide one.\n\n### Type checks and tests\n\n```\npnpm exec vp run -F @gadgets/bundled-blueprints build   # the five type-check programs\npnpm --filter @gadgets/bundled-blueprints test:run      # the blueprints', libraries' and build's tests\npnpm --filter @gadgets/bundled-blueprints test:watch\n```\n\n`pnpm build` type-checks all of it, through one config per set of globals -- the sets must not see\neach other, since a Durable Object has no `document`, iframe code cannot import\n`cloudflare:workers`, and neither has Node's `fs`:\n\n| config | covers | globals |\n| --- | --- | --- |\n| `tsconfig.client.json` | every `client.ts` (blueprints and libraries) and what it imports | DOM |\n| `tsconfig.server.json` | every `server.ts` and what it imports | Workers |\n| `tsconfig.tests.json` | the blueprints' and libraries' `__tests__/` and what they import | DOM + Node |\n| `tsconfig.server-tests.json` | `__tests__/server.test.ts` and `__tests__/*.server.test.ts`, the tests of a server side | Workers + Node |\n| `tsconfig.node.json` | `src/` and `__tests__/`, the build and its tests | Node |\n\nEach follows its entries' imports, so a `lib/` or `src/` module is checked under the globals of\nwhichever side imports it, and a module both sides import under both -- which is what keeps a\nshared module honest without forcing a server-only one to compile against the DOM. A library import\nis this package referring to itself by name, which tsc resolves through the `exports` in\n`package.json` with no `paths` block, so a blueprint is checked against the real signatures it\nimports.\n\n### What the build does not check\n\nThe bundler and the type check are kept in step by refusing what would let them diverge, not by\nsupporting every way TypeScript can be written. Write a blueprint the plain way -- `.ts` modules\nunder `files/`, relative imports spelled `./name.ts` or `./lib/name.ts`, libraries by the package\nsubpath -- and stay inside these limits, which the build does not enforce:\n\n- A `/// <reference types=\"…\" />`, `lib=\"…\"` or `path=\"…\"` directive loads types the side's tsconfig\n  deliberately leaves out (`types: []`, one `lib` per side), so `Buffer` type-checks in a client and\n  ships as a free global that throws in the iframe. Do not write one: a gadget module sees exactly\n  the globals its runtime supplies, and the type check is only as honest as that isolation.\n- A tree under `BUNDLED_BLUEPRINTS_DIR` is bundled but not type-checked; the programs above cover\n  this package's `blueprints/` and `libraries/` only. Type-check such a tree in its own workspace.\n- The comment naming each inlined library module is rewritten line by line, so the bundle is the\n  same wherever it is built; a template literal whose own line spells exactly such a path would be\n  rewritten with it.\n\nUnit tests of a blueprint's `lib/` modules, or of its server, live in the blueprint's `__tests__/`\nand run under `pnpm test` (jsdom by default; a pure module's or a server's test declares\n`// @vitest-environment node`). They import the module under test by on-disk name, e.g.\n`../files/lib/protocol.ts`, and load a fixture with `readFileSync`. A test of the server side is\nnamed `server.test.ts` or `<topic>.server.test.ts`, which is what puts it under the Workers types\nrather than the DOM's; at run time it gets `cloudflare:workers` as a stub of its base classes\n(`__tests__/stubs/cloudflare-workers.ts`), so a Durable Object can be constructed over in-memory\nstorage. The blueprints the build produces are still installed and inspected inside workerd by the\nWorkshop backend's suite. Only `blueprint.json` and `files/` are read by the build, so `__tests__/`\n(and anything else beside them) is repo-only and never part of the blueprint.\n\n`blueprintId` is the install key. Never change it after deployment: the new ID would install a\nsecond blueprint while the old one remained. (TODO: the bundled IDs keep the historical `format.`\nprefix -- `format.document`, `format.spreadsheet`, `format.slides` -- for that reason; renaming them\nneeds an install-time migration keyed on the old id.) `version` is the blueprint's published content\nversion, a counter for display: the content itself is stored under the commit of its files. The\nbuild fingerprints the files and the rest of the manifest, so direct edits under `files/` reinstall\nautomatically. `revision` remains an explicit reinstall trigger and is bumped by the importer.\n\n## Editing presentation\n\nEdit `blueprint.json` and rebuild. Changes to title, description, output, or author are included in\nthe install fingerprint and do not need a `revision` bump.\n\n## Updating code\n\nBuild the blueprint in a Workshop, export it, then import the export, from `packages/workshop-backend`:\n\n```\npnpm import:bundled-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document\n```\n\nThe importer replaces `files/` and leaves anything beside `blueprint.json` and `files/` alone,\nupdates archive-owned metadata (`created`, `version`, `lastUpdated`, and `bindings`), bumps\n`revision`, rebuilds the backend's `src/generated/bundled-blueprints.ts`, and reports changed files\nand bindings. An export the build rejects is refused before it replaces\nanything. Review the resulting source diff normally.\n\nAn export is a `.gadget` archive in one of two versions, and the importer reads both. Version 1\nholds a snapshot of the blueprint's files. Version 2, which a Workshop writes for anything published\nsince blueprints became git commits, holds a git packfile, and the importer has `git` unpack it.\nOnly the files are taken from either: the bundled blueprint's commit is made from them at install,\nnot copied from the export.\n\nAn export contains the bundled JavaScript, so importing one over a TypeScript blueprint replaces its\nsources with the built `client.js` / `server.js`. Edit a TypeScript blueprint in the repo instead,\nand use the Workshop only to try the result.\n\n## Adding a format\n\n```\npnpm import:bundled-blueprint ~/Downloads/Brief.gadget --new acme-brief\n```\n\nThis extracts the files and writes a valid scaffolded `blueprint.json`. Before deploying, replace\nthe scaffold description and review `output`. Prefer a generic `output.id` such as `document`; the\nOutputs page uses it to group related formats.\n\n## Shipping your own formats\n\n`BUNDLED_BLUEPRINTS_DIR` points the build at another directory in this same extracted layout. It is\nresolved against `packages/workshop-backend`, where the build runs (the variable used to be called\n`FORMAT_BLUEPRINTS_DIR`; that name is no longer read, so a deployment setting it builds the bundled\nset instead):\n\n```\nBUNDLED_BLUEPRINTS_DIR=../../acme-formats pnpm exec vp run build\n```\n\nThe named directory replaces this set rather than extending it. It can be empty to ship no bundled\nformats. The import command honors the same variable. Keeping deployment-owned formats outside this\nrepo avoids modifying it when it is consumed as a submodule.\n\nA `BUNDLED_BLUEPRINTS_DIR` tree may be written in TypeScript like the blueprints here, and the build\nbundles it the same way: a syntax error or an import that does not resolve fails the build, and it\nimports the gadget libraries by the same package name, which the build resolves to this package's\n`libraries/` without the tree having installed anything. It is not type-checked, though. This\npackage's `tsconfig.*.json` programs are static and cover only `blueprints/` and `libraries/`, so a\ntree elsewhere needs its own `tsc` run in the repository that owns it, with\n`@gadgets/bundled-blueprints` linked into that workspace the way `@gadgets/scripts` already must\nbe, so its editor and `tsc` resolve the same `exports`; or it can stay JavaScript.\n\nDirectories using the previous `<name>.gadget` plus `<name>.json` layout remain supported, so an\nexisting deployment can update this repo without coordinating a format conversion. Importing a new\nexport into one of those entries migrates that pair to the extracted layout automatically. That is\nalso the only way to update one: the layout holds version 1 archives, and the build refuses a\nversion 2 export dropped in a `<name>.gadget`'s place.\n\nAdministrators can also publish and promote ordinary blueprints at runtime instead of rebuilding a\ndeployment.\n\n<!-- File: packages/gatekeeper-cloudflare/README.md (Tokens: ~2393 | Category: plugin-manifest) -->\n# Gatekeeper Cloudflare\n\nThis package provides Cloudflare OAuth integration for Gadgets. It serves three purposes:\n\n- **Sign-in:** when `cloudflare` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Cloudflare\" appears on the login page. The grant reads the account email (verified by Cloudflare,\n  via the `/user` API), which becomes the user's identity. Cloudflare sign-in also establishes the\n  persistent billing-only connection described below.\n- **AI Gateway billing:** when a user connects Cloudflare or signs in with it, the billing scopes are\n  requested and the connection persists. The Workshop then reads a usable access\n  token from it (`getUsableAccessToken`) to power the [AI Gateway billing](../../docs/ai-gateway-billing.md)\n  flow — reading the credit balance and routing BYOK inference through the account's default AI\n  Gateway.\n- **Workers Observability:** gadgets can receive read-only access to logs, events, invocations,\n  aggregate metrics, and traces either across an account or restricted to one Worker. Every result\n  is authorized as an observation, and Worker bindings inject an immutable service filter and\n  defensively discard foreign-service events. Distributed trace summaries are account-only because\n  their names, timing, services, and counts describe the whole cross-service trace; a Worker binding\n  can still retrieve its own events for a known trace ID.\n\nObservability connections request `workers-observability.read`. The OAuth client must allow that\nscope or Cloudflare will omit/reject it. Existing billing-only connections can add the grant when the\nuser first selects an observability resource. Cloudflare exposes account and Worker resource choices,\nbut both map to this one indivisible OAuth scope; resource bindings provide the finer capability\nboundary after connection.\n\nWorkers telemetry is retained by Cloudflare for at most seven days. Queries default to the last hour,\nand the Worker picker searches the full retention window. Suggested bindings are\n`CLOUDFLARE_OBSERVABILITY` for account access and `WORKER_OBSERVABILITY` for one Worker.\n\n### Sharing a gadget that reads telemetry\n\nA binding is not transferable. When a gadget with an observability binding is shared, each\ncollaborator is admitted only if **their own** connected Cloudflare account can read that resource\n(`addObserver` checks it against their credentials, not the owner's). A collaborator without access is\nrefused, and a verification that fails for any other reason — a 5xx, a transport error — is also a\nrefusal rather than an admission.\n\n### What a Worker binding trusts, and what it re-checks\n\nThe scope filter is prepended to every query, but a filter the provider *accepted* is not evidence it\n*applied* it — three separate behaviours here return wrong-but-plausible data with no error. So events\nare re-filtered on the way out, and a single foreign event is treated as proof the filter was dropped:\nthe provider's `count` is withheld (it would be a count of the whole account's matching telemetry) and\nthe event is logged at `error`. `statistics` is kept, because it describes what our query cost rather\nthan how much matched, and callers are told to read it for cost.\n\nThe re-check is deliberately not a hard failure. The events returned are filtered and therefore safe,\nand this provider has surprised us often enough that turning a hypothetical disclosure into a\nguaranteed outage would be the worse trade.\n\n`calculate()` is the exception, and knowingly so: an aggregate cannot be un-mixed, so there is no\nsecond line of defence to add. It rests entirely on the injected filter. The fix that would work —\ngrouping by `$metadata.service` and keeping this binding's own group, whose value *is* the correctly\nscoped answer even for a median — changes `limit` and `orderBy` semantics for every caller, so it is a\nfollow-up rather than a footnote.\n\n### Why discovery sometimes costs a query\n\nCloudflare's `telemetry/keys` and `telemetry/values` endpoints ignore the `filters` array they accept:\nverified against a live account, they answer for the whole account no matter what is passed. A\nWorker-scoped binding therefore cannot use them — they would disclose every field name and value in\nthe account. Whenever a discovery call has to be constrained (a Worker binding, or any caller-supplied\nfilter), the answer is derived instead from a filtered `telemetry/query` events sample, which the\nprovider does filter correctly. Unconstrained account-wide discovery still uses the cheap endpoints.\n\n> Unresolved: whether the `workers-observability.read` scope alone is accepted, or whether Cloudflare\n> requires the Workers Observability **Write** permission to read telemetry. This has not been\n> verified against a live token; if reads 403 on a correctly-scoped grant, that is the first thing to\n> check.\n\n### Two names for one field\n\nA log's own structured fields are returned nested under `source` but are **indexed under their bare\nname**: `logger.warn(msg, {event: \"x\"})` comes back as `source.event` and is queried as `event`.\nConfirmed both ways — `telemetry/keys` on a live account reports `event`/`component`/`level` and no\n`source.*` key at all, and the [Workers Logs\ndocs](https://developers.cloudflare.com/workers/observability/logs/workers-logs/) show\n`console.log({user_id: 123})` being filtered as `user_id`.\n\nThis matters because the provider accepts an unknown filter key, matches nothing, and returns an empty\npage with no error. So discovery reports indexed names (`observability-discovery.ts`), and\n`observabilityFieldKey` additionally accepts a `source.`-prefixed key as an alias wherever a caller\nnames a field — filters, `calculations`, `groupBys`, `listValues` — because copying a path out of a\n`listEvents` result is the obvious thing to do and used to fail silently.\n\n`openid` is intentionally **not** requested — the Cloudflare dashboard OAuth client isn't permitted\nthat scope; identity comes from the `/user` API (`user-details.read`).\n\n### Why an error's text never reaches the log\n\n`summarizeFilter` deliberately keeps filter *values* out of the audit trail — they are caller text.\nBut a provider error message can quote that same value straight back, so logging the message would\nreadmit through the error path exactly what the audit path excludes. `CloudflareObservabilityApiError`\ntherefore carries Cloudflare's numeric `codes` alongside the message: the request log names the\ncodes, and the message travels only to the caller who caused it.\n\nThe codes are not the discriminator, though — Cloudflare can return a message with no numeric code\nat all, so the error records separately whether the message is *its* or *ours*, and only ours is\nlogged. A provider failure carrying no codes is therefore logged as a bare status, which is the\nfail-closed answer: the status still says what happened without quoting anyone's filter back.\n\n### Listing accounts is a walk, not a request\n\n`/accounts` is paginated and defaults to **20** per page, so a single GET silently returns a truncated\nlist — an account past the first page simply cannot be picked, with nothing to indicate why. The\naccount picker's substring match also stays client-side: `name` is the only documented server-side\nfilter and whether it matches exactly or by substring is not specified, so pushing it down would trade\na visible truncation for an invisible one.\n\n## Setting Up Cloudflare OAuth Credentials\n\nYou need a Cloudflare dashboard OAuth client (client id + secret). The dashboard OAuth endpoints and\nscopes are hardcoded in `src/oauth.ts`, so you only configure the client id/secret and register the\nredirect URI. Ensure the client's scope allowlist includes `workers-observability.read` when this\ndeployment offers Workers Observability resources.\n\n### Step 1: Register the redirect URI\n\nThe gatekeeper's OAuth redirect URI is:\n\n```\n${BASE_URL}/oauth\n```\n\nwhere `BASE_URL` defaults to `http://localhost:8787/gatekeeper/cloudflare` in dev — i.e. the full\nredirect URI is:\n\n```\nhttp://localhost:8787/gatekeeper/cloudflare/oauth\n```\n\nRegister **exactly** this (replace the host with your `PUBLIC_BASE_URL` when not running locally) as\nan allowed/pre-registered redirect URL on the Cloudflare OAuth client. If it isn't registered you'll\nget an `invalid_request` error: _\"the 'redirect_uri' parameter does not match any of the OAuth 2.0\nClient's pre-registered redirect urls.\"_\n\n### Step 2: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-cloudflare/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\nIn local dev, `run-dev-server.ts` will also seed these from `CLOUDFLARE_OAUTH_CLIENT_ID` /\n`CLOUDFLARE_OAUTH_CLIENT_SECRET` if you'd rather set them in the root `.dev.vars`. A per-package\n`.env` takes precedence and keeps the credential with the gatekeeper that uses it.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable Cloudflare sign-in / billing\n\nTo offer \"Continue with Cloudflare\" on the login page, add `cloudflare` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nThe order controls the order of the login buttons. For the AI Gateway billing / top-up flow, also\nset `ENABLE_CLOUDFLARE_LIMITS=true` (see [AI Gateway billing](../../docs/ai-gateway-billing.md)); a\nuser enables billing by connecting Cloudflare, which requests the billing scopes\n(`offline_access aig.read aig.run user-details.read account-settings.read`). Connecting all\nCloudflare gadget resources also requests `workers-observability.read`.\n\n### Step 4: Verify Setup\n\n1. Start the application in dev mode (see the root README.md).\n2. On the login page, click **Continue with Cloudflare**.\n3. A pop-up opens to the Cloudflare authorization page; approve it.\n4. The pop-up closes and you're signed in, identified by your Cloudflare account email.\n5. To use AI Gateway credits, open **Usage & billing** in settings and **Connect Cloudflare** (this\n   requests billing scopes only).\n\n## Troubleshooting\n\n### \"redirect_uri ... does not match any of the ... pre-registered redirect urls\"\n\nThe redirect URI isn't registered on the OAuth client. Register exactly\n`http://localhost:8787/gatekeeper/cloudflare/oauth` (or your `PUBLIC_BASE_URL` equivalent) — no\ntrailing slash, `http` not `https` for localhost.\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` / `CLIENT_SECRET` are missing. Ensure they're set (per-package `.env` or seeded from the\nroot `.dev.vars`), then restart the dev server.\n\n<!-- File: packages/gatekeeper-confluence/README.md (Tokens: ~2156 | Category: plugin-manifest) -->\n# Confluence (Cloud) gatekeeper\n\nConnects Gadgets to Atlassian Cloud [Confluence](https://www.atlassian.com/software/confluence)\n(`*.atlassian.net`) using OAuth 2.0 (3LO). It exposes spaces, pages, blog posts, comments, labels,\nand attachments through a capability-based Session API, with page/blog bodies converted to and from\nMarkdown. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_CONFLUENCE` binding.\n\nOnly Atlassian **Cloud** is supported; there is no Server / Data Center adapter.\n\n## What it provides\n\nThree connectable resource granularities:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| **Site** — search and open any space/page/blog post the account can access | `https://*.atlassian.net/wiki` | `ConfluenceSite` |\n| **Space** — list/search/create pages and blog posts within one space | `https://*.atlassian.net/wiki/spaces/:spaceKey` | `ConfluenceSpace` |\n| **Page or blog post** — read/edit the body, manage labels/comments/attachments, and (pages only) read/create child pages | `https://*.atlassian.net/wiki/spaces/:spaceKey/pages/:pageId/*` | `ConfluenceContent` |\n\nPages and blog posts share the `ConfluenceContent` session type (distinguished by\n`ContentMetadata.type`); child-page methods are page-only.\n\nA single account-level grant can span multiple sites. `getGatekeeperClassFor()` resolves a URL's\nhost to one of the connection's accessible sites (its cloud ID) — the one chokepoint enforcing site\naccess.\n\nSee `src/types.d.ts` for the full, agent-facing Session API.\n\n## Setting up an Atlassian OAuth 2.0 app\n\n1. Go to the [Atlassian Developer Console](https://developer.atlassian.com/console/myapps/) and\n   create an **OAuth 2.0 integration**.\n2. Add the **Confluence API** to the app and enable **both** scope families below (see\n   `CONFLUENCE_SCOPES` in `confluence-api.ts`). The console has separate **Granular scopes** and\n   **Classic scopes** tabs; this gatekeeper needs scopes from each because it uses the v2 API for\n   most operations and falls back to v1 for a few (search, label writes, attachment upload,\n   restore).\n\n   **Granular scopes** (authorize the v2 API — v2 doesn't accept classic scopes):\n   - `read:space:confluence`\n   - `read:page:confluence`, `write:page:confluence`, `delete:page:confluence`\n   - `read:blogpost:confluence`, `write:blogpost:confluence`, `delete:blogpost:confluence`\n   - `read:comment:confluence`, `write:comment:confluence`, `delete:comment:confluence`\n   - `read:label:confluence`, `write:label:confluence`\n   - `read:attachment:confluence`, `write:attachment:confluence`, `delete:attachment:confluence`\n   - `read:user:confluence`\n\n   **Classic scopes** (authorize the v1 fallbacks — v1 doesn't accept granular scopes):\n   - `search:confluence` (CQL search)\n   - `read:confluence-content.all`, `read:confluence-content.summary` (search results / content reads)\n   - `read:confluence-space.summary`\n   - `write:confluence-content` (label add/remove, restore-from-trash)\n   - `write:confluence-file` (attachment upload)\n   - `readonly:content.attachment:confluence` (attachment download)\n   - `read:confluence-user`\n\n   **Account-level:**\n   - `read:me` (authorizing user's identity)\n   - `offline_access` (required for refresh tokens)\n3. Under **Authorization**, set the callback URL to `<BASE_URL>/oauth`:\n   - `http://localhost:8787/gatekeeper/confluence/oauth` for local dev\n   - `<PUBLIC_BASE_URL>/gatekeeper/confluence/oauth` otherwise\n4. Copy the app's **Client ID** and **Secret**.\n\n> **API version:** this gatekeeper targets the Confluence **v2** REST API (`/wiki/api/v2/...`),\n> which Atlassian now requires (the v1 content/space endpoints have been removed) — and which is\n> why the **granular** scopes are needed. A few capabilities with no v2 equivalent (CQL search,\n> label add/remove, attachment upload, restore-from-trash) fall back to v1 endpoints, which need\n> the **classic** scopes, and degrade with a clear error if Atlassian has removed them too. The app\n> therefore must enable scopes from both the Granular and Classic tabs.\n\n## Configuring credentials\n\nFor local development, set these in the repo-root environment (e.g. `.dev.vars`) so the dev server\ninjects them into this Worker:\n\n```\nCONFLUENCE_CLIENT_ID=your-client-id\nCONFLUENCE_CLIENT_SECRET=your-client-secret\n```\n\n`run-dev-server.ts` maps `CONFLUENCE_CLIENT_ID` / `CONFLUENCE_CLIENT_SECRET` into the Worker's\n`CLIENT_ID` / `CLIENT_SECRET` vars. For production, set `CLIENT_ID` and `CLIENT_SECRET` as secrets\non the deployed Worker and set `BASE_URL` to the public gatekeeper URL.\n\n`providesAuth` is false: Confluence is a data connector, not a sign-in method. Wiring it up as a\n\"Sign in with Atlassian\" provider (using the verified `/me` email) is a possible fast follow.\n\n## How the connect flow works\n\n1. The user starts a connection; `connectAccount()` creates a `UserAccount` Durable Object and\n   returns a URL of the form `<BASE_URL>/<doId>/<nonce>`. The flow uses a two-phase nonce\n   (initiation → OAuth).\n2. Visiting that URL redirects to `auth.atlassian.com` to authorize the requested scopes.\n3. Atlassian redirects back to `<BASE_URL>/oauth`; the gatekeeper exchanges the code for access +\n   rotating refresh tokens, lists the account's accessible Confluence sites\n   (`/oauth/token/accessible-resources`), reads the authorizing user's identity (`/me`), and stores\n   them.\n4. Each connected resource routes API calls through\n   `https://api.atlassian.com/ex/confluence/<cloudId>/wiki/...`. Access tokens are short-lived and\n   refreshed automatically (proactively before expiry and again on a 401); each refresh persists\n   the **new** rotating refresh token.\n\nA connection is created either by pasting a Confluence URL into the chat, or from the Connections\nUI.\n\n## API\n\nPage/blog bodies are exchanged as **Markdown** (best-effort conversion to/from Confluence \"storage\nformat\" XHTML; see `confluence-markdown.ts`). Highlights:\n\n- `ConfluenceSite`: `listSpaces`, `getSpace`, `getContent`, `search` (text or raw CQL),\n  `getCurrentUser`\n- `ConfluenceSpace`: `listPages`, `listBlogPosts`, `getContent`, `search`, `createPage`,\n  `createBlogPost`\n- `ConfluenceContent`: `getMetadata`/`getContent`, `setContent`/`appendContent`/`setTitle`,\n  `listChildPages`/`createChildPage`, labels, comments, attachments\n  (`downloadAttachment` is capped at 16 KB), `trash`/`restore`\n\nEndpoint split: v2 for spaces, pages, blog posts, child pages, and reading comments/labels/\nattachments; v1 (with graceful degradation) for CQL search, label add/remove, attachment upload,\nand restore-from-trash.\n\n## Approvals, caching & simulation\n\nEvery read calls `ApprovalQueue.authorizeObservation()`; every side-effecting operation\n(create/edit page or blog post, comments, labels, attachment upload, trash/restore) is recorded as\na pending action and submitted via `submitAction()` — nothing reaches Confluence until the Overseer\ncalls `applyAction()`. Reads simulate pending (unapproved) writes so the agent can keep working\nbefore approval (provisional IDs for created content, body/title/label/comment overlays, trashed\nstate). `rejectAction()` / `revertAction()` discard or undo. Content responses are cached in DO\nstorage with a short TTL and invalidated on write. This logic lives in `confluence-actions.ts`.\n\n## Resource pickers\n\nThe Connections UI shows space / page-or-blog / site pickers supplied by\n`startResourceConfigurator()`. Pasting a resource URL — or an agent's\n`requestConnection(resourceUrl)` — also works and pre-fills the picker. For multi-site Atlassian\naccounts the space/page pickers search across **all** accessible sites (each option's value carries\nthe correct site), so pasting a URL works for any connected site too.\n\n## Notes and current limitations\n\n- Body conversion between Confluence storage format and Markdown is best-effort; unsupported macros\n  degrade to a labeled placeholder.\n- `author` / `lastUpdatedBy` expose only an `accountId` (the v2 content API returns IDs, not user\n  objects).\n- CQL search, label add/remove, attachment upload, and restore-from-trash rely on v1 endpoints; if\n  Atlassian removes them, those operations surface a clear \"not available\" error.\n\n## Development\n\n- `pnpm exec vp run -F @gadgets/confluence-gatekeeper build` — build the configurator UIs (`build:configurator`, which this task depends on) and type-check (`tsc`). A Vite+ task rather than a script, so `pnpm --filter` cannot see it.\n- `pnpm --filter @gadgets/confluence-gatekeeper test:run` — run unit tests (URL/CQL parsing, Markdown conversion, v2 converters, action simulation).\n- The Worker is run via the root `pnpm dev-server`, not directly.\n\n<!-- File: packages/gatekeeper-context/app/skills/README.md (Tokens: ~2081 | Category: plugin-manifest) -->\n# Skills Navigator Direction and Compatibility\n\nThe target information model is deliberately flat: a collection contains atomic skills. Each skill\nencompasses its `SKILL.md` manifest and all related agentic standards files. Those files may be\nstored together in a skill directory, but the navigator presents the complete bundle as one skill,\nnot as a file hierarchy.\n\nSkill titles are presented as human-readable title case. Creation and rename accept that display\nform, then derive the lowercase, hyphen-separated `name` required by the Agent Skills specification\nfor the manifest metadata and directory name.\n\nOrganizational directories are being abandoned. The navigator does not let users create, rename, or\nmove them, and new product behavior must not depend on adding more directory hierarchy. Directory\nnodes remain only to preserve collections that already use older nested layouts.\n\nSelecting a skill still opens the existing collection editor, which remains the general-purpose\ndocument editing surface. Every reader can also open a collection from its row menu, so collections\nthat contain only ordinary documents remain reachable.\n\n## Legacy directories\n\nExisting organizational directories remain usable for backwards compatibility: skills can be\ncreated in them and moved into, out of, or between them, and the directories themselves can be\ndeleted. They cannot be created, renamed, or moved through the navigator, because the forward model\ntreats only skill bundles as movable units.\n\nLegacy directories can also be deleted. Deleting one removes its complete document subtree,\nincluding files the skills-only projection does not display. None of these compatibility behaviors\nmake directories part of the forward-looking data model: no operation can create another\norganizational directory.\n\n## Mutation boundaries\n\n- Users can create, rename, move, and delete complete skill bundles, but cannot create, rename, or\n  move organizational directories.\n- Skill mutations use the skill-specific RPC methods rather than composing generic document calls in\n  the client. Each operation validates the manifest and applies all storage changes atomically.\n- Renaming or moving a non-root skill operates on its complete directory, preserving supporting\n  files. Destination conflicts fail rather than overwrite existing documents.\n- A root-level `SKILL.md` is supported, but it has no exclusive directory subtree: moving, renaming,\n  or deleting it affects only that manifest. Treating every other root document as its support file\n  would risk moving or deleting unrelated skills and documents.\n- A destination must be the collection root or an existing legacy directory outside every skill\n  bundle. Skills cannot be nested inside other skills, even if an RPC caller supplies a path the UI\n  would not offer.\n\n## Permissions and sources\n\nCollection actions follow `getContextCollectionPermissions`. Skill and directory actions are offered\nonly when the account can edit the collection and the collection is web-backed. Git-backed\ncollections remain read-only at the skill level, but editors, owners, and admins can still edit\ntheir collection settings, manage Git tokens, and refresh them. Sharing is offered on editable\nprivate collections, and deleting the whole collection only to owners and admins. Viewers keep\nnavigation, previews, downloads, and opening the collection. Otherwise read-only collections expose\nno mutation actions and are not offered as Add Skill targets. If loading a collection's documents\nor permissions fails, that collection fails closed as non-writable in the current view.\n\nCollection labels come from the viewer's live membership: deployment-public collections show\nOrganization, an owned collection shows Private or, once anyone else can read it, Shared, and a\ncollection someone else shared shows Shared with you and the viewer's role. Collection titles need\nnot be unique. Labels describe access, not identity: same-titled collections can also have identical\nlabels. UI selection and API operations use collection IDs rather than titles.\n\n## Sharing\n\nThe Share dialog adds people through the Workshop's host-owned person picker\n(`useSelectUsers()` in `bridge.ts`). Each picker session gets its own receiver that fixes the\ncollection and the role chosen when the picker opened, and shares with each picked person as soon as\nthey are delivered; a backend refusal rejects that delivery, so the picker shows the message in a\nstatus notice that names the person. Cancelling the picker does not undo shares already delivered.\nStopping the wait is not a sharing error and does not undo a delivery already running. The dialog\nreloads sharing and permissions when the picker closes and again when a late delivery settles,\nincluding rejected deliveries that may have committed a grant before cleanup failed. A delivery that\nsettles after the Share dialog closes also refreshes the parent collection metadata or listing.\n\nMutation controls remain disabled until refreshed sharing and permissions arrive together. If that\nreload fails, the previous rows remain visible for context but cannot be used to mutate access;\n**Retry** must successfully refresh them first. Backend cleanup errors remain visible independently\nof reload failures.\n\n`Share “<collection title>”` starts with an **Add users with role** dropdown and **Select users**\nbutton. Compact access rows show the current account as **You** with a generic profile icon, then\n**Anyone in organization** (No access or Can view), then a dashed divider and the other members.\nThe add-users role and access controls use ghost-button dropdown menus with right-aligned selection\ncheckmarks; member menus contain role changes and a separate destructive **Remove access** action,\nor **Leave collection** for the current account. The last owner cannot leave or be demoted. A neutral\n**Done** button is the only footer action and stays visible while the member list scrolls. The\nWorkshop's person picker uses the same neutral **Done** action.\n\nAnyone a collection was shared with as a viewer or editor can leave it from the collection's row menu,\nincluding viewers, who cannot open the Share dialog. Owners leave from the Share dialog, which knows\nwhether another owner remains. Both surfaces open the same confirmation before leaving. It warns\nthat the last member leaving an ownerless collection permanently deletes the collection and its\ndocuments, even when organization-wide viewing is enabled. Leaving reconciles the parent even if\nGit token cleanup rejects after the membership was removed, while retaining the cleanup error rather\nthan presenting a rejected operation as success.\n\nThe server remains the authority for every mutation. The client-side checks control affordances and\nprovide earlier feedback; they are not the security boundary.\n\n## Move limitations\n\nDrag-and-drop is intentionally limited to one collection. Within that collection, existing legacy\ndirectories remain valid destinations for the compatibility reasons above. A same-collection move\ncan update the skill directory and all supporting documents in one Durable Object storage\ntransaction.\n\nCross-collection moves are disabled. Implementing one as separate client-side copy and delete calls\ncould overwrite concurrent edits or leave duplicated or partially moved data after a failure. A\nfuture backend operation must authorize both collections, reject read-only sources and destinations,\npreserve the complete skill subtree, fail on destination conflicts, and define recoverable or atomic\nsemantics across the two collection Durable Objects before the UI enables these drops.\n\n## Upload limitations\n\nThe Skills Navigator accepts standalone Markdown files and folders. Each loose `.md` or `.markdown`\nfile becomes one skill. A `SKILL.md` file defines a bundle and imports the other files below its source\ndirectory as related skill files; nested bundles are imported as separate top-level skills. Browser\nfolder selection is based on the non-standard but widely supported `webkitdirectory` input. Chrome's\ndirectory APIs fail or terminate the management app's opaque-origin iframe, so complete folders should\nbe selected with the folder picker. Supporting folder drops requires reading them in the Workshop host\nand passing the files into the sandboxed app.\n\nSkill manifests are created through the skill-specific RPC, but there is currently no backend RPC for\natomically creating a complete bundle. Supporting files are written only after the manifest succeeds.\nIf one of those writes fails, the user is told which skill was only partially uploaded, but the created\nmanifest and any successful related files remain. A future bundle-oriented RPC is required for true\nall-or-nothing uploads.\n\n## Skill upload format contract\n\nThe upload dialog accepts [Agent Skills](https://agentskills.io/specification) and\nlegacy Markdown. Import acceptance is deliberately broader than validation of the\nstored `SKILL.md`.\n\n### Recognizing metadata\n\nAn import opts into skill metadata only when it begins with a complete `---`\nfenced block that parses as a YAML mapping containing `name` or `description`.\nThe delimiter must be unindented; a BOM, CRLF, and trailing fence whitespace are\naccepted. Indented delimiters within YAML block scalars do not close the block.\n\nEverything else is legacy Markdown, including ordinary text, horizontal rules,\nunclosed fences, invalid YAML, sequences, and mappings without skill metadata keys.\nThese inputs are ambiguous, so preserve the entire source as instruction text\nrather than guessing which part to discard. This also applies to imports named\n`SKILL.md`; that filename groups supporting files but does not require a legacy\nimport to already be a valid skill.\n\n### Review and conversion\n\n- Recognized metadata supplies the editable name and description. Missing or\n  non-string fields are left blank for correction; invalid strings are not replaced\n  with inferred defaults. Extra metadata is retained when writing the manifest.\n- Legacy Markdown gets a filename-derived name and a suggested description for\n  user review. Conversion adds new frontmatter and preserves the entire original\n  instruction text, including leading whitespace and line endings.\n- Recognized frontmatter is serialized after edits, but its Markdown body is\n  preserved verbatim. The metadata syntax itself need not round-trip byte-for-byte.\n- The existing review form checks the final name and description before upload;\n  the server's `parseSkillManifest` validates the resulting stored manifest.\n\n`readSkillUploadSource` in `skillUpload.ts` is the single recognition decision used\nby both inference and conversion. New import behavior should be expressed here\nand covered by the table-driven round-trip tests, rather than added as an independent\ndelimiter heuristic in a caller.\n\nThis is not a claim of full specification validation: the existing server validates\nrequired fields, permits names differing from the parent directory, and does not\nvalidate every optional specification field.\n\n<!-- File: packages/gatekeeper-email/README.md (Tokens: ~1411 | Category: plugin-manifest) -->\n# Gatekeeper Email\n\nThis package provides an email receiving gatekeeper for Gadgets. It allows a Gadget to receive inbound emails at an address like `<name>@<host>` via a hook.\n\nUnlike most gatekeepers which connect to external services, this gatekeeper *is* the service -- it implements a Cloudflare Email Worker that receives mail directly.\n\n## How It Works\n\nThe email gatekeeper uses the URL scheme `http://localhost:8787/gatekeeper/email/mailbox/<name>` to represent the email address `<name>@<host>`, where `<host>` is the domain configured to route email to this worker.\n\nWhen a Gadget is connected to an email address:\n\n1. The Gadget's code exports a `WorkerEntrypoint` that implements the `EmailHook` interface.\n2. The coding agent calls `setBindingHook` to connect the hook to the binding.\n3. The gatekeeper stores the hook reference in a Durable Object keyed by the email username.\n4. When an email arrives, the Email Worker routes it to the appropriate DO, which invokes the Gadget's hook with the parsed email content.\n\nEmails are parsed using [postal-mime](https://www.npmjs.com/package/postal-mime), so the hook receives structured data (from, to, subject, text body, HTML body, attachments) rather than raw MIME.\n\n## Creating a Binding\n\n1. Start the dev server (see root README).\n2. Create or open a Gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose **Email Mailbox**.\n6. Enter the mailbox local part, e.g. `myinbox`.\n7. This represents the email address `myinbox@<host>`.\n8. The binding will appear as `EMAIL` (the suggested name).\n\nMailbox names are canonicalized to lowercase. They may contain letters, numbers, dots, underscores, plus signs, or hyphens, and cannot start or end with a dot or contain consecutive dots.\n\nThe binding provides an `EmailSession` interface with a single method:\n\n```typescript\ninterface EmailSession {\n  getAddress(): Promise<string>;  // e.g. \"myinbox@example.com\"\n}\n```\n\nTo actually receive emails, the Gadget must implement a hook. Ask the coding agent to set up a hook for the email binding, or do it manually:\n\n```typescript\n// In the Gadget's code:\nimport { WorkerEntrypoint } from \"cloudflare:workers\";\n\nexport class MyEmailHandler extends WorkerEntrypoint {\n  async receiveEmail(email) {\n    // email has: from, to, cc, subject, date, text, html, attachments\n    console.log(`Got email from ${email.from.address}: ${email.subject}`);\n    // Store it, process it, etc.\n  }\n}\n```\n\nThen prompt your coding agent to use the `setBindingHook` tool to connect it:\n- Binding: `EMAIL`\n- Export name: `MyEmailHandler`\n\n## Local Development\n\nIn local development, the gatekeeper is served under the path `/gatekeeper/email` on `localhost:8787`. We don't actually support receiving real SMTP email locally.\n\n### Sending a Test Email\n\nIn local dev, wrangler exposes a `/cdn-cgi/handler/email` endpoint that simulates inbound email. You can POST raw email content to it using curl.\n\nWith the dev server running (`pnpm run dev-server` from the repo root), send a test email:\n\n```bash\ncurl -X POST 'http://localhost:8787/cdn-cgi/handler/email' \\\n  --url-query 'from=sender@example.com' \\\n  --url-query 'to=myinbox@example.com' \\\n  --header 'Content-Type: application/json' \\\n  --data-raw 'From: \"Alice\" <sender@example.com>\nTo: myinbox@example.com\nSubject: Hello from local dev\nContent-Type: text/plain; charset=\"utf-8\"\nDate: Mon, 16 Feb 2026 12:00:00 +0000\nMessage-ID: <test-123@example.com>\n\nThis is a test email body.'\n```\n\nThe `to` address's local part (`myinbox`) determines which `EmailAddress` Durable Object receives the email. Make sure:\n- You have a Gadget with an Email Mailbox binding for `myinbox@<host>`\n- The binding has a hook connected via `setBindingHook`\n\nIf no hook is configured for that address, the email will be rejected.\n\n## Production Configuration\n\nIn production, you need to set up [Cloudflare Email Routing](https://developers.cloudflare.com/email-routing/) to forward emails to this worker.\n\n### Step 1: Deploy the email gatekeeper and set env.BASE_URL\n\nSet the `BASE_URL` environment variable to the full base URL (protocol + host + optional path) at which the email gatekeeper's fetch handler is served. No trailing slash. For example:\n\n```\n# Deployed as its own worker at the root:\nBASE_URL=https://gatekeeper-email.example.workers.dev\n\n# Or co-hosted on the same domain as the main app under a path:\nBASE_URL=https://app.example.com/gatekeeper/email\n```\n\nAll occurrences of `http://localhost:8787/gatekeeper/email` in the doc above will in production be replaced by this `BASE_URL` value.\n\n### Step 2: Enable Email Routing\n\n1. In the Cloudflare dashboard, go to your domain's **Email Routing** settings.\n2. Follow the setup wizard to enable Email Routing and configure the required DNS records (MX, SPF, etc.).\n\n### Step 3: Create an Email Worker Route\n\n1. Go to **Email Routing** > **Email Workers**.\n2. Create a route that matches the addresses you want to handle. For example:\n   - **Custom address**: `*@yourdomain.com` (catch-all) or specific addresses like `gadget-*@yourdomain.com`\n   - **Action**: Send to a Worker\n   - **Worker**: Select the deployed `gatekeeper-email` worker\n3. Alternatively, you can configure this in your Worker config for deployment.\n\n### How It Fits Together\n\n```\nInternet email → Cloudflare Email Routing → gatekeeper-email worker\n                                               │\n                                               ├── email() handler parses recipient\n                                               │\n                                               ▼\n                                          EmailAddress DO (per username)\n                                               │\n                                               ├── loads stored hook Fetcher\n                                               │\n                                               ▼\n                                          Gadget's hook entrypoint\n                                          (via Overseer loopback)\n```\n\nEach email address maps to a Durable Object named by its local part. The DO stores the hook `Fetcher` in KV storage, which persists across requests. When an email arrives, the DO loads the hook and calls `receiveEmail()` with the parsed email data.\n\n<!-- File: packages/gatekeeper-github/README.md (Tokens: ~2389 | Category: plugin-manifest) -->\n# Gatekeeper GitHub\n\nThis package provides GitHub OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `github` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitHub\" appears on the login page. Sign-in requests only minimal scopes (`read:user user:email`)\n  to read the account's **primary verified email**, which becomes the user's identity. The sign-in\n  grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects GitHub (or signs in and later connects it), the full scopes\n  (`repo read:user user:email`) are requested so gadgets can access repositories, issues, and pull\n  requests on the user's behalf.\n\n> **Use a GitHub _OAuth App_, not a GitHub _App_.** Only OAuth Apps honor the OAuth `scope`\n> parameter, which is what makes the minimal-on-login / full-on-connect behavior work, and the\n> `user:email` scope is what grants access to the user's verified email. A **GitHub App** (client id\n> starting with `Iv…`) ignores `scope` entirely and will return `Resource not accessible by\n> integration` for the email lookup unless you separately grant it the **Email addresses** account\n> permission — and even then login won't be minimal-scope. See [Using a GitHub App\n> instead](#using-a-github-app-instead) if you must.\n\n## Auto-approval\n\nEvery queued action carries a stable kind (`github.<action>`, e.g. `github.push`), and the user may\nopt in to approving a kind automatically on a connection. Every action is eligible, including\nclosing and reopening, `requestChanges` reviews and force pushes, except two that always wait for a\nperson: **merging a pull request** and **an approving review**. An approval shares the\n`github.postReview` kind with comment and request-changes reviews, but is never auto-approvable,\nso enabling that kind does not cover it. A force push has its own kind (`github.forcePush`), so\nenabling ordinary pushes does not let history be rewritten. Excluded actions are still approved\nby hand as before. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit (an issue binding, for example, never lists pushes).\n\n## Setting Up GitHub OAuth Credentials\n\nIf you're running this project locally and want to use GitHub integrations, you'll need to create\nyour own GitHub OAuth app. This guide walks you through the process.\n\n### Step 1: Create a GitHub OAuth App\n\n1. Go to [GitHub Settings > Developer settings > OAuth Apps](https://github.com/settings/developers)\n   (**OAuth Apps**, not \"GitHub Apps\").\n2. Click **New OAuth App**\n3. Fill in the application details:\n   - **Application name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - **Homepage URL**: `http://localhost:3000`\n   - **Authorization callback URL**: `http://localhost:8787/gatekeeper/github/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n4. Click **Register application**\n\nGitHub enables expiring user tokens by default for OAuth apps registered since August 2026\n(**Optional features** in the app's settings). The gatekeeper supports both kinds: it refreshes an\nexpiring token shortly before its eight hours run out, and asks the user to reconnect only once\nGitHub rejects the refresh token itself (unused for six months, or revoked).\n\n### Step 2: Generate a Client Secret\n\nOn the app's settings page after registration:\n\n1. Click **Generate a new client secret**\n2. Copy the **Client ID** and the generated **Client secret** — you'll need both in the next step.\n\n### Step 3: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-github/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n```\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 4: (Optional) Enable GitHub sign-in\n\nTo offer \"Continue with GitHub\" on the login page, add `github` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nUsers are keyed by their GitHub primary verified email, so the OAuth App must be able to read it\n(the `user:email` scope, requested automatically). No extra setup is needed for an OAuth App.\n\n### Step 5: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitHub resource type: repository, issue, or pull request.\n6. If prompted, connect a GitHub account.\n7. You should be redirected to GitHub's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the repository, issue, or pull request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitHub resource scope.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Event hooks (optional)\n\nGadgets can subscribe to a repository's issue, pull request, comment, review, push and tag events,\nor to one issue's or pull request's (`subscribe()` in `src/types.d.ts`). GitHub delivers them through\na repository webhook, so this needs a public URL:\n\n1. Set `WEBHOOK_ORIGIN` on this worker to the public origin GitHub should deliver to, e.g.\n   `https://gadgets.example.com`. Without it, hooks are refused and everything else works as\n   before.\n2. If the deployment sits behind Cloudflare Access, add a bypass for\n   `/gatekeeper/github/webhook/*`; the worker accepts only deliveries signed with a webhook's\n   secret instead.\n\nEnabling a hook adds a webhook to the repository, delivering to\n`${WEBHOOK_ORIGIN}/gatekeeper/github/webhook/<id>`, so GitHub allows it only if the connected\naccount is an admin of the repository. An account's hooks on one repository share its webhook,\nwhich is deleted when the last of them is disabled, or when the account is disconnected. Should\nGitHub fail to delete it, the hourly check tries again, and a disconnect that leaves it fails,\nkeeping the account connected, with the token that can delete it, until disconnecting again finds\nit gone. Each webhook signs its deliveries with a secret only GitHub and this deployment hold; a\ndelivery of a payload already received, or of an event more than a day old, is ignored; and an\nevent is delivered only while the account can still read the repository, since a webhook outlives\nits creator's access.\n\nThe repository's other admins can see the webhook, though not its secret. Every hour the worker\nchecks each webhook on GitHub, and restores one that someone has deleted, deactivated, pointed\nelsewhere, switched to form-encoded payloads or unverified TLS, or given other events, another\nsecret or none: to stop the deliveries, disable the hooks. The same check moves them to a new\n`WEBHOOK_ORIGIN` within the hour. GitHub never retries a failed delivery itself, so the same check\nasks it to redeliver what it failed to deliver in the past two hours: up to 20 an hour for each\naccount, shared among its repositories so that a busy one can't crowd out the rest. That includes\nwhat was refused while the webhook was misconfigured, by this worker or by another receiver, as\nGitHub redelivers to the webhook as it is configured now. Failures beyond that, or older than two\nhours, are lost, as is anything another receiver accepted while the webhook pointed there. A\ndelivery that reaches the worker is retried until the gadget's hook accepts it, eight attempts in\nall. When every recent delivery failed, as when Cloudflare Access turns GitHub away, the check logs\n`hooks.webhook.deliveries.failing` with the statuses GitHub got.\n\nFor local development, GitHub cannot reach `localhost`: run a tunnel to the dev server (e.g.\n`cloudflared tunnel --url http://localhost:8787`) and set `WEBHOOK_ORIGIN` to its origin in the\nroot `.dev.vars`.\n\nWhat this relies on GitHub for is checked against GitHub itself by `pnpm test:contract`\n(`__tests__/contract`), which the `github-contract` workflow runs weekly and on demand once the\n`CONTRACT_GITHUB_TOKEN` secret and `CONTRACT_GITHUB_REPO` variable name a disposable private\nrepository (the token fine-grained, with access to that repository alone; see the workflow):\n- a reader without access gets a 404, whatever ETag it sends;\n- a redelivery uses the webhook's current URL, encoding and secret;\n- the delivery log is newest first;\n- the account is the sender of what it does;\n- each event's payload parses.\n\n## Using a GitHub App instead\n\nIf you must use a **GitHub App** (client id `Iv…`) rather than an OAuth App, be aware:\n\n- GitHub Apps **ignore the OAuth `scope` parameter**. Permissions are fixed in the App's\n  configuration and apply to every user authorization, so sign-in cannot be limited to minimal\n  scopes — it grants whatever the App is configured for.\n- To read the user's email for sign-in, you must grant the App the **Email addresses** account\n  permission: App settings → **Permissions & events** → **Account permissions** → **Email\n  addresses → Read-only** → save. Existing users must then re-authorize (re-run the sign-in flow) to\n  approve the added permission. Without it, the email lookup fails with `Resource not accessible by\n  integration` and sign-in is rejected.\n\nFor these reasons an **OAuth App is recommended** for sign-in.\n\n## Troubleshooting\n\n### \"Resource not accessible by integration\" error\n\nYou're using a **GitHub App** that lacks the **Email addresses** account permission, so the email\nlookup required for sign-in is forbidden. Either switch to an OAuth App (recommended) or grant the\nApp the Email-addresses permission and re-authorize — see [Using a GitHub App\ninstead](#using-a-github-app-instead).\n\n### \"redirect_uri_mismatch\" error\n\nThe callback URL in your OAuth app settings doesn't match what the app is sending. Double-check that you set it to exactly `http://localhost:8787/gatekeeper/github/oauth` (no trailing slash, `http` not `https`).\n\n### \"bad_verification_code\" error\n\nThe authorization code has expired or already been used. Return to Gadgets and try connecting again.\n\n### \"Not configured\" page during authorization\n\nYour `CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at `packages/gatekeeper-github/.env` and contains both values, then restart the dev server.\n\n<!-- File: packages/gatekeeper-gitlab/README.md (Tokens: ~2830 | Category: plugin-manifest) -->\n# Gatekeeper GitLab\n\nThis package provides GitLab OAuth integration for Gadgets, for gitlab.com and for self-hosted\nGitLab instances. It serves two purposes:\n\n- **Sign-in:** when `gitlab` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  GitLab\" appears on the login page. Sign-in requests only the `read_user` scope to read the\n  account's **confirmed primary email**, which becomes the user's identity. The sign-in grant is\n  transient (discarded right after the email is read).\n- **Connections:** when a user connects GitLab (or signs in and later connects it), the `api` and\n  `write_repository` scopes are requested so gadgets can read and manage projects, issues, and\n  merge requests, and pull from and push to repositories, on the user's behalf.\n\nIt is a mirror of the GitHub gatekeeper: the same three resource granularities (project, issue,\nmerge request), the same worktree integration (commit ids advertised, `gitPull()` over git\nsmart-HTTP, `push()` as a queued action), and the same review and merge flows expressed in GitLab's\nown terms. See `plans/gitlab-gatekeeper.md` for the design record.\n\n`gitPull(oids, options)` returns a `GitPullTransfer`: a one-shot raw PACK stream and a separate\nterminal status. Lazy DO reads consume it through the bounded object cache. The same capability\nsupports native Git hydration, but does not enable it here: the container consumer ships in a\nseparate kernel worktree-replica bootstrap change. That consumer must bind the bootstrap to an\nauthorized immutable commit, request its complete tree at depth one, and import the pack without\npopulating the DO cache. `gitPull` is kernel-facing, not a gadget-facing arbitrary Git proxy;\nthe kernel selects objects from recorded provenance, not a provider-side advertisement validator.\nNo encoded Git commands, ref discovery, or arbitrary clone URLs cross this interface. OAuth and\noptional Cloudflare Access credentials remain in the gatekeeper's transport, including on\nself-hosted instances.\n\n## Auto-approval\n\nAs in the GitHub gatekeeper, every queued action carries a stable kind (`gitlab.<action>`), and\nevery one may be auto-approved once the user opts in to its kind, including closing and reopening,\nresolving and reopening diff threads, `requestChanges` reviews and force pushes (under their own\n`gitlab.forcePush` kind). **Merging a merge request** and **an approving review** always wait for\na person: an approval shares the `gitlab.postReview` kind with the other reviews but is never\nauto-approvable. Each binding advertises through `getAutoApprovableActions()` only the eligible\nkinds its session can submit.\n\n## Setting up GitLab OAuth credentials\n\nIf you're running this project locally and want to use GitLab integrations, you'll need to create\nyour own GitLab OAuth application.\n\n### Step 1: Create a GitLab application\n\n1. On GitLab, go to **User settings → Applications** (or, to share the application with a team,\n   a group's **Settings → Applications**).\n2. Fill in the application details:\n   - **Name**: anything (e.g. \"Gadgets Local Dev\")\n   - **Redirect URI**: `http://localhost:8787/gatekeeper/gitlab/oauth`\n     (replace the host with your `PUBLIC_BASE_URL` when not running locally)\n   - **Confidential**: checked\n   - **Scopes**: `api`, `write_repository`, `read_user`\n3. Save the application, and copy the **Application ID** and **Secret**.\n\n> **Why `write_repository` when `api` is \"everything\"?** GitLab's documentation grants Git over\n> HTTPS to OAuth tokens only through the `read_repository`/`write_repository` scopes; `api` covers\n> the REST API. Pushing commits from a worktree needs `write_repository`, so both are requested.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-gitlab/.env`):\n\n```bash\nCLIENT_ID=your-application-id-here\nCLIENT_SECRET=your-application-secret-here\n```\n\nAlternatively, export `GITLAB_CLIENT_ID` and `GITLAB_CLIENT_SECRET` in your shell; `pnpm dev-server`\nmaps them into the worker's `CLIENT_ID`/`CLIENT_SECRET`.\n\nTo develop against a self-hosted instance, set `GITLAB_URL` (and `GITLAB_API_URL`,\n`CF_ACCESS_CLIENT_ID`, `CF_ACCESS_CLIENT_SECRET` as the instance needs -- see\n[Self-hosted instances](#self-hosted-instances)) in the root `.dev.vars` or your shell;\n`pnpm dev-server` passes them through to the worker, so nothing tracked has to change.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 3: (Optional) Enable GitLab sign-in\n\nTo offer \"Continue with GitLab\" on the login page, add `gitlab` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,gitlab\n```\n\nUsers are keyed by their GitLab primary email, which GitLab only makes primary once confirmed. An\naccount whose primary email is unconfirmed cannot sign in.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a GitLab resource type: project, issue, or merge request.\n6. If prompted, connect a GitLab account.\n7. You should be redirected to GitLab's authorization page in a new tab.\n8. After granting access, the tab closes, and you're back to Gadgets.\n9. Use the picker to choose the project, issue, or merge request to connect.\n10. Create the connection. The Gadget now has access only to the selected GitLab resource.\n\n## Self-hosted instances\n\nBy default the gatekeeper talks to `https://gitlab.com`. Point it at another instance with these\nvariables on the worker:\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `GITLAB_URL` | The instance users visit: the OAuth authorization page, links in results, and the URLs of connectable resources. | `https://gitlab.com` |\n| `GITLAB_API_URL` | The origin the Worker sends requests to (REST, token endpoints, git over HTTPS), when it differs from `GITLAB_URL`. | `GITLAB_URL` |\n| `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` (secrets) | A Cloudflare Access **service token**, attached to every Worker→GitLab request as `CF-Access-Client-Id`/`CF-Access-Client-Secret`. Set both or neither. | unset |\n\nBoth URLs must be `https` origins (scheme and host, no path): every request to the API origin\ncarries a user's token, and the OAuth exchanges carry the client secret. Plain `http` is accepted\nonly on `localhost`, `127.0.0.1` or `[::1]`, for a GitLab run on your own machine. A GitLab\nserved under a relative URL root (`https://example.com/gitlab`) is not supported; the worker\nrefuses the URL at its first request rather than sending tokens to the wrong path.\n\nThe typical Access-protected layout is `GITLAB_URL=https://gitlab.example.com` (behind Access for\nbrowsers) and `GITLAB_API_URL=https://gitlab-access.example.com` (a hostname whose Access\napplication admits the service token). The Access application must admit the token on the\n`/api/v4/*`, `/oauth/*`, **and** `/<group>/<project>.git/*` paths — the last is where git fetch and\npush go, and a policy scoped to the API alone makes worktree pulls fail with a login page.\n\nRegister the OAuth application on the instance itself, with the same redirect URI and scopes as\nabove.\n\nA review that requests changes needs GitLab 19.2 or later: earlier versions' review-publishing\nendpoint ignores the reviewer state without an error, so on them the gatekeeper refuses a\n`requestChanges` review rather than post it as a plain comment. Comment and approve reviews work\non every version.\n\n## Worker Preview OAuth callbacks\n\nA Worker Preview's hostname cannot be registered with the GitLab application, so a deployment\nthat tests on previews registers one **stable** callback and relays the result to the preview\nthat started the flow (`@gadgets/gatekeeper-kit/preview-oauth`, as `gatekeeper-google` does).\nGive the stable Worker and its previews the same `OAUTH_STATE_SIGNING_SECRET` and\n`OAUTH_ALLOW_PREVIEW_REDIRECTS=true`, and set the fixed redirect on previews only:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-gitlab.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the application's redirect URI. A preview sends that URI to\nGitLab and carries its own callback in signed, short-lived state; the stable Worker accepts\nreturn URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only GitLab's\nresult and the state. The preview then exchanges the code with the same redirect URI it\nauthorized under. Normal deployments omit all three settings and use `${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Previews\ndo not inherit the stable Worker's secrets: set `CLIENT_ID`, `CLIENT_SECRET`,\n`OAUTH_STATE_SIGNING_SECRET` and, for an Access-protected instance, `CF_ACCESS_CLIENT_ID` /\n`CF_ACCESS_CLIENT_SECRET` on the stable Worker (`wrangler secret put`) **and** in its Previews\nBase configuration (`wrangler preview base-config secret put`); a preview created afterwards\nreceives them.\n\n## Troubleshooting\n\n### \"The GitLab project has been renamed or transferred\"\n\nThe project a connection was bound to has moved. GitLab answers the old path with a redirect that\nthe gatekeeper deliberately does not follow (following it would turn writes into reads). Re-bind\nthe connection to the project's new path.\n\n### \"GitLab credentials have expired or been revoked\"\n\nGitLab access tokens last two hours and are refreshed automatically; the refresh token behind them\nis single-use and rotates on every refresh. When GitLab refuses an access token the gatekeeper\nfirst refreshes past it. This error means the refresh token itself was rejected (`invalid_grant`)\n— typically because the application was revoked in GitLab, or the account sat idle past the\ninstance's refresh-token lifetime. Reconnect the account. A token endpoint that is unreachable,\nanswers 5xx, or is turned away by Cloudflare Access fails the request with \"Could not refresh\nGitLab credentials\" instead and leaves the connection intact.\n\n### \"GitLab credentials were renewed during this request\"\n\nGitLab invalidates an access token when it issues its replacement, so a request in flight while\nthe gatekeeper refreshed can be refused. Reads are rerun once with the new token; anything else\nfails with this message and is safe to retry.\n\n### The redirect URI does not match\n\nThe **Redirect URI** on the GitLab application must be exactly\n`<PUBLIC_BASE_URL>/gatekeeper/gitlab/oauth` (no trailing slash, `http` for local dev) -- or, on a\nWorker Preview, the stable Worker's `OAUTH_REDIRECT_URI` (see above).\n\n### \"Not configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Make sure the `.env` file exists at\n`packages/gatekeeper-gitlab/.env` and contains both values, then restart the dev server.\n\n### \"GitLab did not answer the request: the API redirected to …\"\n\nEvery REST call is answered with a redirect to a login page: the instance is behind Cloudflare\nAccess and the Access application in front of `GITLAB_API_URL` does not admit the gatekeeper's\nservice token, or `CF_ACCESS_CLIENT_ID`/`CF_ACCESS_CLIENT_SECRET` are not set on the worker (see\n[Self-hosted instances](#self-hosted-instances)). Distinct from the rename message above: the\ngatekeeper tells them apart by where the redirect points.\n\n### git fetch or push fails with a redirect or an HTML page\n\nThe same cause on the `.git/` paths: the Access application admits the service token for\n`/api/v4` but not for the repository, or the token is not configured.\n\n### \"This collaborator does not have read access to the GitLab project …\"\n\nSomeone sharing a workspace is not a member of the project it is bound to at **Reporter** or\nabove — or is a member only at Guest. Being able to open the project in GitLab is not enough,\neven on an `internal` or `public` one: a project can hold confidential issues and internal notes\nthat only members at those roles see, and the workspace may have read them. The remedy is for a\nproject maintainer to add the collaborator as a Reporter (inherited group membership counts).\n\n<!-- File: packages/gatekeeper-google/README.md (Tokens: ~11193 | Category: plugin-manifest) -->\n# Gatekeeper Google\n\nThis package provides Google OAuth integration for Gadgets. It serves two purposes:\n\n- **Sign-in:** when `google` is in the deployment's `AUTH_GATEKEEPERS` allowlist, \"Continue with\n  Google\" appears on the login page. Sign-in requests only minimal scopes (`openid`,\n  `userinfo.email`, `userinfo.profile`) to read the account's **verified email** (`email_verified`),\n  which becomes the user's identity.\n  The sign-in grant is transient (discarded right after the email is read).\n- **Connections:** when a user connects Google (or signs in and later connects it), the scopes for\n  the selected resources (Gmail, Docs, Sheets, Slides, Drive, Calendar, or BigQuery — see below) are requested\n  so gadgets can access those APIs on the user's behalf.\n\nA single Google OAuth client is used for both. Set it up as follows.\n\n## Setting Up Google OAuth Credentials\n\nIf you're running this project locally and want to use Google API integrations, you'll need to create your own Google OAuth credentials. This guide walks you through the process step-by-step.\n\n### Step 1: Create a Google Cloud Project\n\n1. Go to the [Google Cloud Console](https://console.cloud.google.com/)\n2. Sign in with your Google account\n3. Click the project dropdown at the top of the page (it may say \"Select a project\" or show an existing project name)\n4. Click **New Project** in the top-right of the popup\n5. Enter a project name (e.g., \"Gadgets Local Dev\")\n6. Click **Create**\n7. Wait for the project to be created, then select it from the project dropdown\n\n### Step 2: Enable Required APIs\n\nYou'll need to enable the Google APIs that you want to use. Currently supported: Gmail, Google Docs, Google Sheets, Google Slides, Google Drive, Google Calendar, Google Chat, and BigQuery.\n\n1. In the left sidebar, go to **APIs & Services** > **Library** (or [click here](https://console.cloud.google.com/apis/library))\n2. Search for \"Gmail API\"\n3. Click on **Gmail API** in the results\n4. Click **Enable**\n5. Go back to the Library, search for \"Google Docs API\"\n6. Click on **Google Docs API** in the results\n7. Click **Enable**\n8. Go back to the Library, search for \"Google Drive API\"\n9. Click on **Google Drive API** in the results\n10. Click **Enable**\n11. Go back to the Library, search for \"Google Sheets API\"\n12. Click on **Google Sheets API** in the results\n13. Click **Enable**\n14. Go back to the Library, search for \"Google Slides API\"\n15. Click on **Google Slides API** in the results\n16. Click **Enable**\n17. Go back to the Library, search for \"Google Calendar API\"\n18. Click on **Google Calendar API** in the results\n19. Click **Enable**\n20. Go back to the Library, search for \"BigQuery API\"\n21. Click on **BigQuery API** in the results\n22. Click **Enable**\n23. For Chat, enable **Google Chat API**, plus **People API** so direct messages and group chats can be named when Chat omits a participant's name, and so a whole-account Chat connection can search the organization's directory and confirm that everyone in a conversation it starts belongs to the organization.\n24. On the Google Chat API's **Configuration** tab, set an app name, avatar URL, and description, turn off **Interactive features**, and click **Save**. Reads work without this, but Google refuses every Chat send, edit, and reaction until a Chat app is configured.\n\nThe Google Drive API powers the Docs, Sheets, and Slides resource pickers, Drive discovery, and Drive scope checks. Native document, spreadsheet, or presentation content opened from a Drive binding is read through the Google Docs, Google Sheets, or Google Slides API. Direct Google Doc reads and edits still go through the Docs API, direct spreadsheet reads and edits go through the Sheets API, and direct presentation reads and edits go through the Slides API.\n\n### Step 3: Configure the OAuth Consent Screen\n\nBefore creating credentials, you must configure how the consent screen appears to users.\n\n1. In the left sidebar, go to **APIs & Services** > **OAuth consent screen** (or [click here](https://console.cloud.google.com/apis/credentials/consent))\n2. Select **External** as the user type (unless you have a Google Workspace organization and want to restrict to internal users only)\n3. Click **Create**\n4. Fill in the App Information:\n   - **App name**: Enter anything (e.g., \"Gadgets Local Dev\")\n   - Details are largely optional / irrelevant here, since this app will run it testing mode.\n   - Click **Save and Continue**\n5. On the Scopes page, you can just click **Save and Continue** without adding anything. The scopes are specified by the OAuth request itself, not the console configuration. (The console's scope UI is only relevant if you later want to publish your app for Google's verification review.)\n\nThe scopes requested depend on what the user is doing. **Sign-in** requests only the identity\nscopes (`openid`, `userinfo.email`, `userinfo.profile`). **Connecting** Google for capabilities\nrequests scopes granularly per resource type, not all at once: connecting a Gmail mailbox asks\nonly for the Gmail scopes, a Google Doc only for the Docs scopes, and so on (identity is always\nincluded). Across all resource types, the gatekeeper can request:\n\n- `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account.\n- `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending.\n- `documents` for direct Google Docs reads, edits, and creation; `documents.readonly` for native Docs opened from account-wide, folder, or exact-file Drive bindings.\n- `drive.metadata.readonly` for the Docs, Sheets and Slides pickers. Google classifies this as a restricted scope.\n- `drive.readonly` for account-wide, folder, and exact-file Drive bindings: the Drive picker, discovery, file metadata, folder descendant proofs, native-file scope checks, and downloading a PDF to read its text with `readPdf()`. Google classifies this as a restricted scope too, so every Drive resource here needs restricted-scope verification.\n- `drive.readonly` and `drive.file` for the Office import grant (`https://docs.google.com/import`), which converts an Office file into a Google Docs, Sheets or Slides copy (see [Office files](#office-files)). Only the connect dialog's **Convert** button asks for it, by incremental consent, the first time an account converts a file; using a copy made before reads only metadata and asks for nothing. Once held, it is part of the account's recorded grants, so a reconnect asks for it again. `drive.readonly` lets the gatekeeper see a source file this OAuth client did not create, which `drive.file` alone cannot, and `drive.file` lets it create the copy. The grant is private to the gatekeeper: it is not a resource, so it is never offered on connect, listed, bound, or shown among an account's granted resources, and admins cannot turn it off separately from Docs, Sheets and Slides. Once held, `drive.readonly` lets the account's token read any Drive file, so bindings stay limited by the gatekeeper's code rather than by the token. A verified OAuth app must add both scopes to its consent screen, and `drive.readonly` is a restricted scope.\n- `drive.readonly` for the shared-drives grant (`https://drive.google.com/drive/shared-drives`), which lets the Drive picker list the shared drives the account belongs to, since Drive's `drives.list` accepts no narrower scope. Only the picker's **Allow access** button asks for it, by incremental consent, the first time an account opens Shared drives; once held, a reconnect asks for it again. It is private to the gatekeeper as the Office import grant is, and an account holding that grant is still asked once, since a private grant counts only once it has been asked for, not because its scopes happen to be held.\n- `spreadsheets` to read metadata, bounded cell ranges and their formatting from directly selected spreadsheets, edit their values, sheets, rows, columns and formatting with approval, and create spreadsheets. The switch from `spreadsheets.readonly` retracted every existing Sheets grant, including one derived from `drive.readonly`: owners re-consent the next time they connect a spreadsheet, and collaborators observing an existing Sheets binding are asked to grant read-write `spreadsheets` the next time they open its workspace. A `spreadsheets` grant covers the Drive resources' `spreadsheets.readonly`.\n- `spreadsheets.readonly` to read native Sheets opened from account-wide, folder, or exact-file Drive bindings.\n- `presentations` to read, edit, and create directly selected presentations, and to render slide thumbnails; `presentations.readonly` to read and render native Slides opened from account-wide, folder, or exact-file Drive bindings. Google counts each thumbnail as an expensive read, limited to 60 a minute per user and 300 per project.\n- `calendar.calendarlist.readonly` so the resource picker can list calendars.\n- `calendar.events` to manage selected calendar and check calendar availability.\n- `chat.spaces.readonly`, `chat.messages`, and `chat.memberships.readonly` for every Chat resource. A whole-account Chat connection adds `chat.users.readstate.readonly` for its unread-only search, and `chat.spaces.create` and `directory.readonly` to start direct messages and group chats with people in the connected account's Workspace directory. Starting a conversation is its own approval kind, separate from sending in an existing one, and people outside the organization can't be added to a new conversation.\n- `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries.\n\n### Step 4: Test Users\n\nThis is important! While your app is in \"Testing\" mode (which it will be by default), only users you explicitly add here can use OAuth.\n\n1. Click **Add Users**\n2. Enter your own Google email address (the one you'll use to test Google API integrations)\n3. Click **Add**\n4. Click **Save and Continue**\n5. Review the summary and click **Back to Dashboard**\n\n### Step 5: Create OAuth Credentials\n\n1. In the left sidebar, go to **APIs & Services** > **Credentials** (or [click here](https://console.cloud.google.com/apis/credentials))\n2. Click **Create Credentials** at the top\n3. Select **OAuth client ID**\n4. For **Application type**, select **Web application**\n5. **Name**: Enter anything (e.g., \"Gadgets Local\")\n6. Under **Authorized redirect URIs**, click **Add URI** and enter: `http://localhost:8787/gatekeeper/google/oauth`\n7. Click **Create**\n\nA popup will appear with your **Client ID** and **Client Secret**. Keep this window open or copy these values somewhere safe.\n\n### Step 6: Configure Your Local Environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-google/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here.apps.googleusercontent.com\nCLIENT_SECRET=your-client-secret-here\n```\n\nReplace the values with the credentials from Step 5.\n\n> **Note**: The `.env` file is gitignored and should never be committed.\n\n### Step 7: (Optional) Enable Google sign-in\n\nTo offer \"Continue with Google\" on the login page, add `google` to the deployment's\n`AUTH_GATEKEEPERS` allowlist (e.g. in the root `.dev.vars`):\n\n```\nAUTH_GATEKEEPERS=cloudflare,google,github\n```\n\nSign-in only needs the identity scopes, which are always available, so no extra Google setup is\nrequired. (While the app is in Testing mode, the signing-in user must still be listed as a Test\nUser — see Step 4.)\n\n### Step 8: Verify Setup\n\n1. Start the application in dev mode (see instructions in the root README.md).\n2. Create or open a gadget.\n3. Navigate to the **Connections** tab.\n4. Click **+ New Connection**.\n5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Slides Presentation, Google Drive, Google Calendar, or BigQuery.\n6. If prompted, connect a Google account.\n7. You should be redirected to Google's consent screen in a new tab.\n8. The consent screen acts extra-scary since this is an \"unverified\" test app.\n9. After granting access, the tab closes, and you're back to Gadgets.\n10. Use the picker to choose the mailbox scope, document, presentation, project, dataset, or table to connect, or for Google Drive, the whole of Drive, a folder, or a single file.\n11. Create the connection. Ask the agent what it can do, or ask it to write a gadget using the new binding.\n\nYou can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right).\n\n## Gmail attachments\n\nNew messages, replies, forwards, and their draft-creation methods accept\n`attachments?: GmailAttachmentInput[]`. Each input is a regular file with a filename,\nMIME media type, and exact `ArrayBuffer` bytes; strings, base64 inputs, inline uploads,\nand multipart container uploads are not supported.\n\nFor example, given an exported PDF as `pdfBytes: ArrayBuffer`:\n\n```ts\nusing draft = await gmail.createDraft({\n  to: [\"recipient@example.com\"],\n  subject: \"Cloudflare at a Glance\",\n  text: \"Attached is the one-page overview.\",\n  attachments: [{\n    filename: \"cloudflare-at-a-glance.pdf\",\n    mimeType: \"application/pdf\",\n    content: pdfBytes,\n  }],\n});\n```\n\nFor an existing draft, `draft.addAttachment(input)` returns a readable, removable\n`GmailDraftAttachment`. `draft.attachments()` lists `{info, attachment}` entries, and\n`entry.attachment.remove()` removes that exact part, even when filenames are duplicated.\nDispose attachment capabilities when finished. `draft.update()` preserves attachments;\nit does not replace their list. Message attachment capabilities remain read-only.\n\nReplies include only supplied files. Forwards preserve the original attachments and\ninline parts, then append supplied files. A forward draft's preserved source parts are\nimmutable; its added files can be removed.\n\nFiles may be empty, but each upload is limited to **10 MiB**. Filenames must be nonempty\nand contain no path separators or control characters; `.` and `..` are refused.\nThe upload's `filename` is authoritative: redundant MIME `name`/`name*` parameters are\ndiscarded, while other media-type parameters such as `charset` are preserved.\nOutgoing MIME is limited to **25 MiB**, including transfer encoding and message headers.\nThe binding's existing **50 MiB** retained-file quota is shared by uploads and forward\nsource snapshots, including uploads in active drafts.\n\nUploaded bytes are captured in the existing integrity-checked chunk store, not embedded\nin queued-action metadata. Pending reads expose the files before approval, and body edits\nand Durable Object restarts preserve their exact bytes. Approval descriptions include\nfilename, media type, size, and SHA-256, with uploaded bytes marked as incomplete for\nhuman review. Files remain retained while a pending action or active draft references\nthem and are released when the last reference is removed. Rejecting a prerequisite\ninvalidates dependent edits; reject those edits too to release their references.\n\nCalendar files retain their original line endings and charset rather than being normalized.\nNested `message/rfc822` files also retain their exact bytes, including trailing line endings,\nthrough edits and sends; duplicate `.eml` filenames and identical files remain separate parts.\nProvider draft rewrites correlate attachment identities by metadata and SHA-256 in the actual\nMIME order, so queued edits and removals still select the right part when inline parts move.\nAn imported attachment without a disposition keeps its identity when a draft rewrite\nemits the regular `attachment` disposition.\n\nDraft and send fingerprints are prefixed `v2:` and hash attachment bytes exactly. Approvals\nqueued before that upgrade keep their unprefixed fingerprints and still apply, including a send\nwhose response was lost, which is reconciled rather than sent again. See\n`plans/gmail-simulation.md` for how they are verified.\n\n## Google Chat and Gmail new-message hooks (optional)\n\nGadgets can watch a Chat conversation for new messages (see `docs/google-chat-capabilities.md`),\nand a Gmail inbox, label or thread for new mail (`subscribeNewMessages()` in `src/types.d.ts`).\nGoogle delivers both through Pub/Sub, Chat's by Workspace Events and Gmail's by `users.watch`, so\nthis needs a public URL and these steps in the same Cloud project as the OAuth client:\n\n1. Enable the **Google Workspace Events API**, the **Cloud Pub/Sub API**, and the **Gmail API**.\n2. Create a Pub/Sub topic for Chat, granting `chat-api-push@system.gserviceaccount.com` the\n   **Pub/Sub Publisher** role on it, and one for Gmail, granting\n   `gmail-api-push@system.gserviceaccount.com` the same role. One topic granting both may serve\n   for both.\n3. Create a service account for push authentication (it needs no roles). Whoever creates the push\n   subscriptions needs `iam.serviceAccounts.actAs` on it (e.g. **Service Account User**). Pub/Sub\n   signs push tokens as its service agent, `service-PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com`,\n   which needs `iam.serviceAccounts.getOpenIdToken` on this account. Its default **Cloud Pub/Sub\n   Service Agent** role on the project includes that; only if it lacks the permission, grant it\n   **Service Account Token Creator** on this service account.\n4. Create a **push** subscription on each topic with endpoint\n   `${BASE_URL}/pubsub` (e.g. `https://example.com/gatekeeper/google/pubsub`), **Enable\n   authentication** with the service account from step 3, and audience set to that same endpoint.\n5. Set these values for this worker (in `.env` locally), both topics to the same one if it serves\n   for both:\n\n   ```bash\n   PUBSUB_CHAT_TOPIC=projects/your-project/topics/your-chat-topic\n   PUBSUB_GMAIL_TOPIC=projects/your-project/topics/your-gmail-topic\n   PUBSUB_PUSH_SERVICE_ACCOUNT=your-push-account@your-project.iam.gserviceaccount.com\n   ```\n\nIf the deployment sits behind Cloudflare Access, add a bypass for `/gatekeeper/google/pubsub`;\nthe worker instead accepts only pushes whose Google-signed token names that endpoint as audience\nand `PUBSUB_PUSH_SERVICE_ACCOUNT` as sender. That token proves a push came through a push\nsubscription, not who published to its topic, so each topic's Pub/Sub principals are trusted with\nhooked messages: anyone who can subscribe to it reads them all, and anyone who can publish to it\ncan inject messages into hooks of either kind, since a push is routed by its shape rather than\nits topic. Grant those roles to no one beyond step 2. A Gmail push carries only a mailbox address\nand a history ID, so a forged one can only make the worker read that mailbox's new history with\nthe connected account's own credentials. Without its topic and the push service account a kind of\nhook is refused, and everything else works as before.\n\nDisconnecting a Google account drops its hooks from the mailbox's and each space's driver, so\nnothing goes on reading or renewing with credentials that are gone; a watch or subscription no\nhook uses any more lapses, as when the hooks are disabled.\n\n## Worker Preview OAuth callbacks\n\nDeployments using Worker Preview hostnames can register one stable Google callback and relay the\nresult to the preview that initiated authorization. Configure the stable Worker and its previews\nwith the same `OAUTH_STATE_SIGNING_SECRET` and `OAUTH_ALLOW_PREVIEW_REDIRECTS=true`. Set the fixed\nredirect only on previews:\n\n```text\nOAUTH_REDIRECT_URI=https://gatekeeper-google.example.workers.dev/oauth\n```\n\nRegister `OAUTH_REDIRECT_URI` as the authorized redirect URI in Google. The preview sends that fixed\nURI to Google and carries its own callback in signed, short-lived OAuth state. The stable Worker\naccepts return URLs only on its `<preview>-<worker>.<workers.dev>` hosts and forwards only the OAuth\nresult and state. Normal deployments should omit these settings and continue using\n`${BASE_URL}/oauth` directly.\n\nDeploy the relay-capable stable Worker before enabling the fixed redirect on previews. Wrangler\nstores baseline and Preview secrets separately, so provision the same signing value in both places.\n\n## Creating files\n\nGoogle Docs, Sheets and Slides are creatable resource types: an agent can ask for a new one with\n`createExternalResource`, giving a one-line title (trimmed, at most 256 characters), and its binding\nworks at once, before anything exists at Google. Until the creation is approved the gatekeeper\nsimulates an empty file: a Doc reads as one empty tab and queues edits as usual, a spreadsheet as\none empty `Sheet1` of 1000 × 26 cells, which takes no change until it exists, and a presentation as the one Google creates, a 16:9 deck\nwith a title slide, which queues changes as usual. Of the default theme's layouts it offers only\nfive (title slide, section header, title and body, title only, and blank); the others become\navailable once the presentation exists. Nothing reaches Google and no account is involved.\n\nThe approver picks which of their connected Google accounts to create the file in; it lands in\nthat account's My Drive, and the binding switches to the real file. Doc edits queued against the\nsimulation then apply to the created document like any other edit, and Slides changes apply to the\ncreated presentation unchanged.\n\nThat works for Slides because Google gives every new presentation the same object IDs (master\n`simple-light-2`, title slide `p`, and layouts `p2` to `p12`, seen both on a personal account\nthrough the API and on a Workspace account in the editor), so the simulation reads a recording of\none, `src/blank-presentation.json`: each read a session makes, through the session's own field\nmasks, less the presentation's ID, title and revision and the layouts it does not offer.\n`pnpm --filter @gadgets/google-gatekeeper record:blank-presentation <token-file>` records it again;\nthe file holds an access token with the `presentations` and `drive.file` scopes. It creates two\npresentations, refuses to record unless they read the same, and deletes them; with `--check` it\ncompares them with the committed recording instead. Should Google's new presentation change, a\nqueued change naming a slide, element or layout the created presentation lacks fails when\napproved, writing nothing.\n\nGoogle's create calls take no idempotency key. A retried approval after a lost reply binds the file\nalready created (the gatekeeper records it), but a file can still be orphaned in the approver's\nDrive: a create request that times out or fails with a 5xx after Google acted, a crash between\nGoogle's reply and that record being written, or a creation rejected after an approval whose\nsuccess was lost. Every created file's ID is logged (`google.creation.file.created`), so an orphan\ncan be found.\n\n## Office files\n\nGoogle's Sheets, Docs and Slides APIs cannot edit an Office file stored in Drive (`.xlsx`, `.xls`,\n`.xlsm` and `.ods`; `.docx`, `.doc`, `.odt` and `.rtf`; `.pptx`, `.ppt` and `.odp`): Sheets and\nSlides refuse to open one, and Docs reads one but refuses every edit to it. So the Docs, Sheets and\nSlides pickers list these files too, named with their extension and marked \"Makes a Google …\ncopy\", and connecting one converts it. The connect dialog says what converting does and offers\n**Convert**, which has Drive copy the file into the native type (`files.copy` with the native MIME\ntype: Google converts it, and no file content passes through the Worker). The copy is named after\nthe original without its extension, lands next to it when the original's folder takes new files and\nat the root of My Drive otherwise, and is what gets connected. The original is never changed, and later edits\nto it do not reach the copy. The conversion is Google's, and loses what Google's import loses, such\nas macros, some formulas, and complex layout.\n\n**Convert** asks for the Office import grant (see the scopes above) when the account does not yet\nhold it. Workshop opens Google's consent pop-up, and the dialog waits up to three minutes for the\ngrant before giving up. Each copy carries app properties naming its source (`gadgetsConvertedFrom`)\nand the source's modification time when it was copied (`gadgetsSourceModified`), so choosing the\nsame file again offers the copy made before instead of making another; using it reads only Drive\nmetadata, so it needs no new grant. App properties are private to the OAuth client that set them\nbut not to a user, so only copies this deployment's client made are found, and of those only ones\nthe account can edit; that may include a copy another user made in a folder they both edit. The\nlookup is best effort: a missed match makes another copy. A copy request is never retried, since a\nlost reply may still have made one. A file whose owner has turned off copying, a file in the trash,\nor one larger than Google imports into that type (`about.maxImportSizes`) is refused before\nanything is copied.\n\nA pasted link to an Office file fails to connect, and its error offers to open the connect dialog,\nwhere it can be converted. An Office file bound any other way is refused with a message pointing to\nthe dialog: by Sheets and Slides when they cannot open it, and by Docs on connecting, since Docs\nreads it. An Office file already bound as a Doc answers each approved edit with the same message.\n\n## Google Docs request costs\n\nConnecting a Doc reads its Drive metadata once, to refuse an Office file (see\n[Office files](#office-files)). A read costs one full `documents.get`, replayed for 10 seconds and then revalidated by a\nrevision-only `get`. Queuing an edit costs nothing more. Applying one costs a full `get` (the\nedit is materialized against the current revision), the `batchUpdate` carrying the edit and its\nwrite marker, and a second `batchUpdate` removing the marker; the document is reread after it only\nwhen more edits are queued behind it, since they must be replayed over the new revision.\n\n## Known limitations\n\nVery large Google Docs can exceed Durable Objects' 2 MB value limit after Markdown conversion,\ncausing tab listing, content reads, and edits to fail.\n\nA Google Slides presentation's slide summaries come from one response capped at 10 MiB. It holds the\ntext of every slide's shapes and speaker notes, but no styles or geometry: about 4 KiB a slide on a\nlive deck, so a presentation needs thousands of slides to exceed it. It also holds every master's\nand layout's name and background and the masters' theme colours, a few KiB in all. Slide content is\nread one slide at a time, capped at 2 MiB each. While changes await approval, reads also fetch the\nslides they address, so each can be checked as approving it would: slide content reads the other\nslides of any batch touching a slide it shows, since a batch applies all or none, and the summaries\nread every slide a queued batch changes, since they hold no tables or grouped shapes. Large batches\nawaiting approval therefore cost reads more requests against Google's per-user quota.\n\nThe simulated presentation was recorded from an English-locale account, so until it is created it\nreads with locale `en` and English layout names. Google translates those names: a presentation a\nSpanish-language account made in the editor read locale `es-419` and layout names such as\n`Título y cuerpo`, with the recording's object IDs, placeholders and geometry. One created through\nthe API in a non-English account has not been checked, so an approver's presentation may name its\nlayouts differently from what the agent saw before approval, including in an approval card queued\nthen. `createSlide()` takes a layout's ID, never its name.\n\n## Google Slides reads\n\n`getSlides()` reads each element as the Slides editor shows it: its box and rotation, and what it\nsets rather than inherits. Shapes carry their text and its formatting (links to URLs or to slides,\nfont weight, paragraph indents and bullets), and their fill, its opacity, and their outline with its\ndash. Tables carry their cells, column widths, row heights and borders, collapsed to one `border`\nwhen every edge matches. A table's `size` is nominal, 3,000,000 EMU square on every live read, so\nits box is its columns and rows added up; a row's height is the least it may have, since Google\ndraws it taller to fit its text and reports that nowhere. Lines carry their ends, computed from\ntheir transform, arrowheads, and the elements they connect; videos their YouTube or Drive ID; and\nimages and Sheets charts their source URL and spreadsheet. The `contentUrl` an image or chart also\nhas is a bearer URL, like a thumbnail's, so it is never read. `getPresentation()` lists the\npresentation's layouts with their placeholders, for `createSlide()`.\n\n## Google Slides edits\n\nA `.pptx` or other Office presentation cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound presentation accepts six changes, each queued for approval: `updateSlides()`,\n`duplicateSlide()`, `deleteSlide()`, `moveSlides()`, `createSlide()`, and `setSlidesSkipped()`.\nChanges are journaled with the kit's `ActionJournal`, and apply in the order they were queued.\n\n`updateSlides()` takes up to 50 changes applied together: find-and-replace or whole-text\nreplacement in shapes, table cells, and speaker notes, text and paragraph formatting (including\nbullets), shapes and their fill, outline and vertical alignment, moving, resizing, rotating,\nstacking and deleting elements, alt text, images by public `https:` URL, and tables with their\nrows, columns and cell fills. Elements a batch creates get IDs the gatekeeper mints: later changes\nin the batch can address one by its `ref`, and later batches by the ID returned for it. An image\nURL is never fetched by the gatekeeper: Google downloads it when the change is applied, so nothing\nis read from it before approval.\n\nTwo of its changes reach beyond a slide's elements:\n\n- `setBackground` sets the background of a slide, a layout or a master, named by exactly one of\n  `slideId`, `layoutId` and `masterId`: a colour, `\"none\"`, a picture by public `https:` URL, or\n  `null` to inherit the background of the layout or master the page is made from. A master has\n  nothing to inherit from, and Google turns a reset there into no background, so `null` is refused\n  for one. As with images, Google, not the gatekeeper, downloads a picture URL when the batch is\n  applied. Reads report a picture background only as `\"picture\"`, and never request its content\n  URL, a short-lived URL that reads as the account that fetched it.\n- `setThemeColors` sets some of a master's 12 theme colours. Google takes all 12 at once, so the\n  colours not given are merged in from the master's scheme as the batch is planned against its\n  fresh read: a colour a collaborator changed meanwhile is kept, and one changed after that read\n  makes Google refuse the revision-pinned write, so it is planned again.\n\n`getPresentation()` lists the masters, with their backgrounds and theme colours, and the layouts,\nwith their master and background, but not their elements; each slide reports its layout, its master\nand a background of its own. The Slides API cannot change which layout an existing slide is made\nfrom.\n\nA batch is queued as one of three action kinds, which share everything but which ones a user may\nlet apply without asking: \"Slide text edits\" (only text edits), \"Slide formatting and arrangement\"\n(only formatting text, setting no link or font, since Google keeps their whole strings; formatting\nparagraphs and shapes; moving or stacking elements; and setting slides' backgrounds to a colour,\n`\"none\"` or `null`), and every other batch, mixed ones included, which always waits for approval.\nA picture background, any change to a layout or master, and theme colours therefore always wait:\nthey change slides the user is not looking at, or make Google fetch a URL.\n\n`createSlide()` adds a slide made from one of the presentation's layouts, at the start, the end, or\nafter a given slide. The gatekeeper mints the slide's ID and one for each placeholder it gets from\nthe layout, read from the layout's page when the change is queued, so later changes can fill the\nplaceholders before it is approved. Reads show the new slide with the layout's placeholders, empty,\nat the layout's size and position, as a live probe showed Google makes them, but for a slide\nnumber: Google adds one only while the presentation shows slide numbers, which a new one does not,\nand otherwise ignores its mapping, so none is minted and a slide number appears once the slide\nexists (`instantiatedPlaceholders()` in `slides-simulation.ts`).\nGoogle takes a new slide's layout only from the master of the slide before it, of the first slide\nwhen it goes first, or of the presentation's first master when it has no slides, so a deck with\nslides imported in another theme has layouts that fit only some places: layouts and slide\nsummaries carry their master's ID, and a layout that does not fit where it is to go is refused\nwhen queued, and again on approval against a fresh read, as is one deleted since; approval also\nrefuses one that has lost a placeholder the slide was to get. Google deletes a master and its\nlayouts with the last slide on it, unless it is the presentation's first master, as a live probe\nshowed, so reads drop a master whose last slide a queued deletion removes, with its layouts, and\na slide queued after it on one of those layouts is refused. Google names a new slide's speaker notes\nonly as it creates the slide, so they cannot be edited until it is approved. Adding a slide always\nwaits for approval.\n\n`setSlidesSkipped()` skips slides, leaving them out when presenting, or shows them again. It is\nthe \"Skipping slides\" kind, which a user may let apply without asking: it destroys nothing, and\nis undone the same way. It applies against the presentation's outline alone, reading no slide.\n\nReads show queued changes as if applied, by replaying them over Slides' own JSON before it is\nprojected; thumbnails show the presentation as saved. Each change is replayed as Google documents\nits request, and one the replay cannot follow exactly is refused instead: a bullet list started\nright after another list item, which Google may join to that list; table rows or columns inserted\nor deleted beside a merged cell; deleting a grouped element that would leave its group with one;\nrotating a video, which Google cannot shear; and new text filling a list item whose bullet is\nstyled apart from its text, which Google would restyle to match. The replay makes no claim about\nwhat Google renders: shrinking text to fit, wrapping, the box Google fits an image to, the borders\naround new or deleted table rows and columns, and the formatting new rows and columns take appear\nonce applied. Table rows and columns are sized as live reads show Google sizing them: a new table\nshares its box evenly, a new row or column copies the one beside it, every column then narrows so\nthe table keeps its width, and the lines a deletion leaves keep theirs. It does follow Google in\nturning a shape's autofit off once its text changes, fixing the font sizes and line spacings the\ntext sets at what the autofit had shrunk them to. A replacement takes the style of the text it\nreplaces, and text left unchanged at either end of a match is not rewritten, so it keeps its own.\nFormatting a whole list paragraph also updates its bullet's style in replay, which later\nreplacements use when checking whether they can keep that style. Setting a font sets it at\nregular weight, so the text is no longer bold unless the change says so. A slide number or other\nAutoText can only be replaced whole.\n\nEach approved change is one `batchUpdate`, planned against a fresh read and pinned to its revision\nwith `requiredRevisionId`, so a change applies only to the text it was planned against: one that no\nlonger applies fails without writing, and a concurrent edit makes Google refuse the write so it is\nplanned again. A write whose response is lost may have been committed, so it is only ever resent as\nfirst sent, at the same revision. If that is refused, a read decides whether it landed, counting\nonly what the write would have changed: an element it created that it does not also delete, an\nelement it deleted that was there before, text it edited, a table it adds or deletes rows or columns\nof, by its size and its cells' positions, spans and text, a theme colour it set, and a background\nother than a picture it left a page with, that the page did not already have (every picture reads\nalike). If the read cannot tell, as for a batch that only formats or moves elements or leaves a page\na picture, the change is marked as having an unknown outcome and never retried.\n\nGoogle returns a presentation's revision only to an account that can edit it, so a view-only\naccount can read a presentation but every change to it fails. A queued change is stored in one\nDurable Object value, so each is capped at 100 KiB.\n\n## Google Sheets edits\n\nAn `.xlsx` or other Office spreadsheet cannot be bound; connecting one makes a native copy (see\n[Office files](#office-files)). A directly bound spreadsheet accepts `updateSpreadsheet()`, which queues up to 50 changes for\napproval as one batch, applied all or none and in order: `writeCells` enters values in every cell\nof a bounded A1 range that names its sheet, such as `'Sales 2026'!A1:C3`, and `clearRange` clears\na range's contents, keeping its formatting. Batches are journaled with the kit's `ActionJournal`, and\napply in the order they were queued. A spreadsheet opened from a Drive binding has no change method.\n\nInput is literal and typed, so nothing depends on the spreadsheet's locale: a string starting with\n`=` is a formula, any other string is text exactly as given, never read as a number or date,\nnumbers and booleans are entered as such, and `null` clears a cell. A date is entered as its\nserial number in a cell formatted as a date, as `formatCells` can format it. Text starting with\n`=` cannot be written as text, and formula-mode reads show it exactly as a formula, so writing\nback what was read can turn it into one. A batch of only literal values and clears is queued as\n\"Sheet value edits\", which a user may let apply without asking; a batch with any formula always waits for approval.\nA formula naming `IMPORTRANGE`, which reads another spreadsheet and so escapes the binding, or\n`IMPORTDATA`, `IMPORTHTML`, `IMPORTXML`, `IMPORTFEED` or `IMAGE`, which make Google fetch a URL, is\nrefused in any letter case, outside string literals. Formulas the spreadsheet already holds are\nnot inspected, and one of its named functions could wrap a refused function, which is why every\nformula needs approval.\n\nReads show queued changes as entered and never guess what Google computes. Formula-mode reads are\nexact, and raw reads are exact for literal values. A queued formula's result, and in formatted\nreads a queued number or boolean, whose display depends on number format and locale, read `null`,\nand their cells are listed in the range's `pendingCells`. Formulas elsewhere keep their saved\nresults, even when they depend on queued cells. Google tidies the references in a formula as it is entered: column\nletters upper-cased, a sheet named by its title as the spreadsheet spells and quotes it, a range of\none cell collapsed to that cell. A queued formula is shown, approved and written already tidied, but\nwith its whitespace as typed, which Google may later drop. A queued change that no longer applies,\nsuch as one writing to a sheet since shrunk, is reported as `queuedChangeConflict`, and it and the\nchanges queued after it are not shown.\n\nThe same batch can change a spreadsheet's structure: `addSheet`, `renameSheet`, `duplicateSheet`\nand `deleteSheet` act on its sheets, and `insertRows`, `deleteRows`, `insertColumns` and\n`deleteColumns` on a sheet's rows and columns. A change names its sheet by title in `sheet`, as a\nrange does, including a title an earlier change of the batch gives a sheet it adds, copies or\nrenames, so it never needs an ID the spreadsheet has not assigned yet. The gatekeeper mints the ID\nof each sheet a batch adds. A batch with any structural change always waits for approval.\nEvery default is sent explicitly, so the approver sees what will be made: a new sheet has Google's\n1,000 rows and 26 columns and comes last, and a copy is titled `Copy of <title>` and placed right\nafter its source (Google would put it first, under a name of its own choosing). Changes are refused\nwhen queued that would delete the last visible sheet, every row or column of a sheet, or every one\nof its unfrozen ones, delete a sheet of more than 50,000 cells, whose content the guard below could\nnot cover (delete it in Google Sheets instead), grow a sheet past 18,278 columns or the spreadsheet\npast 10,000,000 cells, or change or copy a sheet holding a protected range the account cannot edit. Reads show the\nqueued structure: sheets, rows and columns where the changes put them, cells moved with their rows\nand columns, and formulas rewritten as Google rewrites them, references shifted, grown, shrunk or\nturned into `#REF!`, and sheet titles requoted. A formula's value is shown only while every\nreference it holds keeps exactly its cells; one whose reference grows, shrinks or breaks, or that\ncalls a position-dependent function such as `ROW`, `OFFSET` or `INDIRECT`, or `QUERY`, whose query\nnames columns by letter, or uses a name such as a named range, or calls a function the\ngatekeeper does not know to depend only on its arguments (one of the spreadsheet's named functions\nmay hide references or such calls), reads `null` and is listed in `pendingCells`, as are cells a\nqueued formula enters. Cells of inserted rows and columns and of\nadded sheets read as empty. Deleting a sheet leaves the formulas that refer to it as written, as\nGoogle does; they read `#REF!` once it applies, and stay so even if a later sheet takes the title.\nOnce rows, columns or sheets are queued, cells Google fills with nothing entered in them, such as an\narray formula's results (`SORT`, `SEQUENCE`, `FILTER`, `QUERY`) or a pivot table, read `null` and\nare listed in `pendingCells`.\n\n`formatCells` formats every cell of a range, keeping its contents: bold, italic, underline,\nstrikethrough, font size, text and fill colour (`#rrggbb` or a theme colour such as `ACCENT1`),\nnumber format (a type and an optional pattern in Google's syntax), horizontal and vertical\nalignment, wrapping, and borders along the range's edges and between its rows and columns. A field\nset to `null` is reset to the default, and a border set to `null` is removed; fields left out are\nkept. A batch of only `formatCells` changes is queued as \"Sheet formatting\", which a user may let\napply without asking; mixed with any other change it waits for approval. `readFormats()`, which a\nspreadsheet opened from a Drive binding offers too, reads the formatting set on up to 10,000 cells\nof a range, never their values, and shows queued formatting merged in queue order. Cells of rows\nand columns a queued change inserts, whose formatting Google inherits from a neighbour in ways that\ndiffer between rows and columns, read `null` and are listed in `pendingCells`. A border along a\nrange's outer edge removes the facing border of the cell beside it, since Google gives an edge one\nowner, and reads show that. A formatted value read of a cell whose number format a queued change\nsets or resets reads `null` too, listed in that range's `pendingCells`, unless a queued change\nleaves the cell empty: a format such as `;;;` can hide what a blank-looking cell holds. So does a\nvalue queued on an inserted row or column, which takes the number format of the line before it.\nGoogle accepts any font size and number pattern, so the gatekeeper's checks are the only ones: a font size is an\ninteger from 1 to 400, and a pattern holds 1 to 200 characters and no control characters.\nFormatting is not guarded: a collaborator's formatting change made after a batch is queued is\noverwritten when it applies.\n\nSheets has no revision to pin a write to, so each approved batch is planned against a fresh read\nand guarded by a digest of what it overwrites or removes: the cells it writes and the rows,\ncolumns and sheets it deletes, as entered, the title and size of each sheet it addresses, and,\nwhen it adds or copies a sheet, the order of every sheet, ignoring whitespace in formulas. Cells\nthat changes queued before it write are expected to hold what those enter, and sheets those\nrestructure to have the shape they leave, so a batch built on one that was rejected or failed fails\ntoo, as does a batch with a formula when any structural change queued before it was not applied,\nand a batch adding or copying a sheet when a sheet queued before it to be added, copied or deleted\nwas not. A batch guards at most 50,000 cells. A change whose cells or sheet\nchanged since it was queued fails without writing; a collaborator's edit between that read and the\nwrite is not caught. Each batch also creates a developer-metadata marker whose ID is minted at queue\ntime, which Google stores at most once, so a write whose response is lost is found by its marker or\nresent exactly as first sent. A lost write that cannot be resent, because its cells changed in the\nmeantime or Google refused the resend, or that three sends leave unfound, is marked as having an\nunknown outcome. A marker is visible only to the Google Cloud project of this deployment's OAuth\nclient, leaves no Drive revision, and is deleted by the first write at least ten minutes after its\nbatch was last sent, since a send given up on may commit until then. While a write with an unknown\noutcome could still land, later changes stay pending.\n\nA change is refused when it is queued if the connected account can only view the spreadsheet, or\nif it writes outside a sheet's grid or into a protected range the account cannot edit. A change\naddresses at most 10,000 cells and a batch 20,000, a cell holds at most 50,000 characters, the\nchanges queued at once may set values or formatting in at most 100,000 cells in all (a cell given\nboth counting twice, a copied sheet counting what changes queued to it set) and make at most 50\nchanges to rows, columns and sheets, and a queued batch is capped at 100 KiB. While such changes are\nqueued, a read or a batch may involve at most 5,000,000 characters of formulas times their number,\nsince each formula is rewritten through each of them, and hold at most 5 MiB of text as rewritten.\nGoogle allows 60 write requests a minute per user; a write over that quota stays pending.\nuser; a write over that quota stays pending.\n\n## Google Drive read-only bindings\n\nDrive exposes three permanent resource URL forms:\n\n- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: any ID the account token resolves is in scope. Listings use `corpora=user`, which Google defines as My Drive items the account created or opened plus items shared directly with it, so a shared drive's contents may be readable by ID without appearing in a listing. Bind a folder or file when this authority is too broad.\n- `https://drive.google.com/drive/folders/<folderId>` selects one folder or shared-drive root.\n- `https://drive.google.com/file/d/<fileId>/view` selects one file by its immutable ID.\n\nThe Drive picker opens on Google Drive, whose own choice binds the whole account, above My Drive, Shared drives and Shared with me. Locations list a page at a time. Search spans My Drive, \"Shared with me\" and every shared drive the account is a member of (`corpora=allDrives`) and says where each result lives, and a Drive, Docs, Sheets or Slides link pasted into it answers with the one folder or file the link names. A pre-filled folder or file opens where it lives, with breadcrumbs up to My Drive or its shared drive, or with none when an ancestor is out of reach or more than 20 folders up. All of this runs on the Drive resources' own grant except listing Shared drives, which also needs the shared-drives grant above: until the account holds it, opening Shared drives offers **Allow access**, and a shared drive's folders stay reachable through search, a pasted link or a pre-filled URL. Folders whose children the account cannot list are shown but cannot be opened or bound; on a pre-filled node's breadcrumbs, where Google's [folders with limited access](https://developers.google.com/workspace/drive/api/guides/limited-expansive-access) put them above a file shared directly, they can be opened but are not offered. A shortcut stands for its target, which a listing says nothing of, so a page's shortcut targets are read in one batch: a shortcut left behind when its target was deleted, unshared or trashed is left out, and one to a folder is judged as that folder would be. A search Google reports as incomplete fails rather than presenting partial results as complete.\n\nA folder URL carrying `?resourcekey=` is not supported: the key is dropped, and the binding then fails with a Drive 404 for anyone whose access to that folder comes from the link rather than from a direct grant. Google requires resource keys only for items shared by link before September 2021, and only for link-access users — an owner or anyone granted access directly is unaffected, even when the URL they paste happens to carry a key. Such a folder needs direct access to connect.\n\nThe agent-facing `GoogleDriveReadSession` covers account and exact-file bindings. `GoogleDriveFolderSession` is positioned at the selected root and exposes only its current folder's direct children: `list()`, provider-side structured `search()`, `getEntry()`, native Doc/Sheet/Slides opens, and `openFolder()` for one live direct child. Listing and search return disposable RPC cursors; child folders and native content sessions are independently disposab\n\n<!-- [TRUNCATED AT 50KB] -->\n\n<!-- File: packages/gatekeeper-homeassistant/README.md (Tokens: ~1257 | Category: plugin-manifest) -->\n# Gatekeeper Home Assistant\n\nThis package provides a [Home Assistant](https://www.home-assistant.io/) gatekeeper for Gadgets. It lets a Gadget read state from a connected HA instance, call services on devices (turn lights on/off, set thermostats, lock doors, etc.), edit Lovelace dashboards, render templates, and read history — all mediated through the standard Gadgets approval queue.\n\n## Authentication\n\nThe gatekeeper uses Home Assistant's **long-lived access token (LLAT)** + **URL** combination. There's no OAuth flow because:\n\n- Each HA instance has a different URL (no central directory).\n- LLATs don't expire (10-year default lifetime).\n- LLAT is the path every other HA-adjacent tool uses, so users are familiar with it.\n- It works for both Nabu Casa Cloud users and self-hosted users on a LAN.\n\nWhen the user connects an account, they paste their HA URL and an LLAT into a form. The gatekeeper validates by calling `GET /api/`, then stores both in the per-user Durable Object.\n\n### Reachability\n\nThe gatekeeper makes HTTP and WebSocket calls to the configured HA URL. On a Cloudflare-hosted Gadgets deployment, that means HA must be publicly reachable (via Nabu Casa, Cloudflare Tunnel, port-forwarding, etc.). On a self-hosted (workerd) deployment, LAN addresses like `http://homeassistant.local:8123` and `http://192.168.x.x:8123` work fine — that's the intended setup for users running Gadgets on the same network as their HA.\n\n## Resource granularities\n\nFive granularities are exposed; each comes with its own configurator UI for picking a specific resource from the connected HA instance:\n\n| Granularity   | What it grants                                                      |\n| ------------- | ------------------------------------------------------------------- |\n| Whole instance | Access to every area, device, entity, dashboard, and service.       |\n| Area          | A single area (room) — its devices and entities only.               |\n| Label         | All entities carrying a particular HA label.                        |\n| Device        | A single physical device and the entities it provides.              |\n| Entity        | A single entity (light, sensor, switch, etc).                       |\n\n## TypeScript API\n\nBindings expose one of `HomeAssistantSession` (whole-instance), `Area`, `Label`, `Device`, or `Entity`, depending on the granularity granted. Common operations:\n\n```ts\n// Whole-instance:\nconst config = await session.getConfig();\nconst areas = await session.listAreas();\nconst lights = await session.listEntities({ domain: \"light\" });\n\n// Capability-based:\nconst light = await session.getEntity(\"light.kitchen\");\nawait light.turnOn({ brightness: 200 });\nconst state = await light.getState();  // reflects the simulated post-write state\n\n// Area-scoped service call (affects every entity in the area):\nconst livingRoom = await session.getArea(\"living_room\");\nawait livingRoom.callService(\"light\", \"turn_off\");\n\n// Templates:\nconst temp = await session.renderTemplate(\"{{ states('sensor.outside_temp') | float }}\");\n\n// Dashboards:\nconst dashboard = await session.getDashboard(\"lovelace\");\nconst dashboardConfig = await dashboard.getConfig();\n// ... mutate the JSON ...\nawait dashboard.saveConfig(dashboardConfig);\n```\n\nSee `src/types.d.ts` for the complete API and `@example` blocks for every method.\n\n## Approval & simulation\n\nEvery read calls `authorizeObservation` and every write goes through `submitAction`. Writes do **not** execute against HA until the user approves them.\n\nUntil approval, reads reflect a **simulated post-action world**: e.g. after `entity.turnOn()`, an immediate `entity.getState()` shows `state: \"on\"` even though HA hasn't been touched. This lets agents chain reads and writes without waiting for user approval. Simulation predicts final states only — no transition timing, and unrecognized service calls (custom integrations, scenes, scripts, templates) leave state untouched.\n\nSee the **APPROVAL & SIMULATION** section at the top of `src/types.d.ts` for the full list of caveats.\n\n## Phase 2 — not yet implemented\n\n- **Caching.** Every read does a fresh registry fetch. Once caching is added, area / device / label-scoped reads will be much cheaper.\n- **Hooks (push events).** `setHook` is a no-op. WebSocket `subscribe_events` / `subscribe_entities` would enable a `HomeAssistantHook` interface — e.g. a Gadget receiving a callback when a motion sensor fires.\n\n## Implementation notes\n\n- **Service calls go through the WebSocket API.** HA's REST `POST /api/services` endpoint expects target fields flattened at the top level of the body and has uneven support for area / label / floor targets across versions. The WebSocket `call_service` command is HA's modern path and supports the full target shape natively.\n- **All actions get an integer `id`** assigned by a per-DO counter. Pending actions are stored under `pending:<id>` in DO storage so reads can simulate the post-action state.\n- **Defensive validation.** Malformed action bodies (e.g. an agent passing a single options object instead of positional arguments) fail synchronously with a rich error message that includes a suggested corrected call.\n\n## Files\n\n```\nsrc/\n├── homeassistant.ts        # main: Vendor, UserAccount, User, GatekeeperImpl, Session impls\n├── homeassistant-api.ts    # REST + WS clients (incl. timeouts and error-body sanitization)\n├── approvals.ts            # describeAction, executeAction, applyRevertForEntity, ...\n├── simulation.ts           # pure overlay-at-read-time helpers\n├── registry-utils.ts       # shared resolveTargets helper\n├── types.d.ts              # public Session/Area/Label/Device/Entity/Dashboard interfaces\n└── configurator/           # 5 picker UIs (instance / area / label / device / entity)\n```\n\n<!-- File: packages/gatekeeper-kit/README.md (Tokens: ~2530 | Category: plugin-manifest) -->\n# `@gadgets/gatekeeper-kit`\n\nShared building blocks for gatekeeper connect flows, credentials, actions, observations, cursors,\ncaching, and simulation. These modules replace security-sensitive plumbing that gatekeepers had\nimplemented separately.\n\nThe kit provides a pragmatic baseline for common gatekeeper behavior. Provider-specific or esoteric\nbehavior can use the canonical TypeScript interfaces directly while retaining whichever leaf\nmodules still fit.\n\n## Current scope\n\nOnly Layer 1 is shipped: independent leaf modules exposed through package subpaths. Import them à la\ncarte; none requires a gatekeeper assembly.\n\nLayer 2, including `KitUserAccountBase`, `KitVendorBase`, and `KitGatekeeperBase`, remains a proposal\nin [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md). No gatekeeper consumes it.\n\nThe code and tests define the shipped behavior. The plan records the design and the unshipped\nproposal.\n\n## Responsibility boundary\n\nThe kit owns provider-independent mechanisms inside each imported module. The gatekeeper owns\nprovider facts, policy, and the assembly around those modules. Importing a leaf does not transfer\nthe duties in the right-hand column.\nWorkshop owns connect completion ticket minting and redemption.\n\n| Concern | Kit owns | Gatekeeper owns |\n| --- | --- | --- |\n| Assembly and lifetime | Independent leaf contracts; no Layer 2 assembly is shipped. | Worker, account, and resource-facet RPC surfaces; stub disposal; keeping stateful kit objects stable for one Durable Object activation. |\n| Connect | Nonce generation and comparison, the two-stage handshake, staged reconnect escrow, hardened handoff HTML, and browser mutation guards. | Provider exchange, staged payload shape, exact live commit, provider cleanup policy, routes, and authorization parameters. |\n| Preview OAuth | Signed state, stable-to-preview callback relay, and return-host validation. | Deployment configuration, provider callback parameters, issuer checks, and retaining the exact redirect URI for code exchange. |\n| Credentials | Atomic credential records, refresh coalescing, identity and connection generations, replay, and rejection adjudication. | Grant shape and projection, the refresh callback (see OAuth token endpoint), and display-safe errors. |\n| Credential expiry and simple auth retry | Durable deduplication of expiry notifications and one-refresh/one-replay helpers. | Deciding what proves grant expiry beyond an OAuth `invalid_grant`, provider refresh and revoke calls, and choosing the coordinator flow versus the standalone retry helper. |\n| OAuth token endpoint | Token-request hardening, response parsing, PKCE, the default `invalid_grant` death rule, and the canonical refresh merge. | Endpoints and client authentication, provider death evidence beyond `invalid_grant`, non-canonical merges, and non-RFC revocation. |\n| Actions | Durable submission and resolution, serialization, journaling, retention mechanics, connection fences, and dependency tracking and refusal. | Approval text, provider calls, idempotency and reconciliation, action-specific simulation, revert semantics, and retention policy. |\n| Action files | Bounded chunk storage, integrity verification, aggregate accounting, deletion, and orphan-pruning mechanics. | Byte limits and key prefixes, keeping references in action records, describing the same bytes that will be applied, and releasing files with their records. |\n| Simulation | Ordered pending-action views, pure replay with explicit incomplete results, and durable provisional-ID allocation and binding. | Target extraction, state transitions, unsupported-effect policy, provider ID syntax, and projecting pending effects onto every affected read. |\n| Observations | Admission strategies, tracked-collection fencing, exclusion derivation, and the guarded authorization call. | Calling the gate for every read, truthful escaped descriptions, choosing the matching strategy and scope, ACL oracles, and collection canonicalization. |\n| Cursors | Serialized walks, buffering, a per-call provider fetch bound, common page/offset/token continuation, merging injected simulated rows, filtering hooks, and per-page authorization callbacks. | Provider page adapters, page limits and termination semantics, the injected rows and their order, resource lifetime, and the observation description for each returned page. |\n| Cache | Authority partitioning, stale-fill fencing, TTL storage, single-flight loads, and invalidation. | Cache families, names and keys, authority dimensions, TTLs, value projection, and deciding which reads are safe to cache. |\n| HTTP endpoints and responses | Operator-supplied endpoint normalization, dot-segment-safe path-segment encoding, common no-access probes, byte-capped text decoding, and best-effort error-body prefixes. | Host allowlists, redirect and header policy, provider id grammar, response schemas, provider errors, and binary or streaming limits. |\n\nWhen a provider does not fit a leaf, implement the canonical TypeScript interface directly and keep\nthe other leaves that still fit. Bypassing a leaf also means owning its guarantees for that concern;\ndo not call around a stateful module while relying on its journal, fence, or lifetime elsewhere.\n\n## Start here\n\n- For an OAuth-shaped provider, start with [Connect flows](USAGE.md#connect-flows), then read the\n  [credentials guide](USAGE.md#credentials). They cover browser handoff sequencing, account-side\n  storage, consumer-side RPC, refresh, replay, expiry, and action fences.\n- For provider writes, use [`./actions`](#module-inventory) and read\n  [Actions and files](USAGE.md#actions-and-files).\n- For every gatekeeper's observer methods, select a strategy from `./observers` and read\n  [Observations](USAGE.md#observations).\n- For details attached to one class, function, option, or error, read that export's JSDoc.\n\nImport from the narrow subpath:\n\n```ts\nimport {\n  CredentialCoordinator,\n  CredentialSource,\n} from \"@gadgets/gatekeeper-kit/credentials\";\n```\n\n## Module inventory\n\n| Subpath | Purpose | Use it when |\n| --- | --- | --- |\n| `./connect-nonce` | Nonce generation, expiry, and constant-time comparison. | A connect flow mints or checks its own nonce. The handshake and credential modules already use it. |\n| `./connect-handshake` | Two-stage `initiation` to `oauth` nonce storage, and the once-only `complete()` marker. | A connect link or form redirects through an OAuth provider, or has no single-use nonce to keep `complete()` from running twice. |\n| `./credential-stage` | Durable reconnect escrow, exact commit, and exact-stage cleanup. | A reconnect must remain inert until Workshop commits the completed stage. |\n| `./connect-pages` | Hardened connect HTML, escaping, and browser mutation guards. | A gatekeeper serves HTML from its own origin. |\n| `./credentials` | Account-side `CredentialCoordinator` and consumer-side `CredentialSource`. | An OAuth-shaped provider stores, refreshes, or rejects credentials. |\n| `./credential-expiry` | Durable, deduplicated `credentialsExpired()` notification. | An account has a Workshop connect callback to notify. |\n| `./auth-retry` | One refresh and replay without account adjudication. | A token flow has no `CredentialSource`; otherwise use `CredentialSource.run()`. |\n| `./cache` | Authority-partitioned Durable Object TTL caching. | Provider reads repeat and reconnects must fence stale fills. |\n| `./single-flight` | Keyed coalescing of concurrent work, without caching results. | Concurrent callers must share one in-flight request or write, such as two approvals that would each create the same resource. |\n| `./serial-queue` | FIFO serialization of async operations; a failed one never blocks the next. | Operations on shared state must not interleave, such as preparing changes that each check those queued before them. |\n| `./cursors` | Array, page-number, offset, and continuation-token cursors, with injected simulated rows (`CursorInjection`); `PageHookCursor`, which runs a hook on each page of a cursor the gatekeeper already built; and `SessionGitCache`, a git session's `GitCache` stub holder that advertises returned commit ids. | A session returns more rows than one RPC reply should carry. |\n| `./actions` | Action declaration, approval, application, retention, and journaling. | An operation has an externally visible side effect. |\n| `./action-files` | Bounded, integrity-checked action-file storage. | A queued action carries file bytes. Store only its `ActionFileReference` in the action. |\n| `./action-description` | Approval-text builder: prose plus exact typed fields under one byte budget, and the `descriptionIsComplete` claim. | An action's `describe` renders what it will write or send. |\n| `./simulation` | Pending-action replay and provisional-ID mapping. | An action continues with simulation and later reads must include its projected effect. |\n| `./observers` | Observer admission strategies and per-read authorization. | A gatekeeper implements its required observer methods. |\n| `./hook-delivery-queue` | Per-hook queue of pushed events: deduplication, retry with backoff, and bounded delivery runs from the driver's alarm. | A hook driver delivers provider pushes through `HookInitiator.startHook()`. |\n| `./oauth-client` | OAuth 2.0 token-endpoint client, PKCE, and refresh adapter. | A gatekeeper exchanges, refreshes, or revokes OAuth 2.0 tokens. |\n| `./preview-oauth` | Signed OAuth state and stable-to-preview callback relay. | Preview Workers share one callback registered with the OAuth provider. |\n| `./endpoint` | User-supplied provider endpoint normalization, and agent-supplied path-segment encoding (`pathSegment`, `pathSegments`). | A user enters a self-hosted provider URL, or an agent-supplied id goes into a request path. |\n| `./http-errors` | HTTP access-error classification and ACL probes. | A verifier distinguishes no access from provider failure. |\n| `./response-body` | Strict byte-capped body decoding, and `readTextPrefix` for error excerpts. | A gatekeeper reads any provider response body, or a connect form posted to a route that reads the body before checking the nonce. |\n| `./git-transport` | Git smart-HTTP framing: protocol-v2 fetch (pkt-line, filter specs, sideband demux into a raw pack stream) and send-pack ref updates, plus `simulateBranchHead` for a branch with queued pushes. | A gatekeeper implements `Gatekeeper.gitPull()` or a `push` action against a git host; it supplies only the URL, auth, and timeouts as a fetch callback. |\n| `./git-pull-transfer` | `startGitPull`: a one-shot raw PACK stream with a separate RPC-visible terminal status. | A gatekeeper implements `Gatekeeper.gitPull()` and must preserve provider errors across Workers RPC. |\n| `./git-objects` | Commit-id validation, raw commit-object parsing, and commit advertising (`advertiseCommits`, and `advertisePages` as a `PageHookCursor` hook). | A session returns commit ids (advertise them) or simulates reads of commits queued for push from their local bytes. |\n| `./git-diff` | Tree-to-tree diff over an injected `TreeDiffSource`, and `parseUnifiedPatch` for provider patches, both producing the unified-patch hunk shape agents see. | A pull/merge request's source branch has queued pushes and the provider cannot compute the diff yet, or a provider returns unified patches. |\n\n## Internal modules\n\nThe package does not export `kv`, `positive-int`, `per-storage`, `action-journal`, or\n`observer-tracker`. The last two are re-exported through `./actions` and `./observers`.\n\n## More documentation\n\n- [`USAGE.md`](USAGE.md): integration sequencing, storage, bounds, and operational sharp edges.\n- Exported-symbol JSDoc: exact API contracts and examples.\n- [`plans/gatekeeper-kit.md`](../../plans/gatekeeper-kit.md): design record and Layer 2 proposal.\n- [`AGENTS.md`](AGENTS.md): package-specific contributor constraints and verification commands.\n\n<!-- File: packages/gatekeeper-mcp-portal/README.md (Tokens: ~3375 | Category: plugin-manifest) -->\n# MCP Server Portals gatekeeper\n\nConnects the deployment's own MCP portal as a Gadgets capability. An administrator configures one\nURL; everyone in the organization then reaches every MCP server the organization has approved\nthrough it, and no user ever types an endpoint. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_MCP_PORTAL` binding.\n\nThe intended deployment is a\n[Cloudflare MCP server portal](https://developers.cloudflare.com/cloudflare-one/access-controls/ai-controls/mcp-portals/):\nAccess decides who may connect, Cloudflare Gateway logs and inspects what crosses, and this\nconnector turns what is behind it into typed capabilities that Gadgets can grant one server at a\ntime.\n\nFor endpoints a user supplies themselves, use [`gatekeeper-mcp`](../gatekeeper-mcp/README.md).\n\n## What it provides\n\nOne resource type, **MCP portal server**, at two grant breadths. A grant always names one upstream\nserver; \"everything the portal offers\" is not offered, since it would hand a Gadget every tool of\nevery system the organization has connected in one click.\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool of one upstream server, including ones it adds later | `<endpoint>#server=github` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools of that server | `<endpoint>#server=github&tool=github_a&tool=github_b` | `Mcp<Name><tag>Session` |\n\n`<tag>` is four hex characters derived from the resource URL. It matters most here: two grants\npinning different tools of one upstream server share both the name and the endpoint, so the scope is\nthe only thing that distinguishes them.\n\nThe session API — a typed method per described tool, plus `callTool`, `getActionResult`, and\n`listTools` with progressive search/name options — is the same as\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#what-it-provides).\n\nA server-wide grant can cover more tools than one catalog describes, so its authority is not limited\nto the ones with generated signatures. `listTools({ search })` searches beyond the bounded preview\nand returns up to 20 compact matches within the shared 5,000-tool / 4 MiB discovery scan;\n`listTools({ name })` loads one exact bounded definition, and `callTool` resolves that name under the\nsame bound before dispatch. Every path rejects names outside the\nbinding's scope before loading the catalog or contacting the endpoint. Discovery and read results are\nrecorded as observations; writes retain the ordinary approval flow.\n\nScoping to one server also shrinks what the agent reads: only that server's tools are rendered as\nsignatures, and only as many as the budget describes. Additional tools are discovered on demand\nwithin the explicit scan limits above; exceeding a limit fails rather than pretending a tool is absent.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `MCP_PORTAL_URL` | The portal's MCP endpoint. Unset means the connector hides itself. |\n| `MCP_PORTAL_NAME` | Display name in the connector list and every approval prompt. Defaults to `MCP Server Portal (<host>)`. |\n| `MCP_PORTAL_AUTH` | `oauth` (default), `none`, or `token`. |\n| `MCP_PORTAL_TOKEN` | Secret bearer token, for `MCP_PORTAL_AUTH: \"token\"`. |\n| `MCP_PORTAL_TRUST_ANNOTATIONS` | `true` to let upstream tool annotations drive auto-approval. Off by default; see below. |\n| `MCP_PORTAL_HIDDEN_SERVER_IDS` | Comma-separated upstream server IDs to hide from the configurator and refuse at the grant boundary. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, for the portal and every OAuth URL discovered from it. Local dev only. |\n\nThe portal must expose upstream tools directly. Use a portal where Code Mode is off or opt-in, or\nappend `?codemode=off` when its policy is default-on. Enforced Code Mode is unsupported. Do not add\nan `optimize_context` parameter or opt in to Code Mode on `MCP_PORTAL_URL`.\n\nOnly `MCP_ALLOW_INSECURE` is set in the repo's `cloudflare.config.ts`, pinned to `\"false\"` so the default\nis explicit rather than merely absent. None of the others is, and a portal URL committed there would\nbecome the default for every deployment of this repo and would send their users' OAuth flows to\nwhichever host it named, so it belongs in the deployment's own configuration — for Cloudflare's\ninternal deployment, the per-package overrides in `gadgets-internal`.\n\nUnconfigured, `getSupportedResources()` returns nothing and the Workshop drops the vendor. A\n`MCP_PORTAL_URL` that cannot be used — a non-`https` typo or a URL containing `username:password`,\nsay — is treated the same way, so a misconfiguration hides the connector rather than producing one\nthat fails on first use or copying URL credentials into account state and configurator fields.\n\nChanging `MCP_PORTAL_URL` on a deployment that already has connected accounts is a **repoint**.\nExisting bindings fail closed at once: the minting path checks facet props against current\nconfiguration, and an already-minted facet must name its endpoint when asking the account for\ncredentials, which the account refuses after it has moved. The user recovers by reconnecting, which\nis the one endpoint change an account will accept. It is accepted only because the new endpoint\ncomes from this Worker's configuration rather than from a form. Nothing held for the old portal\nsurvives the move: tokens, the transport session, and any in-progress authorization are dropped, so\nthe user re-authorizes against the new host. The account advances a persisted generation before\nprobing; refreshes, expiry notifications, and session writes that started under the old generation\nare ignored when they eventually return. Always-approve action kinds also include the exact endpoint,\nso consent for the old portal does not carry over to the new one.\n\n`MCP_PORTAL_TRUST_ANNOTATIONS` is read at each point of use (`portalTrust(env)`) and never\npersisted on an account or a binding's props, so clearing it de-escalates every existing connection\non the next call. Setting it does not retroactively auto-apply anything; the user must still enable\na rule for each action kind.\n\n## How the connect flow works\n\nThere is no connect form: the endpoint is a deployment setting, so pressing \"connect\" goes straight\nto the portal's own sign-in. Under `MCP_PORTAL_AUTH: \"oauth\"` the gatekeeper runs the same\ndiscovery chain as [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#how-the-connect-flow-works)\nagainst the portal; under `\"token\"` it presents `MCP_PORTAL_TOKEN` and no user interaction is\nneeded; under `\"none\"` it connects unauthenticated.\n\nThe account records `provenance: \"deployment\"`, which is what keeps an upstream server from renaming\nitself over `MCP_PORTAL_NAME` in every approval prompt.\n\n## Granting\n\nThe configurator asks which server behind the portal, how broad the grant is, and — if it is\npinned — which tools:\n\n```\nServer · Which server behind this portal to grant. Its tools appear next.\n[ 🔍 GitHub ]\n\nTools · Choose how much of this server the Gadget may call.\n(•) All tools      Every tool this server offers, including ones it adds later.\n( ) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · Read-only tools return data straight away. The rest queue for your approval.\n[ 🔍 Filter tools...                                                   ]  12 of 12 selected\n☑ List issues                                                                     read-only\n☑ Create issue                                                               needs approval\n```\n\n**All tools** is the default. The breadth is asked outright rather than inferred from whether every\nbox is ticked, since the two look identical and diverge as soon as the server publishes another\ntool. A portal with one server behind it collapses to the tool questions alone, because the choice\nis made for the user rather than skipped — the grant still records that server.\n\nEvery grant here names exactly one upstream server. There is no breadth that spans the portal, so a\nconfigured endpoint with no upstream servers — a plain MCP server, or a portal that currently fronts\nnothing — is **not grantable through this connector**: the configurator has no server to offer and\nthe form stays unsubmittable. That is deliberate. The alternative is a grant recorded against the\nbare endpoint, which would silently include every server added to it later. Point a plain MCP\nendpoint at the **MCP** connector (`gatekeeper-mcp`) instead, which is built for exactly that and\noffers endpoint-wide and per-tool grants.\n\n## How upstream servers are recovered\n\nA portal flattens every upstream server's tools into one `tools/list`, so the seam has to be\nrecovered from two facts in the portal's documented contract:\n\n1. Every tool is named `{server_id}_{original_name}`, split on the first underscore only, and an\n   alias replaces the tool name but never the server-id prefix. Membership is therefore a pure\n   string test needing no network call, so a server scope cannot fail open on a transient error.\n2. `portal_list_servers` is available in every portal session and returns each upstream server's id,\n   name, and enabled state.\n\nDetection is a capability probe — does the endpoint offer `portal_list_servers`? — not a hostname\nmatch, so it works for a custom portal hostname and for any other aggregator adopting the\nconvention. A *truncated* listing counts as a portal whether or not the probe tool is in it:\n`tools/list` is unordered, so concluding \"not a portal\" because the evidence fell past the cut would\nfail open on the `portal_*` exclusion below. Truncation is reported by the client rather than\ninferred from the tool count, because either cap can stop a listing — the count the caller asked for,\nor the 96 KiB UTF-8 budget, and the latter can cut a listing of verbose tools short while leaving an\narray that looks complete. The byte budget leaves 32 KiB below Durable Object's per-value limit for\nthe cache wrapper and serialization overhead; if storage nevertheless rejects the cache value, the\nfresh catalog is still used for that operation rather than turning a cache miss into a failure.\n\n### Surveying a portal too large for one catalog\n\nThe configurator normally gets server names directly from `portal_list_servers`, without surveying\nevery upstream tool. If that response is unavailable or only partly understood, it falls back to a\n**name-only tool index** of up to 1,000 entries. The index detects the portal and recovers server\nmembership from tool-name prefixes; it carries no descriptions, schemas, or policy claims, so the\n96 KiB result budget covers as many names as possible. A truncated fallback cannot establish the\ncomplete server list and blocks the form rather than presenting a partial list as complete.\n\nAfter a server is selected, a separate filtered scan returns up to 200 compact summaries from that\nserver. Each summary carries its bounded title, description, and annotations, and is classified\nthrough the shared `tools.ts` trust boundary before it becomes a read-versus-approval label. The\nfilter is applied before result budgets, so unrelated servers cannot crowd the selected one out.\nOnly the returned prefix is offered for an individual-tool grant; additional tools require the\nserver-wide grant, and a call resolves the full definition before approval or dispatch.\n\nIndex entries are typed separately (`IndexedTool`) to distinguish name-only survey results from tool\ndefinitions rendered into approval prompts or handed to an agent.\n\nThe server list is advisory: it supplies display names and ordering while tool-name prefixes remain\nthe authority on membership, and a failed call degrades to bare ids recovered from tool prefixes.\nThe gatekeeper makes that call\nwhile building a form, so it does not pass through the approval queue. Failing to reach the portal\nat all is different, and blocks the grant rather than falling back to the bare endpoint — the\nconfigurator reports it and stays unsubmittable.\n\n`portal_*` tools are excluded from every grant at every scope. `portal_toggle_servers` and friends\nchange which upstream servers the session can reach, so granting one would let a Gadget widen its\nown authority. This is a capability-boundary rule, not a policy preference, and is not configurable.\n\n## Approvals and sharing\n\nIdentical to [`gatekeeper-mcp`](../gatekeeper-mcp/README.md#approvals-and-sharing), except for the\ntrust tier. A portal is an aggregator: `destructiveHint` and `idempotentHint` are written by\nwhichever upstream server it fronts, servers the administrator who chose the portal never reviewed,\nand any one of them could self-declare both and get writes that skip the approval prompt. So the\ntier is `byo` unless a deployment sets `MCP_PORTAL_TRUST_ANNOTATIONS=true`. That flag is the\n\"trusted server\" assertion MCP's guidance asks for, and it has to be about the upstreams themselves,\nnot just the portal in front of them.\n\n`addObserver` refuses everyone, as it does for user-supplied endpoints. Reaching the portal is not\nthe same as being allowed to see a particular tool result, and the Gadget runs on the owner's\ncredentials regardless. A sharper check is available here than for a bare endpoint —\n`portal_list_servers` is per-user and Access-filtered, so it could confirm a collaborator may reach\nthe same server — but that is still server-granular, not record-granular. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\n## Notes and current limitations\n\nThe limitations in\n[`gatekeeper-mcp`](../gatekeeper-mcp/README.md#notes-and-current-limitations) apply here too: no\nsimulation, no revert, no hooks, no scoping below tool names, and `tools/*` only. SSRF is enforced\nthe same way, by `global_fetch_strictly_public` rather than by hostname patterns. In addition:\n\n- **One portal per deployment.** `MCP_PORTAL_URL` is a single value; a second portal needs a\n  second Worker.\n- **Server ids come from tool-name prefixes.** An aggregator that flattens tools without prefixing\n  them exposes no upstream seam, so this connector finds no server to scope a grant to and the form\n  stays unsubmittable. It is not granted as a whole endpoint — that grant does not exist here. Use\n  the **MCP** connector (`gatekeeper-mcp`) for such an endpoint, where endpoint-wide breadth is an\n  explicit, reviewable choice.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/portal.ts` | Vendor, account DO, user, gatekeeper facet, session, configurator RPC |\n| `src/config.ts` | Reading and validating the deployment's portal configuration |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nEverything else comes from [`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-portal-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-portal-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n\n<!-- File: packages/gatekeeper-mcp/README.md (Tokens: ~3205 | Category: plugin-manifest) -->\n# MCP gatekeeper\n\nConnects any [Model Context Protocol](https://modelcontextprotocol.io) server as a Gadgets\ncapability. The user pastes an endpoint URL, the gatekeeper runs the OAuth discovery chain against\nit, and each of the server's tools becomes a typed method on the session. One Worker covers every\nMCP server, so a server needs no Gadgets-specific work to be usable from a Gadget. Runs as its own\nCloudflare Worker and is auto-discovered by the backend from its `GATEKEEPER_MCP` binding.\n\nIf your organization runs an MCP server portal, use\n[`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) instead: it takes one\nadministrator-configured URL, needs no user to paste an endpoint, and scopes a grant to a single\nupstream server behind the portal.\n\n## What it provides\n\nOne resource type, **Any MCP server**, at two grant breadths:\n\n| Granularity | Resource URL | Session type |\n| --- | --- | --- |\n| **Server** — every tool the endpoint offers, including ones it adds later | `<endpoint>` | `Mcp<Name><tag>Session` |\n| **Named tools** — only the listed tools; anything else is refused | `<endpoint>#tool=a&tool=b` | `Mcp<Name><tag>Session` |\n\nA `#server=` fragment is refused here; scoping to one server behind a portal is the MCP Server Portals\nconnector's grammar. `@gadgets/mcp-shared/scope` owns the grammar and the enforcement.\n\n`<Name>` comes from the endpoint's host and is for reading, not identity — `acme.com` and `acme.io`\nboth give `Acme`. `<tag>` is four hex characters derived from the resource URL above, so two grants\nthat differ in what they may call get different type names. The agent is shown each binding's types\nin its own block with no way to compare them, so a shared name would present two unrelated tool\nsurfaces as one.\n\nThe session carries a named method per tool, generated from the server's own `inputSchema`:\n\nThe binding is named after the endpoint's host, so `https://mcp.linear.app/mcp` suggests\n`MCP_LINEAR` (see `server-id.ts`):\n\n```ts\n// Read-only tool: resolves immediately, recorded as an observation.\nlet search = await env.MCP_LINEAR.searchIssues({ query: \"state:open\" });\nif (search.status !== \"ok\") throw new Error(search.message);\nlet { text } = search;\n\n// Anything else: queued for approval, result collected afterwards.\nlet queued = await env.MCP_LINEAR.createIssue({ title: \"Fix the thing\" });\nif (queued.status === \"pending\") {\n  let outcome = await env.MCP_LINEAR.getActionResult(queued.actionId);\n}\n\n// The same call by its exact wire name, for a tool whose name cannot be a method.\nawait env.MCP_LINEAR.callTool(\"search_issues\", { query: \"state:open\" });\n```\n\nEach generated method is a one-line delegate to `callTool`, so the scope check, approval queue, and\nobservation record stay in one place. Tools whose names the RPC layer cannot deliver (`then`, `map`,\n`dup`), names that collide with session methods (`listTools`, `callTool`, `getActionResult`), names\nthat are not identifiers (`2fa`), and both\nsides of a case collision (`list_issues` and `listIssues`) get no method and remain callable through\n`callTool`.\n\nThe agent normally discovers tools statically: `describeGatekeeper()` sends it the binding name and\nthe bounded generated `.d.ts`, where each method carries the tool's own description as JSDoc and\nstates whether calling it needs approval. A server can publish more definitions than that bounded\ncatalog holds: `listTools({ search })` returns up to 20 compact summaries from a bounded scan of the\ngrant, limited to 5,000 tools / 4 MiB; `listTools({ name })` fetches one exact granted definition and\nschema under the same bound, and `callTool()` invokes it by wire name.\n`listTools()` remains the list of definitions currently described in the generated surface.\n\nSee `src/types.d.ts` in `@gadgets/mcp-shared` for the base session API.\n\n## Configuration\n\n| Variable | Meaning |\n| --- | --- |\n| `BASE_URL` | Public base URL of this Worker, for OAuth redirects. |\n| `MCP_CLIENT_NAME` | Client name sent in `initialize` and dynamic client registration. |\n| `MCP_ALLOW_INSECURE` | `\"true\"` to disable the endpoint checks entirely: permits `http://` **and** private, loopback, link-local, and cloud-metadata hosts, on the endpoint and on every OAuth URL discovered from it. Local dev only. |\n\nThere is nothing to configure per server: users supply endpoints, and an administrator's only lever\nis whether this connector is offered at all, in the Gatekeepers admin panel. There is no server\ncatalog — a deployment that wants to offer a chosen set of servers should front them with a portal\nand use [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md).\n\nFor local development no credentials are needed. Set `MCP_ALLOW_INSECURE=true` in the repo-root\n`.dev.vars` to connect a server running on localhost.\n\n## How the connect flow works\n\n1. The user starts a connection and gets a form asking for the server's endpoint URL. The URL is\n   validated against the host blocklist in `endpoint.ts` (no private, loopback, or metadata hosts;\n   HTTPS required unless `MCP_ALLOW_INSECURE`). The form says plainly that connecting a server is a\n   decision to trust it, since the server's own annotations decide which of its tools run without\n   asking (see below).\n2. The gatekeeper opens a Streamable HTTP session and calls `initialize`. A server that completes\n   the handshake unauthenticated is recorded as public and no token is demanded of it later.\n3. A `401` starts the official MCP client's OAuth flow:\n\n   ```\n   401 + WWW-Authenticate  ->  protected resource metadata   (RFC 9728)\n                           ->  authorization server metadata (RFC 8414)\n                           ->  dynamic client registration   (RFC 7591)\n                           ->  authorization code + PKCE     (RFC 7636)\n                               + resource indicator          (RFC 8707)\n   ```\n\n   The SDK falls back to the conventional\n   paths (`/authorize`, `/token`, `/register`) that the Workers OAuth provider and most MCP servers\n   use. That means a server which does not implement dynamic client registration is not detected as\n   such up front — the synthesized `/register` is tried and refuses, so the failure reads as a\n   rejected registration rather than a missing capability. The remedy is the same either way:\n   connect a server that supports registration, or reach it through a portal an administrator has\n   configured with a preissued token.\n4. Tokens are stored in the `McpAccount` Durable Object and refreshed proactively, before the\n   recorded expiry. Nothing outside this Worker can obtain one.\n\n   A `401` mid-session is not a refresh trigger: it means the server rejected a token this Worker\n   believed was valid, so the account is marked as needing attention and the user is asked to\n   reconnect. Refreshing in response would paper over a revoked or repudiated grant. Refresh\n   failures are classified — only the authorization server's own verdict on the credential\n   (`invalid_grant` and friends) marks the account expired, while transport and unrecognised failures\n   leave it alone to be retried.\n\nThe endpoint is fixed at first connect. Reconnecting an account cannot point it at a different\nserver, since the binding's props still name the original. (The MCP Server Portals connector is the\nexception: its endpoint comes from deployment configuration rather than a form, so a reconnect there\nmay adopt a repointed gateway. See its README.)\n\n## Scoping a binding\n\nThe configurator asks how broad the grant is and, if it is pinned, which tools:\n\n```\nTools · Choose how much of this server the Gadget may call.\n( ) All tools      Every tool this server offers (14 today), including ones it adds later.\n(•) Choose tools   Only the tools you tick. Anything else is refused, including tools added later.\n\nAllowed tools · 3 selected. Read-only tools return data straight away; the rest queue for approval.\n[ 🔍 Filter tools...                                  ]  3 of 14 selected   Select all   Clear\n☑ Search issues                                                                        read-only\n☑ Create issue                                                                    needs approval\n```\n\nThe breadth is asked outright rather than inferred from whether every box is ticked: \"all 14 ticked\"\nand \"these 14 by name\" look identical and diverge as soon as the server publishes a fifteenth. The\nlist stays on screen in both modes, disabled under **All tools**.\n\nTools named `portal_*` are never grantable on an endpoint that identifies itself as a portal, since\nthey let a session change which upstream servers it can reach.\n\n## Approvals and sharing\n\nA call to a tool the server annotates `readOnlyHint: true` is an observation and returns straight\naway. Every other call is queued via `submitAction()` and reaches the server only when the Overseer\ncalls `applyAction()`. Annotations are optional in MCP and most servers publish none; every hint is\ntested with `=== true` or `=== false`, so an unannotated tool is an action, needs approval, and can\nnever auto-apply.\n\nMCP's own guidance is that a client must treat tool annotations as untrusted unless they come from a\ntrusted server. The trust tier is that distinction, and it is the deployment's to make rather than\nthe server's: endpoints here are user-supplied rather than administrator-vetted, so they get `byo`.\n`readOnlyHint` classifies reads, but no claim the server makes can auto-apply a write.\n\nHonouring `readOnlyHint` on `byo` is a knowing departure from that rule, and the limit of what this\nconnector can promise. A server that labels a destructive tool `readOnlyHint: true` gets that tool\nrun without a prompt. Refusing the hint would mean an approval prompt for every `search` and `list`,\nwhich makes the connector unusable for the thing people connect it to do; and it would buy less than\nit looks, since a dishonest server can act on any call, including one the user did approve. What the\ntier does guarantee is that no BYO server gets a write *auto-applied*, and that every call trusted on\nthe server's word is recorded as such.\n\nSo the trust decision is made once, by the person pasting the URL, and the connect form says so\nplainly: an annotation is only as trustworthy as the server that sent it. Auto-approval requires a\n`vetted` endpoint, which only [`gatekeeper-mcp-portal`](../gatekeeper-mcp-portal/README.md) can\nproduce. Every call records which side classified it (`McpToolInfo.classifiedBy`), so an audit can\nfind each one taken on the server's word. See `tools.ts` in `@gadgets/mcp-shared` for the tier\nrules.\n\nA Gadget bound to an MCP server can only be opened by its owner: `addObserver` refuses\nunconditionally. Being able to authenticate to a server is not evidence of being allowed to see what\nthe *owner* read from it, and the Gadget runs on the owner's credentials throughout. Writes still\nwork — the alternative, marking every observation `containsRestrictedData`, would set that\nflag, whose restricted mode blocks every action for the rest of the session. See\n[`sharing-policy.ts`](../mcp-shared/src/sharing-policy.ts).\n\nTo share the work rather than the binding, publish the Gadget as a blueprint and let each person\nconnect their own server.\n\n## Notes and current limitations\n\n- **No simulation.** MCP describes no way to predict a tool's effect, so a queued call is not\n  reflected in later reads. `awaitDecision` is set and the agent's turn suspends until the user\n  decides.\n- **No revert.** MCP describes no inverse for a tool call, so `implementsRevert` is false.\n- **No hooks.** `notifications/tools/list_changed` is session-scoped; a Gadget hook is durable and\n  must survive restarts.\n- **No scoping below tool names.** MCP tools take arguments, not capabilities, so \"this repo only\"\n  cannot be expressed. A list of allowed tool names is the narrowest grant available.\n- **Only `tools/*`.** Prompts, resources, sampling, and elicitation are not implemented. Sampling\n  and elicitation would let a server drive the agent.\n- **Tool-list changes are adopted, not pinned.** A changed list is taken and logged\n  (`catalog.changed`), since refusing to see new tools would break working Gadgets. A binding with a\n  tool scope cannot widen this way, which is the reason to prefer **Choose tools** for anything that\n  writes.\n- **SSRF is enforced after DNS, not by the blocklist.** The hostname patterns in `endpoint.ts` are a\n  legible refusal at connect time; they cannot see through a public hostname that resolves, or\n  rebinds, to a private address. The actual boundary is the `global_fetch_strictly_public`\n  compatibility flag in `cloudflare.config.ts`, which makes workerd reject reserved IP ranges after\n  resolution on every request and redirect hop. It does not apply under `wrangler dev`, which is\n  what keeps `MCP_ALLOW_INSECURE` usable locally.\n- **Sharing UI reports late.** `GadgetMetadata.containsRestrictedData` derives only from\n  `ObservationDescription.containsRestrictedData`, so creating a share key appears to succeed and\n  fails when the recipient opens it (their observer verification is refused). Fixing this needs a\n  kernel change.\n\n## Layout\n\n| File | Purpose |\n| --- | --- |\n| `src/mcp.ts` | Vendor, account DO, user, verifier, gatekeeper facet, session |\n| `src/connect-form.ts` | The endpoint prompt served during connect |\n| `src/server-id.ts` | Endpoint to display slug, for the binding name and session type |\n| `src/configurator/` | The grant UI (compiled into `src/generated/`) |\n\nThe MCP client, OAuth, tool classification, generated TypeScript, and the scope grammar come from\n[`@gadgets/mcp-shared`](../mcp-shared/README.md).\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/mcp-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/mcp-gatekeeper test:run    # vitest\n```\n\nThe Worker is run via the root `pnpm dev-server`, not directly.\n\n<!-- File: packages/gatekeeper-notion/README.md (Tokens: ~712 | Category: plugin-manifest) -->\n# Notion gatekeeper\n\nMediates a Gadget's access to a user's [Notion](https://www.notion.so) workspace: pages and\ndatabases. Runs as its own Cloudflare Worker and is auto-discovered by the backend from its\n`GATEKEEPER_NOTION` binding.\n\n## Auth\n\nOAuth 2.0 public connection. Configure a Notion **public integration** and provide its client\ncredentials to the worker as `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts`\nmaps `NOTION_CLIENT_ID` / `NOTION_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nThe integration's **redirect URI** must match `<BASE_URL>/oauth`, which in local dev defaults to\n`http://localhost:8787/gatekeeper/notion/oauth`. Recommended capabilities: read/insert/update\ncontent, read/insert comments, read user info (no email needed — `providesAuth` is false).\n\nThe connect flow uses a two-phase nonce (initiation → OAuth) and stores the access + refresh\ntokens in a `UserAccount` Durable Object; access tokens are refreshed on a 401.\n\n## Resources\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all) | `NotionWorkspace` |\n| A page or database | `https://www.notion.so/:path+` | `NotionPage` or `NotionDatabase` (detected server-side) |\n\nOnly the pages/databases the user shares with the integration during the OAuth page-picker are\nreachable.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Page bodies are\nexchanged as Markdown and property values use a simplified union. Highlights:\n\n- `NotionWorkspace`: `search`, `getPage`, `getDatabase`, `createPage`, `listUsers`\n- `NotionPage`: `getMetadata`/`getProperties`/`getContent`, `appendContent`, `setTitle`/\n  `setProperties`/`setIcon`, `createSubPage`, `archive`/`restore`, comments, `listChildPages`\n- `NotionDatabase`: `getSchema`, `query` (typed filters/sorts), `getPage`, `createPage`\n\n## Approvals, caching & simulation\n\nEvery read calls `authorizeObservation()`; every write is staged via `submitAction()` and only\nperformed in `applyAction()`. Reads simulate pending (unapproved) writes so a Gadget sees its own\nchanges immediately — including provisional IDs for created pages. Page/database/data-source/user\nresponses are cached in DO storage with short TTLs.\n\n## Data sources\n\nNotion's newer model splits a database into one or more **data sources**. Database `query`,\n`getSchema`, and row creation resolve the database's primary data source under Notion-Version\n`2025-09-03`; pages/blocks/comments/search use `2022-06-28` so user-facing IDs/URLs stay\nconsistent. This split is hidden from the Session API.\n\n## Build & test\n\n```\npnpm exec vp run -F @gadgets/notion-gatekeeper build   # build:configurator + tsc\npnpm --filter @gadgets/notion-gatekeeper test:run    # vitest\n```\n\n<!-- File: packages/gatekeeper-scheduler/README.md (Tokens: ~2536 | Category: plugin-manifest) -->\n# Scheduled Tasks\n\nScheduled Tasks is an ambient Gatekeeper that lets workspace code register persistent callbacks for\nelapsed intervals, wall-clock recurrences, and one-time runs. Each account also gets a read-only\nmanagement app at `/gatekeepers/scheduler`.\n\n## User flow\n\n1. Ask the agent to create a scheduled task, or open **Scheduled** and choose a starter prompt.\n2. Confirm what the task should do, the workspace and resources it may use, its cadence, and its\n   IANA timezone when it uses wall-clock time.\n3. The agent registers a persistent callback. Registration creates a disabled hook and returns its\n   schedule ID; it does not start the schedule.\n4. Enable the hook in the Workshop's Connections UI.\n5. Use **Scheduled** to search and inspect schedules across the account. Select a schedule row to\n   open its workspace.\n\nThe Scheduler app is intentionally read-only. Enabling and disabling remain in Connections; the app\ndoes not provide editing, pausing, deletion, run history, or a second hook toggle.\n\n## Agent API\n\nThe ambient binding exposes `ScheduleSession`. The exact agent-facing contract and examples live in\n[`src/types.d.ts`](src/types.d.ts).\n\n```ts\n// executeCode, where `callback` is a persistent stub for the params `{ type: \"dailyBrief\" }`\nconst scheduleId = await env.SCHEDULER.calendarAt(\n  {\n    timeZone: \"America/Chicago\",\n    freq: \"weekly\",\n    byDay: [\"MO\", \"TU\", \"WE\", \"TH\", \"FR\"],\n    hour: 8,\n    minute: 0,\n  },\n  callback,\n  {\n    title: \"Daily brief\",\n    description: \"Prepare the morning calendar and inbox brief.\",\n    occurrences: { count: 10 },\n  },\n);\n```\n\nUse the three registration methods according to the user's intent:\n\n- `every(everyMs, callback, options)` uses elapsed UTC time. The minimum interval is 60 seconds.\n- `calendarAt(rule, callback, options)` follows local wall-clock time and requires an explicit IANA\n  timezone. It supports hourly, daily, and weekly rules.\n- `runAt(when, callback, options)` runs once at an absolute epoch-millisecond timestamp or an\n  explicit timezone-aware wall-clock time.\n\nRecurring `every()` and `calendarAt()` calls may stop after a finite bound. Give one or the other,\nnever both; `runAt()` accepts neither.\n\n- `occurrences: { count: 10 }` allows ten logical scheduled occurrences.\n- `occurrences: { until: { timeZone, year, month, day, hour, minute } }` allows occurrences through\n  that instant, inclusively. An absolute epoch-millisecond `until` is also accepted. Registration\n  rejects a cutoff that precedes the schedule's first occurrence.\n\nThe count bounds *due slots*, not successful runs. A slot consumes one count as soon as it becomes\ndue and takes a `runId`, even if admission or callback delivery then fails; admission failures skip\nthe slot without retrying it. Retries reuse the same `runId` and do not consume another count.\nMissed occurrences remain skipped and do not count.\n\nA bound is checked against live state, so a schedule that reaches it reports `completed`. A schedule\nwhose cutoff already passed by the time its hook is enabled reports `expired` instead: it never got\na slot. A schedule whose callback exhausts its eight attempts reports `dead` and stops there,\nwhether or not the bound was reached — a `count: 10` schedule that dies on its third occurrence\nnever reaches the fourth. Disabling a hook drops its driver state, so re-enabling the same schedule\nrestarts the count — treat `count` as a bound per enablement, not a lifetime guarantee.\n\n`list()` returns active and terminal schedules for enabled hooks in the current workspace only. It\ndoes not expose schedules from other workspaces in the account.\n\n## Persistent callbacks and retries\n\nCallbacks implement `ScheduledTaskHook.onSchedule()` and must be made persistent before\nregistration: `this.ctx.restore(params)` inside the Gadget, or through the Gadget's binding from\n`executeCode`, as the agent's instructions on persistent stubs describe. Each firing contains:\n\n- `scheduleId`, the stable registration ID;\n- `runId`, stable across retries of one logical occurrence;\n- `scheduledTime`, the planned Unix epoch time;\n- `actualTime`, the current delivery-attempt time; and\n- `timeZone`, the schedule's display timezone (`UTC` for elapsed intervals and numeric one-shots).\n\nCallback delivery is best-effort within a bounded retry window and may occur more than once. Callback\ncode should use `runId` as an idempotency key. Authorization or callback failures retry up to eight\ntotal attempts with exponential delays beginning at one minute and capped at one hour. Exhausted\nschedules enter the **Needs attention** state.\n\nThe Workshop admission check runs before every attempt. If the hook, gatekeeper, or account is no\nlonger allowed, the occurrence is skipped without consuming a callback attempt. Recurring schedules\nadvance to their next future occurrence; a due one-shot expires.\n\n## Cadence behavior\n\n- Recurrences preserve the phase established at registration.\n- Missed occurrences are skipped, not replayed or caught up.\n- Fixed intervals measure elapsed time and therefore shift relative to local clocks across DST.\n- Calendar schedules retain their requested local wall-clock cadence across DST.\n- A nonexistent spring-forward time moves forward by the transition gap.\n- An ambiguous fall-back time uses the earlier instant and fires once.\n- Date-less wall-clock one-shots resolve to the next future occurrence of that local time.\n\nAlways ask the user for the timezone of a wall-clock schedule. Do not infer it from locale or silently\nchoose UTC.\n\n## Lifecycle\n\nRegistration only binds a disabled Workshop hook; it writes no schedule row. Enabling records the\ntarget workspace ID and optional gadget ID, then creates the account-driver row and arms its alarm.\nDisabling removes the row and its stored capabilities. Re-enabling creates fresh active state,\npreserves the original recurrence phase, and does not replay missed work. A repeated enable while\nthe row still exists instead preserves its current schedule state and refreshes its activation details.\n\nSuccessful one-shots become **Finished**. Past or rejected one-shots become expired. Terminal rows\nremain visible and consume enabled-schedule quota until their hook is disabled in Connections.\nCreating a workspace from a blueprint does not copy schedules or capabilities: the new workspace must\nregister its callback and receive fresh enablement.\n\nDisconnecting the Scheduler account revokes its driver, deletes schedule state, and leaves a permanent\ntombstone so retained stale controllers cannot recreate the account.\n\n## Management app\n\nThe account advertises its UI through the generic `AccountDescription.providesUi` mechanism, the same\nmechanism used by other Gatekeeper management apps. The Workshop discovers it dynamically and hosts\nthe single-file app in an opaque-origin, network-isolated `srcDoc` frame.\n\nThe app can only call its account-scoped, read-only `list()` capability plus bounded host methods for\ntheme updates, workspace-title resolution, navigation, and starter prompts. Pages contain at most 100\nschedules. Search is case-insensitive over title and description and is limited to 200 characters.\nCursors are opaque, bounded, and tied to the normalized search and status filters.\n\nThe **All**, **Active**, **Needs attention**, and **Finished** tabs map to the projected\n`active`, `dead`, and `completed`/`expired` statuses. Target metadata is presentation data only;\nopening its validated internal route grants no new authority. Create and starter actions place fixed\neditable text in the Home composer and never submit it automatically.\n\n## Architecture and security\n\nEach Scheduler account owns one SQLite-backed `ScheduleDriver` Durable Object and one alarm for all of\nits workspaces. Workspace sessions inherit their opaque scope from the containing Overseer facet;\ncallers cannot supply an account or workspace ID. Plain schedule metadata and reconstructable RPC\ncapabilities are stored under separate keys and changed transactionally.\n\nThe alarm persists state before crossing an RPC boundary. It processes at most 20 due schedules per\npass with four concurrent deliveries, then arms an immediate continuation when a backlog remains.\nStable `runId` fencing prevents late completion or retry continuations from mutating a disabled,\nre-enabled, or revoked schedule.\n\nOne account-wide driver keeps management and revocation simple, but it is also a shared failure domain:\na user callback that never settles can delay other schedules in that account until the runtime aborts\nthe alarm. Bounded batches limit ordinary load; they do not eliminate that tradeoff.\n\nThe Scheduler is capability-authorized. It does not receive Workshop user identity, assert its own\nambient policy, expose external network authority, or implement actions. The Workshop's existing hook\nadmission and observation authorization remain the security boundaries.\n\n## Limits\n\n- 500 enabled or terminal schedule rows per account.\n- 100 enabled or terminal schedule rows per workspace.\n- 100 rows per management page.\n- 20 due schedules per alarm pass and four concurrent deliveries.\n- Eight callback attempts per logical occurrence.\n- Schedule titles: 200 characters; descriptions: 2,000 characters.\n\nThese are fixed policy limits rather than deployment settings.\n\n## Development\n\nInstall and build from the repository root:\n\n```sh\npnpm install\npnpm --filter @gadgets/gatekeeper-scheduler test:run\npnpm --filter @gadgets/gatekeeper-scheduler build\npnpm run dev-server\npnpm run dev-client\n```\n\nThe development server discovers `gatekeeper-*` packages, builds `app/` into the generated single-file\nasset, and creates the local `GATEKEEPER_SCHEDULER` service binding. Do not edit generated local\nWrangler configuration.\n\nWorker code gets its runtime and `Cloudflare.Env` types from generated\n[`worker-configuration.d.ts`](worker-configuration.d.ts), referenced by `tsconfig.json`. Regenerate it\nwith Wrangler after changing Worker configuration; do not hand-maintain a parallel `Env` interface.\nThe browser app uses the separate DOM-only `tsconfig.app.json` and imports only environment-neutral\nmanagement DTOs.\n\n## Deployment\n\nBefore exposing Scheduler:\n\n1. Deploy the Workshop hook target-metadata contract and the `startHook()` admission backstop.\n2. Deploy this Worker so its `ScheduleDriver` and `SchedulerGatekeeper` SQLite migration exists.\n3. Add `GATEKEEPER_SCHEDULER` to the deployment's Workshop service bindings.\n\nScheduler declares only that it can auto-provision an account. Workshop provisioning policy defaults\nthe `scheduler` vendor to **optional**; an administrator can explicitly choose disabled, optional, or\nenabled. Production service bindings are site-specific and are not generated by this package.\n\nThe `allow_irrevocable_stub_storage` compatibility flag is required while stored callback capabilities\nexist and must not be removed from an existing deployment.\n\n## Troubleshooting\n\n- **Scheduled is missing from navigation:** confirm the Scheduler binding exists, the vendor is enabled\n  or optional with a connected account, and account discovery returns `providesUi`.\n- **A new schedule is not listed or running:** registration starts disabled. Enable its hook in the\n  workspace Connections UI.\n- **A schedule says Needs attention:** callback authorization or execution exhausted its retry budget.\n  Fix the callback or resource access, then disable and re-enable the hook.\n- **A one-shot says Expired:** its firing time passed without admission or it was already past when\n  enabled. Register a new future one-shot.\n- **A recurrence appears an hour off:** use `calendarAt()` for local wall-clock intent and `every()` for\n  elapsed time; verify the explicit IANA timezone.\n- **A blueprint-created workspace has no schedules:** schedules are deliberately not copied. Register\n  them again in the new workspace.\n\n## Non-goals\n\nV1 does not provide schedule editing, local pause/toggle/delete controls, recurring run history,\n`lastRunAt`, account default timezones, actor attribution, blueprint schedule cloning, catch-up delivery,\nor collaborator-owned schedule routing. Hook lifecycle remains in the Workshop Connections UI.\n\n<!-- File: packages/gatekeeper-slack/README.md (Tokens: ~1806 | Category: plugin-manifest) -->\n# Slack gatekeeper\n\nMediates a Gadget's **read-only** access to a user's [Slack](https://slack.com) workspace:\nchannels, direct messages, threads, members, and search. Runs as its own Cloudflare Worker and is\nauto-discovered by the backend from its `GATEKEEPER_SLACK` binding.\n\nThis gatekeeper is read-only and never sends or modifies Slack data.\n\n## Auth\n\nOAuth 2.0 using a **user token** (`xoxp-…`), requested via `user_scope` (not a bot token) so the\nagent sees exactly what the connecting user can see — including private channels, DMs, and search.\n\nCreate a Slack app (https://api.slack.com/apps) and provide its client credentials to the worker\nas `CLIENT_ID` / `CLIENT_SECRET`. For local dev, `run-dev-server.ts` maps `SLACK_CLIENT_ID` /\n`SLACK_CLIENT_SECRET` (e.g. from a root `.dev.vars`) into those vars.\n\nApp configuration:\n\n- **Redirect URL** must match `<BASE_URL>/oauth`, which in local dev defaults to\n  `http://localhost:8787/gatekeeper/slack/oauth`.\n- Enable **token rotation** (OAuth & Permissions → *Token Rotation*). Tokens are then short-lived\n  (~12h) and refreshed via `oauth.v2.access?grant_type=refresh_token`. Non-rotating tokens also\n  work as a fallback (they're returned as-is).\n- Request the **User Token Scopes** the granted resources need (see below). `users:read` is always\n  requested for connected-account display and user-name resolution.\n\n## Org-wide installations\n\nBoth workspace and org-wide installations use the connecting user's token; the bot token in an\norg OAuth response is never used for reads. Org OAuth can return `team: null`, an `enterprise`\nidentity, and `is_enterprise_install: true`. Those describe the installation, not a resource's\nworkspace. In particular, an `E…` enterprise ID must never be used in a `/client/T…` workspace URL.\n\nAn org admin must also assign the app to the desired workspaces. Installing it at the org level\nalone does not make every workspace available. The workspace picker intersects all pages of\n`auth.teams.list` (the app's assignments) with `users.info.enterprise_user.teams` (the connecting\nuser's memberships). See Slack's [Enterprise development guide](https://docs.slack.dev/enterprise/developing-for-enterprise-orgs/)\nand [auth.teams.list reference](https://docs.slack.dev/reference/methods/auth.teams.list/).\n\nEach granted resource still selects exactly one workspace. Workspace and conversation pickers\nretain its `T…` ID in the resource URL and Durable Object props. For thread URLs, workspace\ninstallations verify the permalink host against their own workspace's metadata; only org\ninstallations use the Enterprise-only `team.info(domain)` lookup, followed by membership checks.\nThe agent-facing session API is unchanged.\n\n| API calls | Workspace selector |\n| --- | --- |\n| `users.conversations`, `users.list`, `search.messages` | `team_id` |\n| `team.info` | `team` (not `team_id`) |\n| Channel-addressed conversation calls | `client_context_team_id`, plus workspace membership verification |\n\nPassing channel context is not itself an authority grant. Before reading a channel by ID, the\nclient verifies it belongs to the selected workspace, allowing shared channels linked to that\nworkspace. Responses without decisive membership metadata (including DMs) must appear in a\nworkspace-scoped conversation listing. Search matches undergo the same check. Direct user lookup\nis limited to the workspace directory; author/mention resolution can still resolve participants\nfrom other workspaces in shared channels.\n\nEnterprise DM responses can name another workspace in `shared_team_ids` even when the selected\nworkspace's DM discovery includes the conversation. For DMs and group DMs, that mismatch falls\nthrough to the existing workspace-scoped `users.conversations` proof instead of rejecting the\nread. An exact conversation ID must still be found in the selected workspace's listing; neither\norg-token visibility nor `context_team_id` is sufficient. Shared-channel mismatch denials are\nunchanged. See the [users.conversations reference](https://docs.slack.dev/reference/methods/users.conversations/).\n\nObserver checks use each collaborator's own credentials and the selected workspace ID, never\nenterprise-ID equality. Workspace bindings additionally track the conversations actually read,\nexcluding collaborators who cannot read newly observed private channels or DMs.\n\n### Upgrading existing connections\n\nOlder credentials need not be reconnected solely to learn the installation kind: discovery can\nrecognize an org token through `auth.test`. Legacy bindings did not retain the workspace from their\nURL. On first use, they recover a stored workspace ID, a thread's permalink workspace, or the sole\neligible workspace, then persist that selection separately from credentials. A subsequent reconnect\ncannot silently switch a pinned binding to another workspace.\n\nThe access proof is cached separately from that workspace selection and keyed to the access\ntoken. Every outgoing bound read, including rate-limit retries, checks the exact credential it\nwill use. Replacement credentials must demonstrate access to the pinned workspace before any\ndata request is sent. Failed checks are evicted so transient failures can recover without\nchanging the selected workspace; concurrent checks using the same credential are coalesced.\n\nIf a legacy org binding has several eligible workspaces and no recoverable selection, it fails\nclosed: add the resource again and choose its workspace. Old resource URLs containing an `E…` ID\nalso need to be replaced with a selected `T…` workspace URL.\n\nLegacy conversation-only/thread-only grants may lack the metadata scopes listed below. Reconnect\nor grant the resource again to approve the additional scopes; the reconnect flow preserves those\nprevious resource selections while requesting the new consent. Reads do not treat old scopes as\nsufficient. Workspace grants already requested the required scopes.\n\n## Resources\n\nAccess is granted at one of three granularities. Each grantable resource maps to a URL pattern\nthat drives both consent (which OAuth scopes are requested) and routing:\n\n| Granularity | URL pattern | Session type |\n| --- | --- | --- |\n| Whole workspace | `https://*` (catch-all whole-instance) | `SlackWorkspaceSession` |\n| A conversation (channel, DM, or group DM) | `https://app.slack.com/client/:teamId/:conversationId` | `SlackConversation` |\n| A thread | `https://*.slack.com/archives/:conversationId/:messageId` | `SlackThread` |\n\nWorkspace grants use the framework's account-wide `https://*` pattern; more-specific conversation\nand thread URLs take precedence. Channels and DMs share one \"Conversation\" grant.\n\n### Scopes per resource (user token scopes)\n\n- **Workspace**: `team:read`, conversation read scopes, `search:read`\n- **Conversation**: `team:read`, conversation read scopes, `search:read`\n- **Thread**: `team:read`, conversation read scopes\n- **Always**: `users:read`\n\nwhere the conversation read scopes are `channels`/`groups`/`im`/`mpim` `:read` + `:history`.\n\n## API\n\nSee `src/types.d.ts` for the full Session API (the agent-facing documentation). Highlights:\n\n- `SlackWorkspaceSession`: `getInfo`, `listChannels`, `listDirectMessages`, `listUsers`,\n  `getUser`, `getConversation`, `search`\n- `SlackConversation`: `getInfo`, `members`, `listMessages`, `getThread`, `search`\n  (conversation-scoped search is **hard-restricted** to the bound conversation regardless of query)\n- `SlackThread`: `getRoot`, `listReplies`\n\nList and search methods return paginated `Cursor` objects. Known mentions are rendered with readable\nnames.\n\n## Build\n\n```\npnpm exec vp run -F @gadgets/slack-gatekeeper build\npnpm --filter @gadgets/slack-gatekeeper test:run\n```\n\nNode regression tests cover OAuth response shapes, workspace API parameters, shared-channel/DM\nboundaries, legacy recovery, reconnects, observer exclusion, and configurator field dependencies.\nEnterprise DM regressions include nonmatching shared-team metadata, membership on a later listing\npage, nonempty search validation, and denial when the conversation is absent from that workspace.\nValidate the Worker RPC schema with `pnpm exec capnweb-validate build --out .wrangler/validate`\nfrom this package before a local Wrangler dry-run.\n\n<!-- File: packages/gatekeeper-spotify/README.md (Tokens: ~1242 | Category: plugin-manifest) -->\n# Gatekeeper Spotify\n\nThis package provides Spotify Web API integration for Gadgets. Connect a Spotify account and gadgets\ncan search the catalog, read and edit your library and playlists, manage follows, and control\nplayback on your Spotify Connect devices — all behind the approval queue.\n\nIt offers two resource granularities:\n\n- **Spotify Account** (whole-instance): profile, catalog search, library (saved tracks/albums, top\n  items, recently played), follows, playlists, and playback control via `getPlayer()`.\n- **Spotify Playlist**: read / edit / follow / unfollow a single playlist.\n\nThis gatekeeper is **not** an authentication provider (`providesAuth: false`) — Spotify isn't offered\nas a \"Continue with…\" sign-in method.\n\n> **Targets Spotify's post-February-2026 development-mode API.** Library writes use the generic\n> `PUT/DELETE /me/library`, playlist contents use `/playlists/{id}/items`, etc. Some fields Spotify\n> removed in dev mode (e.g. track `popularity`, parts of the user profile) are returned as `null`.\n\n## Setting up Spotify OAuth credentials\n\nIf you're running this project locally and want to use Spotify integration, you'll need to create\nyour own Spotify app.\n\n### Step 1: Create a Spotify app\n\n1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) and click\n   **Create app**.\n2. Fill in a name and description (anything).\n3. **Redirect URI**: add exactly\n\n   ```\n   http://127.0.0.1:8787/gatekeeper/spotify/oauth\n   ```\n\n   > Spotify **does not allow `localhost`** as a redirect URI for new apps — you must use the\n   > loopback IP literal `127.0.0.1`. Replace host/port with your `BASE_URL` when not running\n   > locally (production must use HTTPS).\n4. Under **APIs used**, select **Web API**.\n5. Save, then copy the **Client ID** and **Client secret**.\n\n### Step 2: Configure your local environment\n\nCreate a `.env` file in this package's directory (`packages/gatekeeper-spotify/.env`):\n\n```bash\nCLIENT_ID=your-client-id-here\nCLIENT_SECRET=your-client-secret-here\n# Must match the redirect URI host you registered. Spotify rejects \"localhost\", so use 127.0.0.1.\nBASE_URL=http://127.0.0.1:8787/gatekeeper/spotify\n```\n\n> **Note**: `.env` is gitignored and should never be committed. Because the redirect uses\n> `127.0.0.1`, open the Workshop at `http://127.0.0.1:8787` (not `localhost`) so the OAuth popup\n> stays on the same host.\n\n### Step 3: Add yourself as a user (development mode)\n\nA new Spotify app starts in **development mode**, which limits it to a small number of users you add\nexplicitly. In the dashboard, under **User Management**, add the Spotify account (name + email) you\nintend to connect — otherwise authorization will fail. Lifting this limit requires requesting\nextended quota from Spotify.\n\n> **Playback control requires Spotify Premium.** Player write commands (play/pause/seek/volume/…)\n> return 403 for free accounts.\n\n### Step 4: Verify setup\n\n1. Start the application in dev mode (see the root README.md).\n2. Create or open a gadget and go to the **Connections** tab.\n3. Click **+ New Connection** and choose a Spotify resource type (whole account, or a playlist).\n4. If prompted, connect your Spotify account — you'll be redirected to Spotify's authorization page\n   in a new tab; after granting access the tab closes and you're back in Gadgets.\n5. For a playlist resource, use the picker to choose the playlist to connect.\n6. Create the connection.\n\nYou can see and manage connected accounts in settings (via the account menu in the upper-right).\n\n## Notes & limitations\n\n- **Approvals & simulation.** Reads are logged and writes are queued for human approval; nothing is\n  performed on Spotify until approved. Reads optimistically reflect your own pending edits (e.g. a\n  pending playlist add shows in `listTracks`) so a gadget can keep working before approvals land.\n  Playback commands are gated but **not** simulated — `getState()` always shows real device state.\n- **Non-owned playlists.** Spotify withholds track contents for playlists you don't own/collaborate\n  on, so `listTracks()` returns an empty list (metadata via `getDetails()` still works).\n- **Spotify Connect quirks.** Some third-party Connect endpoints (e.g. Music Assistant) can be\n  invisible to `getDevices()` and `getState().device` may misidentify the active device; confirm\n  device transfers with a follow-up `getState()`.\n\n## Troubleshooting\n\n### \"Spotify Gatekeeper Not Configured\" page during authorization\n\n`CLIENT_ID` or `CLIENT_SECRET` is missing. Ensure `packages/gatekeeper-spotify/.env` exists with both\nvalues, then restart the dev server.\n\n### \"INVALID_CLIENT: Invalid redirect URI\"\n\nThe redirect URI sent doesn't exactly match one registered on the app. Confirm the dashboard has\n`http://127.0.0.1:8787/gatekeeper/spotify/oauth` and that `BASE_URL` in `.env` is\n`http://127.0.0.1:8787/gatekeeper/spotify` (note `127.0.0.1`, not `localhost`; no trailing slash).\n\n### Authorization fails with \"User not registered in the Developer Dashboard\"\n\nThe connecting Spotify account isn't on the app's **User Management** list (development-mode limit).\nAdd it in the dashboard and retry.\n\n<!-- File: packages/gatekeeper-supabase/README.md (Tokens: ~491 | Category: plugin-manifest) -->\n# Gatekeeper Supabase\n\nThis package provides Supabase integration for Gadgets via the [Supabase Management\nAPI](https://supabase.com/docs/reference/api/introduction), using OAuth2.\n\nIt exposes two resource granularities:\n\n- **Project** (`https://supabase.com/dashboard/project/:ref`) — the recommended unit. A project is a\n  hosted Postgres database plus its auth, storage, and edge functions. Gadgets can run read-only and\n  (approval-gated) mutating SQL, introspect schema, and list edge functions and storage buckets.\n- **Organization** (`https://supabase.com/dashboard/org/:slug`) — broader access to discover and act\n  across every project in an organization.\n\nA connected account corresponds to the organization the user authorizes during the OAuth consent\nflow.\n\n## Creating the OAuth app\n\n1. In your Supabase **organization settings**, open the **OAuth Apps** tab\n   (`https://supabase.com/dashboard/org/_/apps`) and **Add application**.\n2. Set the **Redirect URI** to your deployment's callback (replace the host with `PUBLIC_BASE_URL`):\n\n   ```\n   ${PUBLIC_BASE_URL}/gatekeeper/supabase/oauth\n   ```\n\n   For local development that is `http://localhost:8787/gatekeeper/supabase/oauth`.\n3. After creating it, copy the **Client ID** and **Client Secret** (the secret is shown once).\n\n> Use the OAuth app's **Client ID + Client Secret** — not a personal access token or project API\n> key. The `authorization_code` exchange requires the OAuth app credentials.\n\n## Configuration\n\nThe gatekeeper Worker reads `CLIENT_ID` and `CLIENT_SECRET`. In local development these are seeded\nfrom shell/`.dev.vars` variables by `run-dev-server.ts`:\n\n```\nSUPABASE_CLIENT_ID=<oauth app client id>\nSUPABASE_CLIENT_SECRET=<oauth app client secret>\n```\n\n## Notes\n\n- Reads are logged as observations; mutating SQL (`execute()`) is submitted to the approval queue\n  and only runs once a human approves it.\n- Mutating SQL is **not** simulated — arbitrary statements cannot be reliably previewed through the\n  stateless query endpoint, so a `query()` will not observe an un-approved change. This is\n  documented in the API types.\n\n<!-- ... +7 more modular skills available via /api/skills/cloudflare/cloudflare-os?path={path} -->\n</yakaai_skills>"}