cloudflare-os Documentation

Agent workspace built on Cloudflare Workers for creating documents, building apps, and running agents with your company’s context and systems.

7,143 stars TypeScript TypeScript 1 file · ~31,726 tokens
RAW Doc

1. Project Overview & Quickstart (cloudflare/cloudflare-os)

File: README.md

Cloudflare OS: An AI productivity environment

Cloudflare OS is an "operating system" for AI productivity originally developed for use inside Cloudflare. A large portion of Cloudflare's workforce -- from engineering to sales and everything in between -- uses Cloudflare OS every day to help them do their jobs.

This is not a traditional computer operating system. We use the term "operating system" in two senses:

  • An operating system for the company to be productive with AI, in a way that is safe, so that the security team can sleep at night.
  • An operating system for AI workloads, analogous to the sense in which a traditional operating system manages compute workloads.

Cloudflare OS provides three things in particular:

  1. An agent chat UI where you can ask agents to do tasks, preloaded with knowledge about how your company operates.
  2. Sandboxed application development, so that you can ask agents to build "gadgets" (small personal apps) and safely share what you've built with others.
  3. A security framework, called Gatekeepers, that applies guardrails to both agents and apps such that non-technical users can safely "go nuts" and nothing bad will happen.

We are making Cloudflare OS open source so that others can copy it and customize it for their own company. The idea is not that your company uses Cloudflare OS, but rather that you make it "Your Company OS".

Quick Start

To quickly run Cloudflare OS locally, install pnpm, then do:

text
pnpm run-local

Then visit: http://localhost:8787

This runs the whole stack locally on wrangler and workerd. This is not meant for production use, but is a quick way to see what the product does.

Alternatively, you can deploy to your Cloudflare account.

(More options at the end of this readme.)

What to try

Try prompts like:

  • "Make slides for my upcoming meeting with a customer." (This will use the built-in slides blueprint.)
  • "Make a collaborative whiteboard app." (This will create a new app from scratch.)
  • "Make a tic tac toe game." followed by "I'll be X and you be O. I've made my first move. Your turn."
  • "Make an issue dashboard for this GitHub repo." (Attach a repo; requires that the GitHub integration is configured.)
  • "Fix the typos in this Google Doc." (Attach a doc; requires that the Google integration is configured.)

WARNING: Early access

Cloudflare OS is in a state of heavy development. This repository is actually version 2, a complete rewrite taking what we learned from version 1 and putting it on a new foundation.

As of the August 2026 release, Cloudflare OS v2 is very capable, but still has many rough edges. We know, and we're working on it. For now, consider this an "early access" release.

Overview: What is Cloudflare OS really?

Gadgets: A new way of thinking about software

Cloudflare OS is more than just another chatbox with connectors. The system revolves around a new approach to software, where every user runs their own copy of the productivity apps they use.

When you create a slide deck in Cloudflare OS, you are not calling out to some SaaS software running in the cloud. The system creates a private instance of the slide deck software just for you. We call this a "gadget". This instance runs in a separate sandbox from everyone else's slide decks.

This has two profound effects:

  1. It's impossible for the slide deck app to have a security bug that leaks your slides to an attacker. The Cloudflare OS sandbox controls all access to your private instance of the app.
  2. If you want, you can freely modify the code. If the slide deck app is missing a feature you need, you can just ask your agent to add it. And because of point 1, it's totally safe to do so.

This is a big departure from the last 25 years of cloud architecture and "Software as a Service", but we think AI has changed the equation. When any user is capable of prompting an agent to add the features they need, the centralized model of software stops making sense.

Gatekeepers: A capability-based security layer

Gatekeepers are like supercharged MCP servers.

When you introduce an agent or Gadget to an external resource, a Gatekeeper is created to manage that access. The Gatekeeper is a piece of software specific to each external service which moderates a Gadget's connection to that service. It:

  • Provides a clean Cap'n Web API to the service (wrapping whatever API the service provides natively).
  • Handles authorization (e.g. via OAuth).
  • Enforces narrow access to only the specific resource the user intended.
  • Logs every action the Gadget (or agent) performs, for your review.
  • For any action which has side effects, provides the human user an opportunity to approve or deny the action ("human in the loop").

On the last point, Gatekeepers implement a significant advancement in the state of the art. Traditionally, human-in-the-loop setups require the human to approve actions synchronously. When the agent wants to do something, it has to stop and wait for said approval before it can continue. This is annoying: you give your agent a task, then walk away and get a coffee, only to come back and find the agent got stuck on an approval on the first step and has made no progress. As a result, people often give in and set their agents to "auto-approve", or --dangerously-skip-permissions, which is, obviously, unsafe.

Gatekeepers provide a better way: When the agent (or Gadget) performs an action that requires approval, the Gatekeeper will simulate the outcome locally, allowing the agent to proceed and queue up more actions. The Gatekeeper tells the agent that the action completed, and if the agent tries to read back the results, the Gatekeeper gives it simulated results. Once the agent is done, the user may approve or reject the actions in bulk, or one-by-one, but either way, they can do it later, when it is convenient.

Logistically, each Gatekeeper is implemented as a separate Worker. In the future, we envision Gatekeeper services being deployed and maintained independently from OS instances, but the details have yet to be worked out. For now, we have provided a few interesting Gatekeepers in this repository which you can deploy together with your own OS instance.

Think of an office suite

The basic user experience of Cloudflare OS is something like an online office suite, like Google Docs or MS Office. But, imagine that instead of a fixed set of file types (document, spreadsheet, slide deck), each file -- or "Gadget" -- is potentially its own custom application, written by AI to serve exactly your needs.

Just like office docs, each gadget is private by default, but can be shared -- securely -- in order to collaborate with your team or your friends.

Just like office docs, you can have thousands of them. You can create them on a whim.

Just like office docs, you can start from "templates" -- called "Blueprints". But where an office template is just some content, a Blueprint specifies a whole application.

Like office docs, you can create new templates (blueprints) from your own docs (Gadgets) and share them with others. But when you do so, you are sharing the code for a whole app.

It kind of is an Operating System

The OS terminology isn't entirely marketing. Cloudflare OS is actually analogous to an operating system on a technical level.

Normal OS Cloudflare OS
kernel packages/workshop-backend
device drivers packages/gatekeeper-*
shell packages/workshop-frontend
processes gadgets
executables blueprints
users users
ACLs shared permissions
??? agents

Our "kernel" is in the workshop-backend package. The backend legitimately does a lot of things similar to real OS kernels: it connects users to programs and devices (Gadgets and Gatekeepers, as we call them) while implementing security by sandboxing applications and enforcing access control.

In this analogy, Gatekeepers -- which connect users and agents to external services -- are like drivers -- which connect users and programs to external devices.

There is one thing that traditional OSes don't really manage today, but Cloudflare OS does: AI agents. If you think about it, this is really a missing feature in traditional OSes. We believe that AI agents cannot simply be treated as users. They must be accountable to a human user, while at the same time having their own restricted permissions. Agents do work by writing snippets of code and executing them on the fly. The ideal security model for all of this is capability-based security, not access control lists. See what I mean? Perhaps traditional OSes ought to give AI agents special treatment, too.

Built on Workers, by the Workers team

Cloudflare OS is built on Cloudflare Workers, making heavy use of Durable Objects, Dynamic Workers, and Facets in particular. Every workspace is its own Durable Object, every Gadget runs in a Dynamic Worker Facet, and Gatekeepers also install facets into each workspace to manage access to remote services.

Cloudflare OS is, in fact, built by the very people who built Workers itself. It uses cutting-edge features of the Workers Runtime -- in fact, Dynamic Workers, Facets, and several other features were added to the runtime specifically to support Cloudflare OS, with more to come. Studying the Cloudflare OS source code is a great way to understand how the Workers Runtime team thinks Workers should be used.

Being built on Workers does not mean that Cloudflare OS can only run on Cloudflare. In fact, workerd, the Cloudflare Workers Runtime, is itself open source, and Cloudflare OS can run entirely on top of it on your own servers.

Features

General multi-purpose agent

The Cloudflare OS coding agent is actually a fully multi-purpose agent that can perform arbitrary tasks; like other popular coding agents, you don't have to code with it. You can use it to build Gadgets, but you can also skip the Gadget and just have the agent perform tasks directly. The Cloudflare OS agent is a Code Mode agent -- it performs tasks by writing and immediately executing snippets of code. It can be connected to external resources using Gatekeepers (like MCP -- see below).

Build apps with AI

While you can code a Gadget by hand if you want, the expectation is that AI writes the code for you. Cloudflare OS features a built-in coding agent that will build whatever you ask it, test it for you, and debug errors.

You can choose your LLM. Cloudflare OS works with many major AI model providers and self-hosted models, with more providers being added all the time.

Because of the tightly-integrated and simplified nature of the platform, even when using the same underlying AI models, the Cloudflare OS coding agent often performs better and faster with fewer tokens than a general-purpose coding agent would.

Collaborate with AI

Every app built with Cloudflare OS automatically has an agent-friendly API. That means, after you've asked AI to build the app, you can also ask AI to collaborate with you inside the app. No need to build an MCP server nor integrate a custom agent loop. It's just there by default.

This works because the client and server portions of a Gadget are required to communicate via Cap'n Web RPC. This is a win-win:

  1. Cap'n Web is extremely low-boilerplate, which makes it easy for agents to work with. You basically just define a method on your server, then call it from your client, as if it were a local call.
  2. Meanwhile, it means that the server necessarily exposes an easy-to-understand API which could be called directly by an agent. The AI Agent harness uses Code Mode for tool calling, making it trivial to expose the Gadget's API directly for the agent to invoke.

Real-time Multiplayer

You can share your Gadget just like you'd share a document in a typical online office suite. You can give specific users access, or create a share link that provides access to anyone who opens it. And just like those online office suites, you'll be able to see your collaborators' actions in real time.

This works because every Gadget is backed by a Durable Object, Cloudflare's stateful serverless primitive which makes real-time multiplayer collaboration easy. It's so easy that the coding agent just implements it by default, without being asked.

Blueprints: Share your code

If you've created a Gadget that might be useful to others, but you don't want to share the Gadget itself, you can instead share a Blueprint, allowing other people to create their own copy of the Gadget. A Blueprint is essentially a copy of the code.

It may sound simple, but Blueprints are a major change from cloud software tradition. Traditionally, if you create a web app that you want to share with other users, you host the app on your server, and the users connect to that. Blueprints are much more like mobile apps and traditional PC apps: every user runs their own copy of the software.

In the age of AI, this change is critically important. On one hand, AI empowers an individual developer to build more than ever, but it is still difficult for an individual developer to maintain an online service; this eliminates the need. On the other hand -- and even more importantly -- allowing each user to run their own copy of the software empowers the user to change the software to meet their needs, using AI. No need to file a feature request, no need to beg the developer to prioritize it. The end user can solve their own problems.

Sandboxed and secure by default

Each Gadget runs in a secure sandbox that prevents it from talking to the internet at all without your explicit consent. In particular:

  • The server runs in a Dynamic Worker which has had its access to the internet disabled. It can only communicate with specific external resources that you have explicitly designated, via Workers Bindings.
  • The client code runs in a sandboxed iframe. This iframe can communicate with its server only via a Cap'n Web RPC session provided over postMessage() to the parent frame. The iframe is otherwise blocked from accessing the internet (to the maximum extent allowed by browsers, via Content-Security-Policy and iframe sandbox settings).

Capability-based access control

Each agent, and each Gadget, by default has access to nothing. Even if you've configured the Gadget Workshop with access to external accounts, agents and Gadgets do NOT automatically get to use them.

Instead, you must introduce each agent (or Gadget) to any particular resources you want it to access. For instance, you may introduce a GitHub repository by pasting a link to it, or clicking "add resource" and selecting it via the UI. An agent can also request an introduction to a resource it thinks it needs, which you can then provide or deny.

This differs from most agent harnesses, where MCP servers are configured upfront, making broad access to all your services ambiently available to the agent in every chat. Capability-based introductions keep each agent restricted to only the access it actually needs for the job at hand.

Get Started

Deploy to your Cloudflare account

We've built an online flow that helps you deploy to your own Cloudflare account:

https://os.cloudflare.app/deploy

Or, for more sophisticated deployment, with your gatekeepers and potentially code changes, check out our deployment starter repo:

https://github.com/cloudflare/cloudflare-os-starter

Run locally

To quickly run Cloudflare OS locally, install pnpm, then do:

text
pnpm run-local

Then visit: http://localhost:8787

This runs Cloudflare OS using wrangler, the Workers developer tooling CLI. This is not the right way to run the OS on a production server, but it works fine for trying it out on your local machine.

Your data will be stored in a subdirectory named .wrangler.

Deploy to your own server using `workerd`

COMING SOON

Cloudflare OS can run entirely on workerd, Cloudflare's open source runtime for Workers. In fact, the "run locally" instructions above use workerd under the hood. We are still working on documentation and tooling to help you smoothly deploy the OS on top of workerd on your own servers. If you are feeling adventurous, read the low-level documentation for workerd config (or point your agent at it) and have a go.

Configuring external services

Many Gatekeepers require configuration in order to be able to connect to third-party services, including obtaining OAuth client credentials for each service. Unfortunately, many service providers intentionally do not make this easy, since the intended audience for OAuth is developers.

Each gatekeeper package contains instructions for how to set it up:

Developing

When developing, you'll want to run the front-end and back-end as two separate commands in two terminals:

text
pnpm dev-server
pnpm dev-client

Then visit: http://localhost:3000

Contributing

At this time, we are not seeking outside contribution.

AI has made writing code easy. The hard part, today, is not writing the code, but reviewing it, making sure quality stays high, and keeping the product coherent. In that light, unfortunately, external code contributions are "donating" the easy part of the job, while creating more of the hard work.

With that said, we are happy to accept small, trivially-verified PRs that fix a problem. However, we ask that you refrain from submitting low-value PRs (e.g. typo fixes) or PRs that are more than a dozen or so lines. Such PRs will be closed with a reference to this guideline.

If you have a big idea you'd like us to consider, feel free to open a discussion about it.

This policy may change in the future as the project matures. Until then, thank you for your understanding.

Credits

Cloudflare OS has far too many open source dependencies to list here. But, we'd like to highlight a few that do particularly heavy lifting:

  • Pi (specifically, pi-agent-core), which made it easy to support every LLM provider with one API.
  • CodeMirror provides our code editor UI and operational transform implementation for synchronizing real-time edits.
  • isomorphic-git is used to implement the backing storage for Gadget code and integration with external git servers.
  • Vite, which makes the development loop so pleasant.

File: docs/ai-gateway-billing.md

AI Gateway billing

An optional flow that gives each user a free daily allowance of AI usage and, once that runs
out, bills further usage to the user's own Cloudflare AI Gateway credits. Off by default
(ENABLE_CLOUDFLARE_LIMITS unset) — usage is then unlimited, as for self-hosted deployments.

How it works

Each user gets a free allowance of LLM calls per UTC day (default 100), counted on the user's own
UserDurableObject (consumeDailyLlmCall / checkDailyLlmCount). Before each user-initiated agent
turn, the overseer calls checkUsageAndBalance:

  • Connected, balance ≥ $2 → allowed, routed through the user's own account so usage bills
    their Cloudflare credits — even while free-tier allowance remains. The platform is never charged
    for funded users, and their daily free-tier counter is left untouched.
  • Otherwise, within the free tier → allowed, served via the platform's configured AI Gateway
    (all providers, Workers AI included). This includes connected users whose balance is below $2
    (incl. $0).
  • Free tier exhausted, no Cloudflare account connected → blocked, with a prompt to connect.
  • Free tier exhausted, connected but balance below $2 → blocked, with a prompt to add credits.

The balance shown to users is read live from their Cloudflare AI Gateway billing
(/ai-gateway-billing/credit_balance), cached for 5 minutes. Topping up means adding credits in the
Cloudflare dashboard — the platform never
holds money.

Connecting Cloudflare

Billing is tied to the Cloudflare gatekeeper: the OAuth tokens live in that gatekeeper's
connection, and the billing flow obtains a usable token from it via getUsableAccessToken(). A user
connects Cloudflare either by signing in with it, or — if they signed in another way — via the
"Connect Cloudflare" button, which runs the normal gatekeeper connect flow
(AuthenticatedApi.connectAccount("cloudflare")). See sign-in for the OAuth
mechanics and redirect URIs.

The account to bill is auto-selected when the grant sees exactly one account; with several, the user
is prompted to choose one. Billing is account-level (Unified Billing): inference is routed through
the account's auto-created "default" AI Gateway.

Configuration

text
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=https://your-host
AUTH_GATEKEEPERS=cloudflare       # allow Cloudflare sign-in/connect (plus any others)

# The Cloudflare gatekeeper's OAuth app (client id/secret live on the gatekeeper Worker; in dev
# they're seeded from these shell vars by run-dev-server.ts):
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier (see existing AI Gateway docs):
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set:
CF_AI_GATEWAY_ACCOUNT_ID=...
# Required unless the WORKERS_AI binding carries gateway traffic; always required for the
# google provider:
CF_AI_GATEWAY_API_TOKEN=...

Gateway mode always requires CF_AI_GATEWAY_ACCOUNT_ID plus a transport: the WORKERS_AI
binding when present (binding requests are pre-authenticated, and cost-log reads work through
the binding too), or otherwise an API token with AI Gateway Run and Read permissions — Read
access lets Gadgets retrieve each log's cost for user-visible accounting. The binding transport
only works when the Gateway lives in the Worker's own account, which the Worker can't verify at
runtime — a deployment whose Gateway is in a different account must set
CF_AI_GATEWAY_USE_BINDING=false to opt out and use the token transport. That is a flag rather
than an unbinding because WORKERS_AI also backs the webFetch tool's document-to-Markdown
conversion (and is hardcoded for every released backend), so removing it would break that instead
of just moving gateway traffic. The token stays required for the google provider even when the
binding transport applies. Every provider, Workers AI included, routes through the same Gateway.

The Cloudflare dashboard OAuth endpoints and scopes are hardcoded in the Cloudflare gatekeeper
(packages/gatekeeper-cloudflare/src/oauth.ts):

  • auth: https://dash.cloudflare.com/oauth2/auth
  • token: https://dash.cloudflare.com/oauth2/token
  • scopes: offline_access aig.read aig.run aig.write user-details.read account-settings.read

Cloudflare gatekeeper redirect URI: ${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth.

Optional (all have sensible defaults):

text
DAILY_LLM_CALL_LIMIT=100        # free-tier LLM calls per user per UTC day
MINIMUM_CLOUDFLARE_BALANCE=2    # min connected-account balance (USD) to proceed via BYOK

Storage / bindings

The free-tier daily LLM-call counter lives on each UserDurableObject (no separate binding).

The OAuth tokens live in the connected Cloudflare gatekeeper account. Each UserDurableObject
stores only lightweight billing state (the selected account id + a cached credit balance, plus the
daily counter) — no tokens.

Code layout

Server code lives under packages/workshop-backend/src/ai-gateway-billing/:

text
ai-gateway-billing/
├── config.ts                     # ENABLE_CLOUDFLARE_LIMITS / minimum-balance readers
├── limits/
│   ├── config.ts                 # daily-limit + calendar-day helpers + DailyQuotaResult
│   └── usage-checker.ts          # checkUsageAndBalance / getUsageInfo (counter lives on UserDurableObject)
└── cloudflare/
    ├── account-service.ts        # CF REST: accounts / balance
    └── connection-service.ts     # token (from CF gatekeeper), account selection, balance cache, BYOK routing

Client-side: ServerConfigContext exposes cloudflareLimitsEnabled; components/billing/
(UsageSettings, OutOfCreditsModal, AccountSelectionModal) renders the usage / top-up /
account-selection UI.


File: docs/blueprints.md

Blueprints

Blueprints let a user share a gadget's source code so that others can create their own gadget instances from it. A blueprint captures the code but not the chat history, SQLite storage, or credentials. Each gadget created from a blueprint gets its own bindings, storage, and chat history.

This is analogous to a template: the blueprint author publishes a reusable gadget design, and anyone with the link can stamp out their own copy, pointing it at their own resources.

Key Properties

  • A single gadget can have multiple blueprints, potentially at different code versions (e.g. a "stable" and a "latest" blueprint of the same gadget).
  • Each blueprint has a 128-bit random hex ID, generated server-side. Blueprints bundled with a deployment are the exception: they carry stable, readable IDs (see Output Formats and Bundled Blueprints).
  • Blueprints are shared via link: https://<host>/blueprint/ (for example, a random hex ID or a bundled ID such as format.document).
  • Anyone with the link can view the blueprint's metadata (title, description, author, required bindings) without authenticating. Creating a gadget from a blueprint requires authentication.
  • A blueprint is always owned by the gadget's owner, regardless of which collaborator creates it. Bundled blueprints have no owning user at all.
  • The blueprint author can update a blueprint to reflect newer code, incrementing its version number. Old code versions are retained in storage to avoid race conditions during concurrent instantiation.
  • Blueprints can be exported to a .gadget file and imported into a different Workshop instance.

What a Blueprint Captures

A blueprint captures:

  • Source code -- a snapshot of the gadget's committed Yjs document, stripped of edit history. The snapshot contains only the final file contents (one insert operation per file), producing a minimal encoding.
  • Binding requirements -- a description of each named binding the gadget uses, including what type of connection is needed (gatekeeper, AI model, or agent spawner) and how to configure it. The blueprint does not include any credentials or live connections.
  • Metadata -- title, description, optional screenshot metadata, author info, version number, and timestamps.

A blueprint does not capture:

  • The gadget's SQLite storage contents.
  • AI chat history or edit history.
  • Live connections or credentials. Only the shape of each binding (its type, gatekeeper name, URL pattern, etc.) is recorded.

Binding Annotations

Before creating a blueprint, the author can optionally add blueprint annotations for the gadget's named bindings. This user-provided metadata controls how each required connection appears to someone creating a gadget from the blueprint:

  • Name -- a friendly connection name shown to blueprint consumers. It defaults to the current resource title, while the binding name remains the stable key used by code.
  • Description -- optional helper text that tells the blueprint consumer what kind of resource to connect.
  • Suggest value -- optionally includes the specific resource URL or model name as a suggestion. This is useful when the blueprint author intends all instances to use the same resource, but it remains a suggestion rather than a requirement.

All named bindings are included in the blueprint. Annotations are configured in the Blueprint modal opened from the gadget editor header. The annotation is stored on the GatekeeperRecord as the blueprintAnnotation field.

Binding Types

Blueprints support three types of bindings, matching the three types of gatekeepers:

  1. Gatekeeper (type: "gatekeeper") -- an external resource connection (e.g. Google Drive, a REST API). The blueprint records the gatekeeper adapter name and a URL pattern describing what kind of resource is expected. When instantiating, the user picks a connected account and configures a matching resource.

  2. AI Model (type: "aiModel") -- a language model binding. The blueprint may suggest a specific provider/model. When instantiating, the user picks from their own configured models.

  3. Agent Spawner (type: "agentSpawner") -- an agent spawner binding. The blueprint carries over the spawner configuration (prompt types, env restrictions) from the source gadget. The user only needs to choose which model the spawner should use (or no model).

Storage Architecture

Blueprint data is stored in three places, with one-way propagation: Gadget DO -> User DO -> Workers KV.

  1. Gadget DO (blueprints collection) -- the authoritative source. Stores BlueprintGadgetRecord including full metadata, the code version that was exported, and a dirty flag for tracking propagation failures.

  2. User DO (blueprints collection) -- a denormalized copy for efficient listing. Stores BlueprintUserRecord with metadata and a reference to the source gadget. This allows a user to audit and manage their blueprints even if the source gadget has been deleted.

  3. Workers KV (BLUEPRINTS namespace) -- the public-facing lookup store. Stores BlueprintKvRecord keyed by blueprint hex ID. This is what PublicApi.getBlueprint() reads from.

Blueprint code content is stored separately in an R2 bucket (BLUEPRINT_CONTENT). The R2 key is /<version>. Content is stored as a Yjs V2-encoded document (the full state, not incremental updates). When a blueprint is updated, old versions are retained to avoid race conditions. When a blueprint is deleted, all its R2 versions are cleaned up.

The dirty flag handles propagation failures gracefully: it is set to true before propagation begins and cleared only after all writes succeed. If a failure leaves it set, the UI shows a warning with a "Retry" button.

Explore

The Explore page (/explore) is a place where users can discover featured blueprints. Goal is to show users what is possible and to give them place to start.

Admins have the ability to "feature" blueprints. This is what determines what is on this page.

Blueprints on home page

The home page has a blueprints tab which shows a list of blueprints that they have published plus what is in their library.

Users can pin blueprints to keep them at the top of the home Blueprints tab. Pinning a public blueprint that is not already in the user's library adds it to the library first, then pins it.

Library entries come in two forms:

  • Saved by reference -- created by addBlueprintToLibrary(). The entry stores a cached copy of the blueprint's public metadata for list rendering, but the actual blueprint remains owned by the original publisher. Removing it only deletes your personal library entry.
  • Uploaded -- created by importBlueprint() from a .gadget archive. This creates a new local blueprint ID on the current deployment, stores the snapshot in this deployment's R2/KV, and records it in your library with uploaded: true. Removing one of these entries deletes the imported blueprint content as well.

Export / Import Format

Blueprints can be downloaded from /blueprint/ as .gadget files and uploaded from the home blueprints tab into another Workshop instance.

The .gadget format is a simple internal binary container:

  • 8-byte magic number: 0xec2e2d3a2300e317
  • 4-byte format version (1)
  • 4-byte JSON metadata length
  • 8-byte raw content length
  • JSON-encoded BlueprintMetadata
  • Raw blueprint content bytes copied from BLUEPRINT_CONTENT//<version>

Imports are validated before publication. Metadata is capped at 64 KiB and the stored snapshot payload is capped at 32 MiB so a malformed archive cannot force unbounded allocation in the worker.

Only BlueprintMetadata is included in the file, not the full KV record. In particular, the archive does not include ownerId, gadgetId, or screenshot bytes. Imported archives clear any screenshot marker because screenshots are stored separately from the archive content.

The trailing content bytes are the same gzip-compressed Yjs snapshot that is already stored in R2 for the blueprint's current version. Import/export streams these bytes directly to and from R2 using pipeTo() rather than buffering the whole archive in memory on the server.

Deployments can optionally configure a set of admin usernames through the backend worker's ADMINS binding as an array of usernames.

Admins get access to two extra RPCs:

  • AuthenticatedApi.adminIsBlueprintFeatured() returns whether a published blueprint is currently featured.
  • AuthenticatedApi.adminSetBlueprintFeatured() marks or unmarks a blueprint as featured.

Only gadget-backed published blueprints are featureable. Uploaded/imported library blueprints are intentionally excluded.

Featured blueprint state is split across two stores:

  • The authoritative featured bit lives in the owning user's blueprints record inside their User DO.
  • The AdminSettings durable object is a singleton (getByName("")) that mirrors the current public metadata for featured blueprints and writes a KV snapshot consumed by AuthenticatedApi.listFeaturedBlueprints().

Output Formats and Bundled Blueprints

A format is an ordinary blueprint the deployment has promoted, so that "New Doc" or "New Slides" appears in the composer's + menu and in the list the agent is told to prefer. Promotion is admin curation (AdminConfig.formats, managed in the admin Formats panel); nothing about the blueprint itself changes.

What a blueprint may declare is BlueprintMetadata.output: a grouping id, a noun and plural ("Doc"/"Docs"), and an icon from the closed OUTPUT_ICONS set. A gadget instantiated from the blueprint inherits it, and that is what the workspace tab, chat cards and the Outputs page draw. Declaring it is presentation only and grants nothing -- any user can publish a blueprint calling itself a Document. Being offered as one of the deployment's standard formats is the separate, admin-curated decision. An admin can override any of these fields (FormatCuration.overrides), and the override is applied on every instantiation path, so a rename reaches gadgets the agent builds as well as ones made from the menu.

A deployment can also ship blueprints as data. packages/workshop-backend/format-blueprints/ holds a <name>.gadget archive plus a <name>.json sidecar for each, and scripts/build-format-blueprints.mjs bundles that directory (overridable with FORMAT_BLUEPRINTS_DIR, so a fork can ship its own set) into a generated module. These differ from published blueprints in three ways:

  • Their IDs are stable and readable (format.document, not a random hex ID), because both installation and promotion are keyed on them. Renaming one after deploy orphans the old entry rather than moving it.
  • They have no owning User DO. AdminSettings writes them straight into the featured mirror, because there is no publishing user whose featured bit could be authoritative.
  • Their output lives in the sidecar rather than the archive, so the deployment's presentation has a single source of truth.

The first /api request a deployment serves installs any whose manifest fingerprint has changed. The fingerprint covers its title, description, author, revision, and output presentation; revision represents changes to the archive bytes. Each bundled blueprint is promoted only once ever -- an upgrade never undoes an admin's later removal or overrides.

Creating and Managing Blueprints

Blueprints are managed through the Blueprint button in the gadget editor header. The UI allows:

  • Creating a new blueprint from the gadget's current committed code, with a title, optional description, and optional screenshot.
  • Describing the required connections with optional per-binding helper text and suggested values.
  • Listing existing blueprints with their title, description, version, and code version date.
  • Editing a blueprint's title, description, screenshot, and connection guidance through the same form used to create a blueprint.
  • Updating a blueprint to the gadget's current code (increments the version).
  • Copying the blueprint's share link to the clipboard.
  • Deleting a blueprint (with confirmation).
  • Retrying a failed publish when the dirty flag is set.

On the backend, the Overseer handles blueprint lifecycle through createBlueprint, updateBlueprint, deleteBlueprint, and retryBlueprintPublish. Blueprint creation generates a random ID, collects binding metadata from all annotated gatekeepers (via collectBindingMetadata), snapshots the code (via snapshotCode), and propagates to all three storage locations (via propagateBlueprint).

Instantiating a Blueprint

When someone opens a blueprint link (/blueprint/), they see the Blueprint Landing Page:

  1. The page fetches metadata via PublicApi.getBlueprint() (unauthenticated -- knowing the ID is sufficient since a blueprint is just data).
  2. It displays the title, description, optional screenshot, author, version, and a summary of required bindings.
  3. If the user is not logged in, they see a "Log in to create a gadget" button.
  4. Once authenticated, the user enters configure mode, where they assign each required binding:
    • For gatekeeper bindings: pick a connected account and configure the matching resource.
    • For AI model bindings: pick from their configured models.
    • For agent spawner bindings: pick a model (or none).
  5. Clicking "Create Gadget" calls AuthenticatedApi.newGadgetFromBlueprint(), which:
    • Reads the blueprint from KV and its code from R2.
    • Creates a new Overseer DO and initializes it with the blueprint's code via initializeFromBlueprint.
    • Creates gatekeepers from the user's binding assignments (pipelined for performance).
    • Returns the new Overseer stub, and the UI redirects to the new gadget.

The new gadget is independent from the blueprint source: it has its own storage, chat history, and bindings. There is currently no mechanism for automatic updates from the blueprint to existing instances (though the Yjs-based storage format could support this in the future).

Instantiation by the agent

The AI agent can also instantiate a blueprint as an additional gadget within an existing workspace:

  • The listBlueprints tool lists the blueprints available to the workspace owner (the deployment's standard formats, listed first and marked as preferred, then their own published blueprints, their library, and the deployment's featured set) as formatted text; there is no search index, so the model scans the list itself.
  • Passing a blueprintId to the createGadget tool creates the new gadget from the blueprint's code instead of empty. The gadget is provisional to the chat like any agent-created gadget, and the blueprint's files are copied into the chat's proposed changes (recorded in the same changes message as the creation), so accepting or reverting the chat's changes covers the files and the creation together.
  • Bindings are not auto-assigned on this path: the tool result describes the bindings the blueprint expects, and the agent wires them up itself under the same names (via setGadgetBinding, requesting connections as needed), or asks the user to add AI-model / agent-spawner bindings from the Connections panel.

When a .gadget file is uploaded, the target instance creates a new local blueprint ID, stores the uploaded code snapshot in its own R2 bucket, writes the imported metadata to its own KV namespace, and records the blueprint under the importing user's account. The original blueprint author metadata is preserved, but ownership of the imported copy belongs to the importing user on the new instance.

Orphaned Blueprints

A blueprint can outlive its source gadget. If a gadget is deleted, its blueprints remain accessible via KV and R2. The user can manage orphaned blueprints through AuthenticatedApi.listOwnBlueprints() (which reads from the User DO) and delete them via deleteOrphanedBlueprint() (which cleans up KV, R2, and the User DO record directly, bypassing the now-deleted Gadget DO).

Creation Specs

To support blueprint metadata derivation, each gatekeeper stores a GatekeeperCreationSpec that records how it was originally created. This includes the vendor ID (for gatekeeper bindings), provider and model name (for AI model bindings), or the full spawner config (for agent spawner bindings). The creation spec, combined with the blueprint annotation, is used by collectBindingMetadata to produce the BlueprintBinding records stored in the blueprint.


File: docs/integration-testing.md

Integration testing

This is how the integration test suites work, and why they are shaped the way they are.

There are two kinds of suite:

this repo's packages/integration-tests a consumer repo's per-vendor suite
Runs pnpm test (part of CI's normal test job) its own CI step
Gatekeeper a fixture Worker whose verification outcome the tests set a real vendor gatekeeper, unmodified
Covers the overseer's observer logic a genuinely expired credential, end to end
Owns the harness, interceptor, and RPC client that vendor's handlers and token minting

A consumer repo is one that vendors this repo as a public/ submodule and consumes the toolkit as a
workspace dependency (public/packages/integration-tests in its pnpm-workspace.yaml).

No such suite lives in this repo, and nothing here depends on one existing. The second column is
described anyway because it is what the toolkit is parameterised for: the harness takes a list of
gatekeepers and the interceptor takes pluggable handler modules precisely so a suite can be added
outside this repo without forking either. Where this doc describes a per-vendor suite, take it as the
worked example of that shape — one gatekeeper run unmodified against its vendor's mocked endpoints —
rather than as something you will find here.

What these tests are

wrangler's createTestHarness()
boots workshop-backend and one or more gatekeepers as real Workers in workerd, with their
checked-in wrangler.jsonc patched in memory. Tests speak Cap'n Web over a WebSocket to /api — the
same transport the browser uses — and serve an ObserverConfigCallback the overseer calls back into.

Nothing is stubbed except outbound HTTP. The consequence worth internalising: the code under test is
in another process.
Most of what follows falls out of that.

Findings that shape the design

Fake timers cannot work here

vi.useFakeTimers() patches the test process's clock. The code under test reads workerd's clock, out
of process, so a faked clock is invisible to it. isTokenExpired()'s 30-second skew is inside
gatekeeper-shared and evaluated inside the Worker.

(Fake timers do work for in-isolate unit tests under vitest-pool-workers, where the test runs
inside the same isolate)

A fixture gatekeeper, not a real one, for the overseer's own logic

The overseer cases need a gatekeeper that refuses an observer on command. Every shipping public
gatekeeper can do that only at a cost that would dominate the test:

  • OAuth gatekeepers need a whole vendor auth surface mocked before an account exists at all.
  • The Context Library only refuses once an observation has been recorded, which takes a gadget
    read session (so a Worker Loader), a slash-command invocation, or an AI-chat catalog snapshot. It is
    also a singleton, so it can never produce the two simultaneously-failing bindings one of these cases
    needs.

Adding a test hook to those workers was considered and rejected. A "mark observed" hook would stub the
very state the tracker maintains, making the test circular.

So fixtures/gatekeeper-test/ is a real Worker speaking the real protocol, whose verification outcome
the tests set over an HTTP control route. It is scoped to overseer logic, not a long-term substitute
for per-vendor coverage.
Testing actual gatekeepers is the expected trajectory, which is why the
harness takes a list of gatekeepers and the interceptor takes pluggable handler modules: a future
gatekeeper-google suite is "add google-handlers.ts, point the harness at the package" — the same
shape a consumer repo's per-vendor suite takes, with production code unmodified.

Storage isolation is by convention, because the alternative is worse

server.reset() exists, and measuring it settles the question: ~3 s per call, which is more than
an entire suite run. It also restarts the server — server.url becomes undefined and every open
WebSocket RPC session dies with "WebSocket connection failed". It is not a storage wipe you can use
between tests; it is a teardown.

So storage persists for the harness's lifetime and no test may assume a clean slate. Tests stay
independent by taking fresh identities: nextUsernames() from the toolkit, per-test resource URLs, and
account labels allocated by the connect/provision helper rather than chosen by the caller.

One corollary that is easy to get wrong: the "nothing escaped to the internet" assertion belongs in
afterAll, not afterEach. With it.concurrent, an afterEach fires while siblings are still
running, so it would inspect and clear state they are still using — and could discard an escape a
sibling was about to be blamed for.

wrangler and workerd versions are coupled

The public repo pins workerd through a root overrides entry, which collapses every transitive
request to one version. A newer wrangler brings a newer miniflare that demands a newer workerd
than the override yields, and the harness then fails to boot:

text
The Workers runtime failed to start ... requires compatibility date "2026-07-08",
but the newest date supported by this server binary is "2026-06-30".

So the public package pins wrangler to ~4.104.0 — the release whose bundled workerd matches the
override. Bumping it means bumping the override in step.

A consumer in another repo can end up with two copies of capnweb

A consumer repo installs its own workspace and the public/ submodule's, as two separate pnpm
stores. So capnweb resolves to two different copies: the toolkit's rpc-client gets the
submodule's, while anything importing capnweb from one of the consumer's own packages gets the
other. A stub is only serialisable by the instance that owns the session, so mixing them fails:

text
TypeError: Cannot serialize value: [object RpcStub]

The trap is that a dev machine where a single pnpm install deduped both will not show this. It first
appeared in CI, which runs pnpm install and pnpm --dir public install separately. To reproduce
locally, do the same.

The toolkit therefore owns the capnweb boundary: mint callback stubs with stubFor() from
rpc-client, never with an imported RpcStub. Importing RpcStub as a type is fine. This is
enforced structurally in this repo — the lint rules in vite.config.ts restrict capnweb value
imports within this package to rpc-client.ts (allowTypeImports leaves type imports alone). A
consumer repo without a linter should treat the rule as a convention its test files follow via
stubFor().

Worker entry modules may export only classes and the default handler

workerd treats every named export of the entry module as an entrypoint. Exporting a plain string
constant from the fixture produced:

text
Incorrect type for map entry 'THING_URL_PATTERN': the provided value is not of
type 'function or ExportedHandler'.

Type-only exports are fine (they erase). Anything else has to stay module-private.


File: docs/oauth-signin.md

Sign-in via authentication gatekeepers

Sign-in is provided by authentication gatekeepers — gatekeepers that advertise providesAuth
and can return a provider-verified email. Each such gatekeeper uses a single OAuth app for both
sign-in and (when the user later connects it) its capabilities, so there's only one OAuth app per
provider — no separate "login" vs. "gatekeeper" apps.

It's an optional, additive feature: for each allowlisted, auth-capable gatekeeper a "Continue
with …" button appears alongside the normal username/password form. Off by default — with an
empty allowlist the Workshop behaves as before (username/password, or Cloudflare Access).

The deployment opts gatekeepers into sign-in via the AUTH_GATEKEEPERS allowlist (comma-separated
vendor ids). Set DISABLE_PASSWORD_AUTH=true to hide username/password and offer gatekeeper sign-in
only (ignored unless the allowlist is non-empty, to avoid locking everyone out).

Identity: keyed by verified email

The primary account key is always the user's verified email. Signing in with any allowlisted
gatekeeper that yields the same verified email resolves to the same account — its UserDurableObject
is addressed by idFromName(email) (the same scheme as Cloudflare Access). Each gatekeeper must only
return an email the provider has verified (Google email_verified, a GitHub primary+verified email,
the Cloudflare account email); otherwise it returns null and can't be used to sign in.

Incremental scopes

Sign-in requests only the minimal scopes needed to verify the user's email (e.g. GitHub
read:user user:email, Google openid email profile, Cloudflare offline_access user-details.read),
and the gatekeeper grant created for login is transient — it self-destructs shortly after the
email is read, so signing in never leaves a broad authorization lying around. The fuller capability
scopes (repos, Gmail/Docs, AI Gateway billing) are requested only later, when the user explicitly
connects the gatekeeper (connectAccount(vendorId) with the default scopes: "full"), which is
what persists a usable connected account. GatekeeperVendor.connectAccount takes
{ scopes: "auth" | "full" } to choose between the two.

Sign-in flow

  1. The client calls PublicApi.startGatekeeperLogin(vendorId). The backend creates a short-lived
    PendingLogin DO, hands the gatekeeper a LoginConnectCallbackImpl, and returns the gatekeeper's
    OAuth url plus an attempt stub (a capability wrapping the PendingLogin DO — no login id is
    exposed to the client).
  2. The client opens url in a pop-up (the gatekeeper's self-closing OAuth window) and calls
    attempt.wait(), which blocks on the PendingLogin DO.
  3. When the gatekeeper finishes, it calls complete(user). The callback reads
    user.getAuthenticatedEmail(), resolves/creates the email-keyed UserDurableObject, mints a
    session, and delivers the ":<secret>" token to the PendingLogin DO — which resolves the
    awaiting RPC.
  4. The client stores the token and authenticates as usual.

Sign-in does not persist a connected account: the minimal-scope grant is only used to read the
email and is then discarded by the gatekeeper. To use a gatekeeper's capabilities (repos, Gmail/Docs)
or Cloudflare AI Gateway billing, the user explicitly connects it afterward (which requests the
full scopes and persists the connection).

Configuration

text
PUBLIC_BASE_URL=https://your-host
AUTH_GATEKEEPERS=cloudflare,google,github   # which gatekeepers may sign users in (order = button order)

# Optional: gatekeeper sign-in only (hide username/password).
DISABLE_PASSWORD_AUTH=true

OAuth app credentials live on the gatekeeper Workers, not the backend. Register each gatekeeper's
OAuth app with its own redirect URI:

  • Google: ${PUBLIC_BASE_URL}/gatekeeper/google/oauth
  • GitHub: ${PUBLIC_BASE_URL}/gatekeeper/github/oauth
  • Cloudflare: ${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth

In local dev, run-dev-server.ts seeds each gatekeeper's CLIENT_ID/CLIENT_SECRET from
GOOGLE_* / GITHUB_* / CLOUDFLARE_OAUTH_* shell vars.

Storage / bindings

  • PendingLogin (DO) — short-lived bridge between a gatekeeper login pop-up and the waiting browser,
    reached via ctx.exports (no explicit binding). Holds no durable storage: the in-flight
    attempt.wait() keeps it alive, and it's evicted once the login completes or the client disposes
    the attempt stub.

Code layout

text
auth/
├── config.ts         # AUTH_GATEKEEPERS allowlist; password-auth toggle
├── auth-vendors.ts    # GATEKEEPER_<NAME> binding lookup helpers
└── login-flow.ts      # PendingLogin DO + LoginConnectCallbackImpl

Client-side: ServerConfigContext exposes authVendors and passwordAuthEnabled;
components/auth/OAuthButtons renders the sign-in options (pop-up + attempt.wait()).


File: docs/observers.md

Implementation Plan: Observer Tracking & Read-Through Sharing Permissions (Workshop side)

This is a historical implementation plan written about the observer enforcement mechanism. This
mechanism enforces that when you share a Gadget with someone, you are not giving them access to any
sensitive information that they did not have access to already.

Specifically:

  • When Bob opens a Gadget that has been shared by Alice, Bob must specify a connected account of
    his own associated with each of the Gadget's Gatekeepers.
  • Each Gatekeeper verifies that Bob's connected account has sufficient privileges to directly read
    all information that the Gadget has historically read through the Gatekeeper. If not, Bob is
    denied access to the Gadget.
  • If the checks pass, Bob is registered as an "observer" of the Gadget, recording his connected
    accounts.
  • Going forward, if the Gadget makes any new observation through a Gatekeeper which at least one
    registered observer lacks privileges to make directly, then that observation is blocked, throwing
    an exception. Alice can optionally resolve the problem by revoking Bob's access.
  • Bob's access is also re-checked every time he opens the gadget.

This document contains the original implementation plan for this mechanism, which was used to guide
AI in implementing it. This doc may become outdated over time.

(The above is human-written. The remainder of this document is largely AI-written.)

1. Introduction & high-level intent

Gadgets enforce a core security invariant (see overview.md §"Security Model"):

If a Gadget can read information that has restricted access, then any user who is not
able to read that information will also be prohibited from interacting with the Gadget,
to prevent data leaks.

Today the only mechanism enforcing this is the blunt prohibitAllSharing flag
(packages/workshop-shared/src/gatekeeper.ts, ObservationDescription.prohibitAllSharing).
When a gatekeeper marks an observation as maximally sensitive, the Gadget can no longer be
shared with anyone, and it drops into "lockdown" (no further actions, no web fetches). This
is a deliberate stopgap — it cannot express "this data may be shared, but only with people who
also have access to it."

This feature replaces that all-or-nothing posture with a per-user, gatekeeper-mediated check:

  • Observers. Every non-owner who can see data the Gadget read is an observer. When a user
    becomes an observer, each relevant gatekeeper is asked — via Gatekeeper.addObserver() — to
    verify that this specific person is allowed to directly observe everything the Gadget has
    already read through that gatekeeper. The gatekeeper is the authority on its own resource's
    ACL, so the check runs inside the gatekeeper's trust domain.

  • Verifiers. The overseer cannot itself reason about a vendor's identity/ACL model. Instead,
    the prospective observer's own connected account mints an opaque GatekeeperUserVerifier
    (via GatekeeperUser.getVerifier()), which the overseer hands back to the gatekeeper. The
    gatekeeper "unwraps" it (today, by calling semi-private methods it defined on its own verifier
    object) to learn the observer's vendor-level identity and check access.

  • Forward exclusion. For observations made after a user becomes an observer, the gatekeeper
    can name observers who must not see a given observation via
    ObservationDescription.excludeObservers. The overseer must then guarantee those observers
    never see it, or block the observation.

The API is already committed (commit e2f1707). The relevant interfaces are
GatekeeperUser.getVerifier(), GatekeeperUserVerifier, Gatekeeper.addObserver() /
removeObserver(), and ObservationDescription.excludeObservers, all in
packages/workshop-shared/src/gatekeeper.ts.

v1 scope decisions (agreed)

  • No per-thread enforcement. v1 is all-or-nothing per observer. We do not (yet) hide
    individual chat threads or observations from individual collaborators.
  • Role-based breadth of verification:
    • build collaborators (full access — chat + code + all bindings) must be verified
      against every gatekeeper the Gadget has.
    • use collaborators (UI only, no chat access — see UseOverseerInterface,
      overseer.ts:2816) must be verified only against named bindings (gatekeepers with a
      bindingName), since that is all the UI can invoke.
  • Account selection. A collaborator must have their own connected account for each vendor the
    Gadget depends on. For ordinary bindings, they choose which account to use (e.g. work or personal
    Google). If an account cannot be selected automatically, the configuration modal prompts them to
    choose or connect one; declining denies the open. Ambient bindings are the exception to account
    selection, not verification: when the collaborator already has the matching provided singleton
    account, the overseer uses it automatically and still runs the gatekeeper's normal addObserver
    check.
  • Authorization is keyed on the sharing table, not on live sessions. Because a Gadget may
    store observed data and re-display it later (even to a use observer who opens much later),
    every exclusion/enforcement decision keys off whether a user is still authorized in the
    sharing graph (computeEffectiveRoles), never off whether they currently have the Gadget open.

2. Background: relevant existing code

Concern Location
Gatekeeper RPC API (the committed surface) packages/workshop-shared/src/gatekeeper.ts
Overseer DO, open() auth entry point packages/workshop-backend/src/overseer.ts:2714
Server openGadget path packages/workshop-backend/src/server.ts:206
Role resolution / permission graph packages/workshop-backend/src/sharing.ts (getEffectiveRole, computeEffectiveRoles, hasAnyShares)
prohibitAllSharing enforcement overseer.ts:1171 (authorizeObservation), :1207 (web fetch), :1258 (submitAction)
Observation recording overseer.ts:1169 authorizeObservation(); ApprovalQueueImpl overseer.ts:4856
Gatekeeper storage record overseer.ts:110 GatekeeperRecord (has creationSpec.vendorId)
GatekeeperCreationSpec packages/workshop-shared/src/api.ts:1345
Gatekeeper facet access overseer.ts:1079 getGatekeeperFacet()
Overseer storage collections overseer.ts:316 (gatekeepers, with byBindingName index — template for a new collection)
Connected accounts (User DO) packages/workshop-backend/src/user.ts:12 ConnectedAccountRecord (account: Fetcher<GatekeeperUser>, vendorId)
List connected accounts user.ts:890 subscribeConnectedAccounts(); subscriber type api.ts:116
Account → gatekeeper class user.ts:1136 getGatekeeperClassFor()

3. Concepts & terminology

  • Observer: a non-owner collaborator (any role) who can see data the Gadget has read.
  • Sharing table: the existing collaborators / shareKeys storage + permission graph
    (sharing.ts). Records the owner's intent that a user have access.
  • Observer record (new): overseer storage describing a user who has configured their
    gatekeeper accounts and passed all addObserver checks
    — i.e. is actually set up to observe.
    This is distinct from the sharing table: intent vs. configured-and-verified. Opening requires
    both (reachable in the sharing graph AND a valid, complete observer record).
  • Observer ID: a random, opaque string the overseer generates when it first creates an
    observer record, and stores in that record. It is passed to gatekeepers as the stable handle
    for this observer. We deliberately do not use profile.id (usually an email), to avoid
    tempting gatekeeper authors to parse identity out of it — identity is conveyed only via the
    verifier. The ID need not survive removal/re-add: a user who loses and regains access gets a
    fresh record and a fresh ID.
  • Verifier: Fetcher<GatekeeperUserVerifier> minted by the observer's own
    GatekeeperUser (a specific connected account they chose). A persistent service stub — no
    disposal required.
  • Invariant maintained: for every user authorized in the sharing graph and every gatekeeper
    in scope for their role, the gatekeeper has confirmed (at the user's last open) that the user
    may observe everything read so far, AND no later observation has been allowed that the user may
    not see.

4. Data model changes

New overseer storage collection: `observers`

Add an observers collection to OverseerStorage (mirror the gatekeepers collection at
overseer.ts:316, including a secondary index for reverse lookup):

ts
type ObserverRecord = {
  // The sharing-table key for this user. Primary key of the collection.
  profileId: string;

  // Random, opaque, stable-for-this-record handle passed to gatekeepers.
  observerId: string;

  // The account the user chose to satisfy each in-scope gatekeeper binding.
  // Keyed by gatekeeper id (GatekeeperRecord.id). The accountId refers to a
  // ConnectedAccountRecord in THIS user's own User DO.
  accountChoices: { [gatekeeperId: number]: number };
};
  • Primary index: profileId (open path looks up by the connecting user's profile).
  • Secondary index: byObserverId (the excludeObservers path maps an opaque id back to a
    profile — see Step 5).

No change is required to GatekeeperRecord; vendor matching uses the existing
creationSpec.vendorId. Gatekeeper-internal observer bookkeeping (e.g. BigQuery's accessed-table
log) lives inside each gatekeeper's own DO and is out of scope here.


5. Work breakdown

Step 1 — User DO: mint a verifier for a chosen account

Add a method to the User DO (packages/workshop-backend/src/user.ts), near
getGatekeeperClassFor (user.ts:1136):

ts
// Mint a verifier from one of THIS user's connected accounts, identified by accountId.
// Returns null if the account is missing. Throws if it belongs to a different vendor.
async getVerifier(
  accountId: number,
  expectedVendorId: string,
): Promise<Fetcher<GatekeeperUserVerifier> | null>

Implementation: look up this.storage.connectedAccounts.get(accountId) and compare its stored
vendor with expectedVendorId (exact match) before returning account.account.getVerifier().
Account selection is done by the frontend, but this server-side check must not trust that filtering
(see Step 3). A missing account returns null (re-prompt); a vendor mismatch throws (not a
legitimate UI state).

Promise pipelining: callers can pass the returned promise straight into addObserver() without
awaiting it (see the Cap'n Web note in AGENTS.md). The observer-open path awaits it because a
null result means the account must be re-selected before calling addObserver().

Step 2 — Client-server API: a configuration callback on `open` / `openGadget`

We must avoid structured/typed errors for control flow (prohibited in this codebase) and must not
break promise pipelining in the common case. So open() gains an optional callback that is
invoked only when the opening user needs to configure gatekeeper accounts. In the common case
(owner, or an already-configured observer) the callback is never called, and open() resolves
without an extra round trip.

Add to the RPC API (packages/workshop-shared/src/api.ts) and thread through
server.ts:206 → overseer.open() (overseer.ts:2714):

ts
// Provided by the client when opening a gadget. Invoked by the overseer only if the opening
// user must choose connected accounts for one or more gatekeeper bindings before they can
// observe the gadget. The overseer does not resolve open() until this returns.
interface ObserverConfigCallback extends RpcTarget {
  configure(needs: ObserverBindingNeed[]): Promise<ObserverAccountChoice[]>;
}

type ObserverBindingNeed = {
  gatekeeperId: number;
  vendorId: string;
  resourceTitle: string;
  resourceUrl?: string;
};

type ObserverAccountChoice = {
  gatekeeperId: number;
  accountId: number;   // an account in the opening user's own User DO
};

open() signature gains configureObservers?: RpcStub<ObserverConfigCallback>.

(Same goes for openGadget(), which is the public-facing API method on AuthenticatedApi.)

Step 3 — Overseer: observer configuration & re-verification at `open()`

Hook into open() in the non-owner branch, after effectiveRole is confirmed and before
constructing the client interface. Keep the existing prohibitAllSharing short-circuit ahead of
this -- lockdown still wins. The NeedsConnections signal is produced only after a valid role is
confirmed, so it never reveals a workspace's gatekeeper or resource metadata to an unauthorized
user.

Add a private helper on OverseerImpl, roughly:

ts
// Bring `profileId` (a non-owner) into compliance as an observer for their `role`.
// May invoke `configureCb` to ask the user to choose accounts for not-yet-configured bindings.
// Re-verifies (re-runs addObserver) for already-configured bindings on every open.
// Returns when the user is fully verified; throws to deny access.
async ensureObserver(
    profileId: string,
    clientUser: Fetcher,
    role: CollaboratorRole,
    configureCb?: RpcStub<ObserverConfigCallback>): Promise<void>

Logic:

  1. Select in-scope gatekeepers from this.storage.gatekeepers.list():

    • build: all gatekeepers.
    • use: only those with a bindingName.
    • A creationSpec with a vendorId requires an account; other specs need no verifier or account
      choice.
  2. Load the observer record for profileId (may be absent).

  3. Determine uncovered bindings: in-scope account-requiring gatekeepers with no
    accountChoices entry in the record. Before prompting, automatically fill ambient bindings from
    the collaborator's matching provided singleton accounts. Ordinary bindings, missing ambient
    accounts, and bindings being re-prompted after a failed verification remain uncovered.

  4. If there are uncovered bindings, invoke configureCb.configure(needs) with one
    ObserverBindingNeed per uncovered binding. If configureCb is absent (e.g. a non-interactive
    open), deny. Merge the returned ObserverAccountChoice[] into a working copy of the record's
    accountChoices.

  5. Re-verify all in-scope bindings (covered + newly chosen). For each, resolve the chosen
    account's verifier via clientUser.getVerifier(accountId, bindingVendorId) and call
    this.getGatekeeperFacet(gk.id).addObserver(record.observerId, verifier). Generate
    observerId (random) if the record is new. Run these with Promise.all + pipelining where
    possible.

    • The User DO compares bindingVendorId with the chosen connected account's stored vendor and
      throws on a mismatch. This server-side check is what guarantees a gatekeeper only receives
      a verifier minted by its own vendor; filtering account choices in the client is only a
      user-interface convenience.
    • If any addObserver throws (or getVerifier throws on vendor mismatch), the user is not
      (or no longer) allowed: best-effort removeObserver(record.observerId) on the gatekeepers
      added in this pass, do not persist the working record, and deny the open with a clear
      message.
  6. Persist the observer record (with merged accountChoices and observerId) only after all
    addObserver calls succeed. Storing/creating the record is the canonical moment the user
    becomes a configured observer; its later deletion is what triggers removeObserver (Steps 5
    and 6).

Then in open():

ts
await this.impl.ensureObserver(profileId, clientUser, role, configureObservers);

Notes:

  • Re-verification every open is intentional. It catches revocation of the user's underlying
    resource access promptly (caught at their next open). Gatekeepers for which re-running
    addObserver on every open is expensive should implement their own caching strategy — it is up
    to each gatekeeper to choose the right tradeoff between performance and immediacy of revocation.
  • Re-verification of already-covered bindings does not pop the modal; it silently reuses the
    stored account choices. The modal is only for genuinely uncovered bindings (first open, a binding
    the owner added after this user last configured, or an ambient binding without a matching provided
    account).

Step 4 — Frontend: the configuration modal

Implement the ObserverConfigCallback on the client. When the overseer calls configure(needs):

  1. For each ObserverBindingNeed, find the user's candidate accounts by filtering the existing
    subscribeConnectedAccounts() results (user.ts:890) by need.vendorId.
  2. If one or more accounts match, pre-select one arbitrarily as the default; let the user change
    it via a dropdown. (Most users have one account per vendor and will just click "OK".)
  3. Include forced auto-provisioned accounts in the subscription. If no account matches, use
    listAddableGatekeepers() to identify optional auto-provisioning vendors and provision those
    directly; otherwise use the existing connectAccount flow. Then include the new account as the
    choice.
  4. Resolve configure() with one ObserverAccountChoice per binding. If the user cancels / can't
    provide an account, reject (the overseer denies the open and the UI shows an access-denied
    state).

Messaging: "To open this Gadget, choose which of your «Vendor» accounts to use, so we can confirm
you're allowed to see the data it uses."

Step 5 — Overseer: forward exclusion in `authorizeObservation()`

Extend authorizeObservation() (overseer.ts:1169) to honor description.excludeObservers.
Because v1 has no per-thread hiding, the only case in which we can let an excluded-but-named
observation proceed is when the named observer has already lost access in the sharing graph.

For each id in description.excludeObservers:

  1. Map the opaque observerId → profileId via the observers.byObserverId index. If there is
    no record, the id is not an active observer → ignore it.
  2. Check sharing-graph reachability for that profileId
    (SharingManager.getEffectiveRole / computeEffectiveRoles).
    • Still authorized → throw, blocking the observation (degrade to per-observation
      lockdown). Use a clear message, e.g.:
      "This observation was blocked because it contains data that a current collaborator is not permitted to see."
    • No longer authorized → allow for this observer, and delete their observer record
      (and best-effort removeObserver(observerId) on all gatekeepers). They are no longer set up
      to observe; if they ever regain access they reconfigure from scratch (Step 3).
  3. If, after evaluating all excluded ids, none are still-authorized, allow the observation.

This is the runtime counterpart of addObserver: addObserver covers observers configured
after data was read; excludeObservers covers data read after observers were configured.
Persisting the observation record itself is unchanged; we only gate it.

Why not also worry about authorized-but-not-yet-configured users here? They cannot be named in
excludeObservers because no gatekeeper knows their id yet. The invariant still holds from the
other direction: when such a user later opens and configures, addObserver re-checks them
against all past observations (including any restricted one) and throws, denying them. So
forward exclusion only needs to handle already-configured observers.

Step 6 — Overseer: remove observers on sharing changes

When sharing changes, configured observers who lose access must be torn down. In the overseer
methods wrapping SharingManager mutations (removeCollaborator, revokeShareLink, and role
downgrades — see the matching methods on OverseerClientInterface and SharingManager):

  • After a mutation, use the returned AffectedCollaborator[] to find users who lost access.
    For each who is now unreachable, if they have an observer record: best-effort
    removeObserver(record.observerId) on all gatekeeper facets, then delete the observer
    record.
  • For a build → use downgrade, optionally removeObserver (and drop the corresponding
    accountChoices entries) for the now-out-of-scope bindings (those without a bindingName).
    Safe to defer — an over-broad observer set only ever errs toward stricter future checks — but
    it keeps gatekeeper state tidy.
  • All these calls are best-effort: log and continue on error. An orphaned observer entry only
    causes superfluous future checks, never a data leak (the leak-relevant gate is
    authorizeObservation, which keys off the live sharing graph).

Multi-gatekeeper sequencing/atomicity is an overseer implementation detail, not part of the
shared interface. Because addObserver is re-run every open and removeObserver is idempotent,
a failure mid-teardown self-heals: the next open re-verifies, and authorizeObservation's
sharing-graph check is always authoritative regardless of stale gatekeeper memory.

Step 7 — Gatekeeper interface contract (hand-off note)

The per-gatekeeper implementations are out of scope (separate plans), but to keep the Workshop
and gatekeeper teams aligned, document the contract the Workshop relies on. Most of this is
already in the JSDoc in gatekeeper.ts; add anything missing there rather than duplicating:

  • getVerifier() returns a persistent service stub representing the calling user's account; the
    Workshop only ever passes it back to the same vendor that minted it.
  • addObserver(observerId, verifier) MUST throw if the user represented by verifier is not
    allowed to observe everything read through this gatekeeper so far. The Workshop calls it on
    every open of every authorized observer (re-verification); gatekeepers should cache as needed.
  • removeObserver(observerId) MUST be idempotent.
  • A gatekeeper that wants to restrict a future observation to a subset of observers sets
    excludeObservers (the opaque ids it was given) on the ObservationDescription; the Workshop
    will block the observation unless every named observer has already lost access.

6. Edge cases

  1. Owner is never an observer — the owner always has build and is excluded from the
    collaborators table; ensureObserver runs for non-owners only.
  2. Collaborator disconnects a chosen account later — next open,
    getVerifier(accountId, bindingVendorId) returns null; treat the affected binding as uncovered
    and re-prompt via the modal. A mismatched-vendor account (only reachable by bypassing the UI)
    throws and denies the open.
  3. Underlying resource access revoked — caught at the next open because addObserver
    re-runs the live check and throws; the open is denied. Consistent with the lazy-revocation
    model in sharing.ts.
  4. prohibitAllSharing interaction — unchanged and still authoritative: if set, no non-owner
    can open at all (overseer.ts:2770). Observer checks only matter when sharing is allowed.
  5. Owner adds a new binding after sharing — existing observers see an incremental modal for
    just the new binding on their next open, and may be denied if they lack access to the new
    resource (inherent to the security model).
  6. Performance — ensureObserver does one getVerifier + one addObserver per in-scope
    gatekeeper per open. Parallelize with Promise.all and pipe the verifier promise straight into
    addObserver. Expensive gatekeepers cache on their side.
  7. use-role observers and excludeObservers — use observers are only configured against
    named bindings, so they will never appear in excludeObservers from a non-named binding (the
    gatekeeper doesn't know their id). The Step 5 logic handles this naturally (unknown id →
    ignored).

7. Testing

  • Observer record / sharing accessors: unit-test any new SharingManager accessor and the
    observers collection indexes (lookup by profileId and by observerId).
  • ensureObserver: with a mock gatekeeper facet that records addObserver/removeObserver
    calls and can be configured to throw, and a mock clientUser.getVerifier:
    • build = all gatekeepers in scope; use = named bindings only.
    • first open invokes the configure callback with all account-requiring bindings; subsequent
      opens do not (record covers them) but still re-run addObserver.
    • a thrown addObserver denies the open and triggers best-effort removeObserver rollback on
      bindings added in the same pass, and does not persist the record.
    • missing account → binding reported as a need to the callback; callback rejection denies open.
  • authorizeObservation exclusion: observation naming a still-authorized observer throws;
    observation naming an observer who lost access proceeds and deletes that observer record (+
    removeObserver); unknown id is ignored.
  • Sharing-change teardown: removing a collaborator / revoking a key deletes their observer
    record and calls removeObserver on all gatekeepers.
  • Frontend: the config modal lists accounts from subscribeConnectedAccounts() filtered by
    vendor, defaults to one, supports changing it, and routes to the connect flow when none match.
  • Per-gatekeeper integration tests of real addObserver/getVerifier belong to the separate
    per-gatekeeper plans.

8. Suggested sequencing

  1. Data model + User DO — add the observers collection (Step in §4) and User.getVerifier
    (Step 1).
  2. API plumbing — ObserverConfigCallback + open()/openGadget() signature (Step 2).
  3. Overseer enforcement — ensureObserver and the open() hook (Step 3). Land with a
    temporary "deny if configureCb absent / any uncovered binding" path so server logic can be
    tested before the UI exists.
  4. Frontend modal — implement ObserverConfigCallback (Step 4), turning denials into a usable
    configuration flow.
  5. Forward exclusion + teardown — excludeObservers handling (Step 5) and observer removal on
    sharing changes (Step 6).
  6. Per-gatekeeper plans (separate docs) — implement real addObserver / getVerifier /
    removeObserver for each gatekeeper, and document the verifier pattern in the
    write-gatekeeper skill. Must be completed before deploying the feature to prod. The
    per-gatekeeper strategy decisions that feed those plans are recorded in §9.

9. Per-gatekeeper observer-tracking strategy

This section records the strategy decision for each existing gatekeeper — i.e. how each one
should satisfy the addObserver / removeObserver / getVerifier contract from §7. The actual
implementation of each is still a separate follow-up plan (sequencing step 6 above); this section
fixes the approach so those plans can proceed consistently.

9.1 Strategies

Strategy is chosen per resource type (per Gatekeeper DO class / binding), not per
gatekeeper package — a single package (e.g. gatekeeper-google) may use several strategies across
its resource types.

  • A — Private-only. Non-owner observers are refused: addObserver() unconditionally throws.
    This is the replacement for today's reliance on prohibitAllSharing for these resources (the
    prohibitAllSharing lockdown mechanism itself is unchanged and remains available separately).
    getVerifier() must still exist (the overseer mints one on every open) but is never consulted.

  • B — ACL check (single unit). The resource is treated as one atomic unit.
    getVerifier() mints a verifier exposing the observer's vendor identity (via the
    "non-standard method on the verifier" pattern, gatekeeper.ts:456-461). addObserver() resolves
    that identity and checks it against the bound resource's ACL, throwing on failure. Gatekeepers
    should cache per-open to bound cost (gatekeeper.ts:511-516). No excludeObservers is needed:
    the whole unit is covered up front, so nothing read later could be invisible to a verified
    observer.

  • C — Data-set tracking. The Gatekeeper DO maintains its own log of the data sets it has
    actually observed (e.g. BigQuery dataset, Linear team, Notion page/database, Supabase project),
    plus the set of current observers. addObserver() verifies the observer against every logged
    set so far. When a later observation first touches a new set, the gatekeeper re-verifies all
    current observers and sets excludeObservers for any who fail (the overseer then blocks the
    observation per gatekeeper.ts:751-774). removeObserver() drops the observer from the tracked
    set. Each per-set check reuses the same ACL primitive the corresponding narrow (B) binding uses.

  • D — Low-stakes. No information-flow tracking. addObserver() / removeObserver() are
    no-ops; any collaborator may observe. getVerifier() returns a trivial verifier (the overseer
    still calls it, so it must exist and not throw).

  • N — N/A. The gatekeeper exposes no resources, so it is never an in-scope binding; nothing to
    implement.

9.2 Decision table

Gatekeeper Resource type / binding Strategy addObserver behavior
cloudflare (no resources — auth only) N Never in scope; nothing to implement.
email Email Mailbox D No-op. Synthetic per-gadget inbound address; the gadget's collaborators are the intended audience.
spotify Account / Playlist D No-op. Personal, low-stakes; no corp-security concern.
homeassistant Instance / Area / Label / Device / Entity D No-op. Self-hosted personal; the pasted long-lived token is all-or-nothing and HA exposes no per-user/per-entity ACL oracle to check against.
github Repo / Issue / PR B Check the observer's GitHub identity has read access to the bound repo (public → always pass; private → collaborator/org-team check). Issues/PRs inherit the repo ACL, so the repo is the atomic unit.
google Google Doc B Check the observer's Drive sharing access to the bound document.
google Google Spreadsheet B Check the observer's Google Sheets access to the bound spreadsheet. Spreadsheet sharing applies to the whole file, so it is the atomic unit.
google Google Calendar (selected calendar) B Require writer or owner access to the bound calendar, since reader access hides private-event details. Future: let the binding owner exclude private events so readers can collaborate.
google Google Calendar (allVisible availability) C In addition to the selected-calendar check, track foreign calendars whose free/busy data was successfully read and verify each observer can independently query their availability.
google Gmail Mailbox A Always throw. (Future: allow observers who independently have access, e.g. mailing-list members — explicitly out of scope now.)
google BigQuery C Track accessed datasets; verify the observer's IAM access to each. Dataset granularity for now (tables/columns later).
linear Team / Issue B Check the observer's workspace/team membership, honoring team privacy.
linear Workspace C Track accessed teams; verify the observer against each (reusing the Team B check).
notion Page / Database B Check the observer's Notion access to the bound page/database.
notion Workspace C Track accessed pages/databases; verify the observer's access to each.
supabase Project B Verify the observer's own listProjects() (supabase-api.ts:306) includes the bound project ref. Within a project, arbitrary read-only SQL spans the whole DB, so the project is the atomic unit (no per-table tracking).
supabase Organization C Track accessed project refs (the org session reaches them via openProject / listProjects, supabase.ts:1015/:1037); verify the observer's listProjects() includes each, reusing the Project B check.
confluence Site C Verify site access; track observed spaces and content because both can have narrower permissions.
confluence Space C Verify space access; track observed pages and blog posts because content restrictions may be narrower.
confluence Page / Blog Post C Verify bound-content access; track observed child pages because they may have stricter restrictions than their parent.
zoominfo Account A Always throw. The whole-account binding exposes licensed, entitlement-dependent and account-specific intelligence, and ZoomInfo provides no ACL oracle proving another account can read every historical result.
context Context Library singleton C Track observed collections; verify each is public in the sharing domain or privately owned by the observer's Context account.

9.3 The "broad binding" lens

Several packages expose both a broad binding and narrower ones. A broad binding should use C
(track the sub-resources actually touched, verifying each with the narrow binding's ACL primitive)
only when both of these hold:

  1. The broad binding spans sub-resources that have distinct ACLs (otherwise one ACL already
    covers everything — use B).
  2. There is a per-observer access oracle to check each sub-resource against (otherwise you can
    log what was touched but cannot verify anyone against it).

This is why the broad bindings split the way they do:

  • Satisfy both → C: Supabase Org (projects + listProjects() oracle), Linear Workspace
    (teams + membership), Notion Workspace (pages + page access), BigQuery (datasets + IAM), Context
    Library (public/private collections + account/domain ownership checks).
  • Fail criterion 1 → B: GitHub Repository — issues/PRs/discussions/code all inherit the single
    repo permission, and there is no binding broader than one repo, so the repo is the atomic ACL
    unit.
  • Fail criterion 2 → D (or A): Home Assistant Instance — areas/devices/entities exist but the
    long-lived token is all-or-nothing and HA has no per-user ACL oracle, so there is nothing to
    verify an observer against. (Spotify is moot: ACL enforcement is off entirely under D.)
  • Decomposition deliberately deferred → A: Gmail Mailbox — could in principle decompose into
    mailing lists the observer belongs to, but that is the out-of-scope "advanced" case, so it stays
    fully private for now.

File: docs/public-server.md

Running Gadgets as a public, multi-user service

By default the Workshop uses built-in username/password accounts (or Cloudflare Access) and gives
every user unlimited AI usage — ideal for self-hosting. It can optionally run as a public,
multi-user service instead: users sign in with Google, GitHub, or Cloudflare, every account gets a
free daily allowance of AI usage, and once that runs out they connect their own Cloudflare account
and top up credits in the Cloudflare dashboard (their account is then billed for further usage).

Sign-in is provided by authentication gatekeepers: each auth-capable gatekeeper (Google, GitHub,
Cloudflare) uses its single OAuth app both to authenticate the user (by verified email) and to
connect the account's capabilities. There's no single switch — the pieces turn on independently:

Configure Effect
AUTH_GATEKEEPERS=cloudflare,google,github Allowlists which connected gatekeepers may be used to sign in. Each shows a "Continue with …" button alongside username/password.
Each gatekeeper's OAuth credentials (on the gatekeeper Worker) Required for that gatekeeper to actually authenticate. In dev, seeded from GOOGLE_* / GITHUB_* / CLOUDFLARE_OAUTH_* shell vars (see run-dev-server.ts).
ENABLE_CLOUDFLARE_LIMITS=true Enables the free daily limit + Cloudflare-credits top-up flow. Billing reads a token from the connected Cloudflare gatekeeper.
DISABLE_PASSWORD_AUTH=true Hides username/password, leaving gatekeeper sign-in only (ignored unless AUTH_GATEKEEPERS is non-empty, to avoid lockout).

The primary account key is always the user's verified email: signing in with any allowlisted
gatekeeper that yields the same verified email maps to the same account.

For local development, set the required variables in a root .dev.vars file (gitignored,
KEY=VALUE per line); pnpm run dev-server loads it automatically. A minimal example:

text
ENABLE_CLOUDFLARE_LIMITS=true
PUBLIC_BASE_URL=http://localhost:8787
AUTH_GATEKEEPERS=cloudflare,google,github

# Each gatekeeper's OAuth app (client id/secret). In dev these seed the gatekeeper Workers:
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
CLOUDFLARE_OAUTH_CLIENT_ID=...
CLOUDFLARE_OAUTH_CLIENT_SECRET=...

# Platform AI Gateway used for the free tier:
CF_AI_GATEWAY=your-gateway
CF_AI_GATEWAY_PROVIDERS=anthropic,openai,google

# Required whenever CF_AI_GATEWAY is set:
CF_AI_GATEWAY_ACCOUNT_ID=...
# Required unless the WORKERS_AI binding carries gateway traffic (see below); always required
# for the google provider:
CF_AI_GATEWAY_API_TOKEN=...

Gateway mode always requires CF_AI_GATEWAY_ACCOUNT_ID, plus a transport for gateway requests.
When the WORKERS_AI binding is present, the binding is that transport by default: its requests
are pre-authenticated in-account, so inference and cost-log reads need no API token. This is only
valid when the Gateway lives in the Worker's own account — binding requests can't reach
another account's Gateway, and the Worker cannot verify where the Gateway lives at runtime — so
deployments whose Gateway is in a different account must set CF_AI_GATEWAY_USE_BINDING=false to
opt out and route over HTTPS instead. Keep WORKERS_AI bound when you do: it is also what the
webFetch tool's document-to-Markdown conversion runs on, so unbinding it opts out of far more than
the gateway transport. Without the binding transport, set
CF_AI_GATEWAY_API_TOKEN — a token with AI Gateway Run and Read permissions so Gadgets can
execute models and report their costs (over HTTPS the Gateway may live in the Worker's own
account or a different one). The token stays required for the google provider regardless of the
binding (the model SDK adapter refuses the binding's fetch — note the platform config above enables
it, so the platform server itself still needs the token). Every provider, Workers AI included,
routes through the same Gateway.

When using CF_AI_GATEWAY* in local development, start the server with
pnpm run dev-server -- --use-workers-ai-binding so the server has a WORKERS_AI binding for
the webFetch tool's document-to-Markdown conversion and for the gateway transport above (without
it, gateway traffic falls back to HTTPS with CF_AI_GATEWAY_API_TOKEN). If your dev Gateway
lives in a different account than the binding, also set CF_AI_GATEWAY_USE_BINDING=false — keep
--use-workers-ai-binding on, since the Markdown conversion still needs the binding.

Each gatekeeper's OAuth app must be registered with that gatekeeper's redirect URI (replace the host
with PUBLIC_BASE_URL):

  • GitHub: ${PUBLIC_BASE_URL}/gatekeeper/github/oauth
  • Google: ${PUBLIC_BASE_URL}/gatekeeper/google/oauth
  • Cloudflare: ${PUBLIC_BASE_URL}/gatekeeper/cloudflare/oauth

See docs/oauth-signin.md and docs/ai-gateway-billing.md
for the full list of options, the free-tier / top-up behavior, and the storage bindings involved.


File: docs/sharing.md

Sharing

Gadgets supports sharing gadgets with other users. There are two sharing mechanisms:

  1. Collaborators -- granting other users direct access to a gadget, so they can work on it alongside the owner. (Covered in this file.)
  2. Blueprints -- sharing a snapshot of a gadget's source code, so others can create independent gadgets from it. (Documented elsewhere.)

This document describes the collaborator system.

Collaborators

A collaborator is a user who has direct access to a gadget they do not own. Each collaborator has a role that determines their level of access:

  • build -- full access: edit code, use the AI chat, manage bindings, and interact with the gadget UI -- the same as the owner, modulo the owner-only exceptions below.
  • use -- may only render and interact with the gadget's deployed UI. Concretely, a use collaborator may call getUiBundle() and connectToGadget() against the mainline code (chatId must be omitted), read basic metadata via getMetadata()/subscribeToMetadata() (restricted to id/title/owner/role), and call subscribeToPresence() to see active viewers' names, profile IDs, and roles. Every other Overseer method throws Unauthorized, with two exceptions: subscribeToConsoleLogs() and subscribeToActions() return inert subscriptions that never deliver data (no console logs; an empty, immediately-ready() action log). The editor opens both speculatively from top-level hooks before switching to the use-only view, so resolving them quietly avoids spurious client-side errors while still revealing nothing else.

Roles are totally ordered: build > use.

build collaborators differ from the owner in a few ways:

  • Cannot delete the gadget. Only the owner can do this.
  • Use their own AI models. When a collaborator engages AI chat, the model is resolved from their own account (BYOK billing goes to whoever prompted the AI, not the gadget owner).
  • Use their own connected accounts for bindings. When a collaborator adds a gatekeeper binding, it connects through that collaborator's third-party accounts, not the owner's. This prevents collaborators from gaining access to the owner's accounts beyond what the gadget's existing bindings already expose.
  • Limited revocation authority. A collaborator can only remove users that they themselves added (see "Permission graph" below).

A caller may never grant a role higher than their own effective role. Today only the owner and build collaborators can share at all (sharing methods are not in the use allowlist), so in practice use access is granted by the owner or a build collaborator. The permission graph nevertheless models roles generally, so allowing use collaborators to reshare use access in the future requires no algorithmic changes.

Restricted capability

Authorization is capability-based: open() computes the caller's effective role from the permission graph and hands back a different object depending on the result. build/owner sessions get the full OverseerClientInterface; use sessions get a UseOverseerInterface that implements the entire Overseer interface but throws Unauthorized for everything outside the use allowlist (except the two inert telemetry subscriptions noted above). Presence is intentionally in the allowlist and exposes active viewers' names, profile IDs, and roles. Because that class implements Overseer, any newly-added interface method fails to compile until a developer consciously decides whether use callers may invoke it (default-deny).

Adding collaborators

There are two ways to grant someone collaborator access:

Direct add. The owner or an existing collaborator enters a username (email address) in the Share modal. The system looks up the corresponding user account; if it exists, a collaborator record is created. The target user does not receive an in-product notification -- the sharer is expected to send them a link or tell them out of band.

Share link. Any collaborator (or the owner) can create a share link, which encodes a secret key in the URL as a #share=<key> fragment. Anyone who opens this link is automatically added as a collaborator. A link is a durable handle that owns one or more keys: creating it mints its first key, and "copying" the link later mints another key for the same link. The raw key is shown to the creator only once at mint time and is never stored server-side, so re-copying can't reproduce an old key -- it mints a new one. Any of a link's keys can be redeemed by multiple people, or the same person multiple times, until the link is revoked, which invalidates every key minted for it.

Share key security: the server generates a random 128-bit key and stores only its HMAC-SHA-256 hash (using a fixed domain-separation constant, SHARE_KEY_HMAC_KEY). When a user redeems a share link, the client sends the raw key to the server, which computes the hash and looks it up. This means the server cannot reconstruct share links from its stored data, and a database leak does not expose valid share keys.

Storage shape: a link is its first key. The shareKeys table holds one row per key: the row for the first key carries the link's metadata and is keyed by that key's hash, which serves as the link id. Each later copy stores only an alias pointing back at that id.

Share key redemption and gadget opening happen atomically in a single RPC call (openGadget(id, shareKey)), which allows subsequent calls to be pipelined on the returned Overseer stub without waiting for a separate redemption step.

Home page behavior

A shared gadget does not appear on a collaborator's home page until they first open it. At that point, a record is created in the collaborator's user account (via UserDurableObject.recordSharedGadgetOpen()), storing a cached copy of the gadget's title and the owner's profile. The lastActive timestamp is updated each time they open the gadget.

Shared gadgets appear in the same list as owned gadgets on the home page, distinguished by showing the owner's name in the "Owner" column. Collaborators can dismiss a shared gadget from their home page (removing the record from their user account), but this does not revoke their access -- if they open the gadget again via its URL, it reappears.

When a collaborator's access is revoked, the stale record remains on their home page (we don't proactively reach into their account to remove it). The next time they try to open it, open() returns a workspace access-denied error and the client tells them they no longer have access without showing the workspace name or other metadata. They can dismiss the dead entry manually.

Permission graph

The sharing system tracks how each collaborator gained access, forming a directed graph of permission edges. This graph is the foundation for transitive revocation.

Edges

Each collaborator has one or more permission edges explaining how they got access. There are two edge types:

  • User edge: records that a specific sharer (identified by profile.id) directly added this collaborator. Includes a timestamp, the granted role, and an optional note.
  • Share-link edge: records that this collaborator redeemed a key for a specific share link (identified by keyId, the id of the link's first key). Includes a timestamp; the granted role is taken from the link.

A collaborator can accumulate multiple edges -- for example, if they were added directly by Alice and also redeemed a share link created by Bob. The collaborator retains access as long as they have at least one valid edge.

(Edges and share links created before roles were introduced have no role field; they are treated as build for backwards compatibility.)

Effective role

A collaborator's effective role is the maximum role reachable from the owner through their valid edges. Each edge grants min(edge role, sharer's effective role): the owner is the implicit root at build, a user edge's sharer is the owner or another collaborator, and a share-link edge's "sharer" is the link's creator. A collaborator's effective role is the maximum granted role across all their valid edges. Effective role is computed live (it is never denormalized into storage), so it is always consistent with the current graph -- which also means a session's access is recomputed from scratch at each open().

Share links are first-class nodes in the permission graph, connected to their creator. A share link is "supported" by its creator: if the creator loses access, the link is transitively revoked, which in turn removes anyone who gained access solely through it.

Concretely, revoking a share link or removing its creator triggers the same transitive revocation algorithm described below, treating all edges referencing that link as invalid. A link may have several keys, but they all resolve to the same link node, so redeeming any of them yields one edge.

The owner as root

The owner is the implicit root of the permission graph. The owner is never stored in the collaborators table and cannot be removed. All permission chains must ultimately trace back to the owner (or to someone the owner added, or someone they added, etc.) for access to be valid.

Lazy revocation

Access is determined by reachability from the owner, recomputed live at every open() (see "Authorization model" below). Revocation exploits this: rather than eagerly cascading and deleting records, the system only severs the edges that grant the removed party access and lets reachability do the rest.

  • Removing a collaborator deletes the edges that grant them access. The owner severs all incoming edges to the target; a non-owner severs only their own user edge. The target's record is retained even if it becomes empty, and crucially the edges where the target is the sharer of access to others are left untouched.
  • Revoking a share link sets the link's revoked flag instead of deleting it. A revoked link contributes nothing to the permission graph and none of its keys can be redeemed, but the link record and all edges referencing it stay intact (no dangling references). The link's copies are deleted outright, since no edge ever references an alias.

Nothing cascades. A dependent who loses their only path to the owner (e.g. Carol, reachable only via the removed Bob) simply becomes unreachable -- they are denied at open() time, not pruned from storage. This is the example from the introduction: removing Bob makes Carol unreachable automatically, with no separate cleanup step.

Restoring access (undo)

Because the graph is never destructively pruned, revocation is reversible. If you accidentally remove someone who had in turn shared with five other people, you can undo simply by re-adding them: their record and their five outgoing grants were never deleted, so re-adding an edge from the owner restores their reachability and, transitively, all five downstream collaborators. (Share-link revocation is likewise non-destructive via the revoked flag, though there is no UI to un-revoke a link yet -- see Future work.)

This does mean removed collaborators and revoked links accumulate in storage. Listing RPCs (listCollaborators, listShareLinks) return only currently-active entries, so removed users disappear from the UI; a future GC could reclaim long-dead records.

Effective-role algorithm

The core is a fixed-point role-propagation computation implemented in SharingManager.computeEffectiveRoles(). It computes the effective role of every collaborator (given an optional hypothetical change), returning a map from profile ID to effective role (absence from the map means no access). It is the single source of truth: open(), hasAnyShares(), the listing RPCs, and the preview methods all derive from it.

Inputs (all optional; used to model a hypothetical change in preview):

  • removedUser -- a profile ID to treat as removed (excluded from the graph).
  • removedEdge -- a single user edge ({target, sharer}) to treat as removed. Used to preview a non-owner removing only their own edge.
  • revokedLinkId -- a share link ID to treat as revoked.
  • overrides -- profile IDs pinned to at least a given role regardless of their edges.

The algorithm:

  1. Build the candidate set. Load all collaborators except the (hypothetically) removed user.
  2. Collect share-link metadata. Build a map from link ID to {creator, role}, skipping links that are revoked (or the hypothetical revokedLinkId).
  3. Initialize the role map with any overrides.
  4. Iterate to fixed point. Repeatedly scan all collaborators. For each edge, compute the role it grants -- min(edge role, sharer's effective role), where the sharer (or share link creator) is the owner (always build) or another collaborator's current effective role -- and raise the collaborator's role to the maximum across their valid edges. Raising one collaborator's role may unlock or raise others on the next pass.
  5. Converge. Roles only ever increase, so the loop terminates when a full pass changes nothing.
  6. Return the role map. Collaborators absent from the map have no access; collaborators present with a lower role than before have been downgraded.

This handles arbitrary graph shapes: diamonds (a user reachable via two independent paths), cycles (mutual adds), and deep chains.

Removals and downgrades

Because edges carry roles, severing an upstream edge or revoking a link may either remove a user (they lose all access) or merely downgrade them (they keep access via another path, but at a lower role). removeCollaborator/revokeShareLink return the affected set by diffing the effective-role map captured before the change against the map recomputed after: each entry is an AffectedCollaborator carrying oldRole and newRole (with newRole === null meaning full removal).

Preview and confirm

Revocation is a two-phase process in the UI:

  1. Preview. Before changing anything, the frontend calls previewRemoveCollaborator() or previewRevokeShareLink(), which runs the effective-role computation with the corresponding hypothetical input and returns the AffectedCollaborators whose access would change. If non-empty, the UI can warn the user (e.g. "removing Bob will also cut off Carol").
  2. Confirm. The frontend calls removeCollaborator(profileId, keepUsers) or revokeShareLink(linkId, keepUsers). Both perform the lazy edge-severance described above and return the actually-affected set.

keepUsers (optional re-rooting)

keepUsers lets the caller spare specific dependents from a removal/revocation. After the edge is severed, #reRootKeptUsers recomputes effective roles and, for each listed user who would otherwise lose access or be downgraded, appends a fresh user edge from the caller at their prior role (bounded by what the caller can grant). This is pure convenience layered on top of the lazy model -- the same effect is achievable by re-adding the intermediary, or by granting the dependent access directly.

Non-owner removal

A non-owner can only sever their own user edge to the target (and only if such an edge exists -- otherwise the call is rejected). If the target still has edges from other sources, they retain access, though possibly at a lower role; if the caller's edge was their only support, the target becomes unreachable like any other lazily-removed user.

Resource isolation between collaborators

Collaborators share the gadget's code, storage, and AI chat history, but certain resources are scoped to individual users:

  • AI model bindings resolve from the account of whoever created the binding. This is baked in at binding creation time for AI model gatekeepers (the full AiModelConfig including API key is stored in the binding props). For agent spawners, the creating user's DO ID is stored in AgentSpawnerBindingProps.creatorUserId so the model can be resolved at trigger time from the correct account.
  • Gatekeeper bindings connect through the third-party accounts of whoever created them (OverseerClientInterface.newGatekeeper() calls clientUser.getGatekeeperClassFor() rather than owner.getGatekeeperClassFor()).

This means no collaborator implicitly gains access to another user's connected third-party accounts.

Authorization model

Authorization is enforced at open(): the method computes the caller's effective role from the permission graph (getEffectiveRole()), treating the owner as the implicit build root. A caller with no effective role receives WORKSPACE_ACCESS_DENIED, while an uninitialized or deleted gadget receives WORKSPACE_NOT_FOUND. This distinction acknowledges that an initialized workspace exists, but the denial exposes no workspace name, owner, or content. Otherwise the role selects which capability is returned (full OverseerClientInterface for build/owner, restricted UseOverseerInterface for use). (Note: UseOverseerInterface still throws Unauthorized when a valid use collaborator calls a build-only method -- that's a different case, where existence is already known and only the operation is denied.)

Because the role is recomputed from the graph on every open(), the live computation is the sole source of truth for access -- there is no eager cleanup whose bugs could grant access to an unreachable user. This is what makes lazy revocation safe: severing an edge is enough to deny access, even though the unreachable records linger in storage.

Terminating live sessions on revocation

Authorization is only checked at open(), so a session that is already open is not re-checked per message. Without intervention, a collaborator who was just removed or downgraded could keep using their live session until something else disconnected them. To close this gap, removeCollaborator/revokeShareLink proactively restart the gadget's Overseer DO via ctx.abort() whenever the change actually removed or downgraded someone (i.e. the returned AffectedCollaborator[] is non-empty; pure no-op removals don't restart). Aborting forcibly disconnects every client; each reconnects and re-runs open(), which re-evaluates the now-changed permission graph -- sending removed users to the terminal access-denied page and handing downgraded users their reduced capability (the editor swaps to the use view automatically based on metadata.role). Since removals are rare (and DOs restart unpredictably anyway, so reconnects are already cheap), the disruption is acceptable.

Two precautions surround the abort (OverseerImpl.scheduleRevocationRestart): the severed edge is flushed with ctx.storage.sync() first (because ctx.abort() does not respect the output gate, a restart could otherwise come back with the change lost), and the abort is delayed ~100ms so the triggering RPC's response reaches the caller -- typically the owner, who is also connected -- before their own connection drops. The disconnect reaches the browser through the existing notifyClosed plumbing: when the Overseer DO aborts, the per-session notifyClosed stub is disposed without being called, which AuthenticatedApiImpl treats as a lost connection and reacts to by killing the browser WebSocket, forcing a reconnect.

Note this is only needed for removals/downgrades. Granting or raising access never strands anyone, and prohibitAllSharing cannot strand a session either: an observation that would set that flag is blocked (rather than applied) if the gadget is already shared, so the flag only ever flips to true on a gadget with no other sessions to evict.

Future work

  • More permission levels. Beyond build and use, planned levels include: chat-only (can create chats but not merge to mainline) and read-only.
  • Resharing of use access. Allow use collaborators to grant use access to others. The permission graph already supports this; it only requires adding the relevant sharing methods to the use allowlist.
  • Binding-aware access control. Prohibit adding collaborators when the gadget holds binding permissions that the collaborator lacks, and conversely prohibit adding sensitive bindings when existing collaborators lack the required permissions.
  • Share link expiration and usage limits. Including single-use links.
  • Un-revoking share links. Revocation is non-destructive (the revoked flag), but there is no UI or RPC to list revoked links or clear the flag, so link revocation is currently one-way in practice.
  • Garbage-collecting dead records. Removed collaborators and revoked links accumulate in storage under the lazy model; a background sweep could reclaim entries that have been unreachable for a long time.
  • Notifications. Currently there are no in-product notifications for access grants or revocations.

File: packages/workshop-frontend/README.md

Gadgets Workshop Frontend

Single-page app for the Gadgets Workshop UI. Built with React, Kumo, and Vite.

Development

sh
pnpm dev                # start dev server on http://localhost:3000
pnpm exec vp run build  # type-check and build for production
pnpm preview            # preview production build locally

build is a Vite+ task, not a package.json script, so that it can declare the VITE_* flags below
as fingerprinted env: a cached vp run executes each task in a clean environment, which drops any
ambient value, and a flag the fingerprint ignores means a changed flag replays the old bundle.

It always produces a production bundle, whatever NODE_ENV the shell holds, and it deletes dist/
before building — a cache hit restores archived files without deleting any, so the previous build's
sourcemaps would otherwise linger. vite.config.ts documents both.

Authentication modes

The frontend supports two authentication modes, selected at build time.

Password mode (default)

Users log in with a username and password. Account creation is available via /signup.
This is the default — no extra configuration needed.

Cloudflare Access mode

When the backend is deployed behind Cloudflare Access, Access handles identity before the user ever reaches the app. In this mode:

  • The password-based login page and signup page are disabled.
  • On load, the app authenticates automatically using the CF Access session that Access has already established (via authenticateFromCfAccess() on the server).

To build in CF Access mode, set VITE_CF_ACCESS_MODE=true:

sh
VITE_CF_ACCESS_MODE=true pnpm exec vp run build

Or add it to a .env file for persistent local configuration:

sh
# .env.local
VITE_CF_ACCESS_MODE=true

The 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.


File: packages/workshop-backend/format-blueprints/README.md

Bundled format blueprints

This directory contains the output-format blueprints that ship with this repo, so a fresh deployment
can write a doc or build a deck without anyone having to build one first. A format is an ordinary
blueprint the deployment has promoted (see AdminConfig.formats); these are the ones it promotes out
of the box.

Each .gadget file here is committed as data. The first /api request a deployment serves
installs them into the BLUEPRINTS KV namespace and BLUEPRINT_CONTENT R2 bucket (see
src/format-blueprints.ts), after which they are ordinary blueprints. Nothing wakes on deploy, so a
fresh deployment is provisioned by its first visitor.

Who owns what

A blueprint is two files with the same stem:

lives in why
the code, and the bindings it needs <name>.gadget what the blueprint does
blueprintId, title, description, output (noun/plural/icon), author, revision <name>.json what a human curates, so it's text in a reviewable file rather than fields inside a binary

The installer writes the sidecar's values over whatever the archive happens to carry,
so an archive's own title and author are inert. The import script normalizes them anyway,
so the committed bytes don't contradict the sidecar.

Changing a title, description or author

Edit the sidecar and rebuild. That's all — no archive rewrite, no revision bump: everything that
ends up in the installed metadata is part of the installed-version fingerprint (see
formatBlueprintsManifestVersion), so a reinstall follows on the next deploy.

Updating a blueprint's code

Build it in a real Workshop, export it, and import the export:

text
pnpm import:format-blueprint ~/Downloads/Gadgets-Doc-v4.gadget format.document

That rewrites the archive, bumps revision in the sidecar, rebuilds
src/generated/format-blueprints.ts, and reports what it did:

text
Updated workspace-docs.gadget (format.document)
  code         23668 -> 24489 bytes (7c5413e5a482)
  bindings     (none)
  version      3 -> 4
  revision     2 -> 3  (workspace-docs.json)

  presented as "Workspace Docs" by Cloudflare, from workspace-docs.json
               [export called it "Gadgets Doc"]

The line worth reading is bindings, flagged [CHANGED] when the export needs something the old
copy didn't — an instantiating user will now be asked for it. revision is the reinstall trigger for
the one input the fingerprint can't see, the archive bytes; it's automated because forgetting it is
invisible (everything builds and deploys, and the old blueprint quietly stays put).

The script also round-trips the bytes it just wrote and checks the metadata and a content hash,
because these are committed as data and a corrupt archive would otherwise first surface when a
deployment tried to install it.

Adding a new format

--new writes the sidecar for you, filling in what it can from the export:

text
pnpm import:format-blueprint ~/Downloads/Brief.gadget --new acme-brief

It then prints the handful of fields worth editing before you deploy — chiefly output, the noun,
plural and icon the sidecar owns rather than the archive, and output.id, the grouping key on the
Outputs page.
Make that one generic (document, not acme-brief): the Outputs page groups by it, so a
"Contract" blueprint declaring document is listed with the rest of the documents instead of
adding a filter chip of its own.

blueprintId defaults to the name you passed. It is the install key: reimporting the same id
updates that blueprint in place, and changing it after a deployment has installed it promotes
the new id as a second format while the old one stays in the New menu, updated by nothing.
Rename files freely; the id is the load-bearing part.

Shipping your own formats

This directory is only the default. FORMAT_BLUEPRINTS_DIR points the build somewhere else:

text
FORMAT_BLUEPRINTS_DIR=../../acme-formats pnpm exec vp run build

vp run, not pnpm build: this package's build is a Vite+ task rather than a package.json
script, so pnpm cannot see it. ../vite.config.ts explains why it has to be one.

Whatever directory it names is the deployment's format set — it replaces this one rather than
adding to it. Keep it in your own tree, in the same <name>.gadget + <name>.json layout; nothing
in it refers back to this repo. If you want one of ours too, copy the pair across once and own it
from then on.

This matters because this repo is usually a submodule: adding or deleting files here would
conflict on every update. Pointing the build at your own directory touches nothing, so the submodule
stays pristine forever. The import script honours the same variable, so you get the same workflow.

Two lighter options need no build change at all:

  1. Promote your own blueprints. These are ordinary blueprints, and the standard set is admin
    curation (AdminConfig.formats). Publish a blueprint in your deployment and promote it in the
    admin Formats panel; disable the bundled ones you don't want. Nothing needs rebuilding, and this
    is the mechanism the bundled set is a convenience on top of, not a special case beside.
  2. Ship no formats. Point FORMAT_BLUEPRINTS_DIR at an empty directory, and the deployment
    simply has none until an admin promotes something.

2. Official Technical Reference & Guides (cloudflare/docs)

AMPProject.org [](https://travis-ci.org/ampproject/docs)

You're looking at the source code of the official website of the AMP Project.
Even if you don't plan on contributing, explore how we've built the site for
a good example of how to build a canonical, responsive standalone AMP site.

How to build the site

Install

Install Grow and npm dependencies:

sh
$ curl https://install.growsdk.org | bash
$ npm install

Get a GitHub token or app id/secret

The docs in the reference section of the AMP Project site are not part of this repository; instead they are read from the amphtml GitHub repository "source of truth." When building the site these docs are fetched via GitHub.

Due to GitHub quotas this fetching will quickly fail unless additional information is provided in the request. This can be either a personal access token or an application id/secret.

Personal access token

  1. Request a GitHub personal access token. Since the docs are fetched from a public repository you only need to select the "public_repo" scope when creating the token. (It's a good practice to limit the token to the scope you need.)
  2. In your shell export the generated personal access token as AMP_DOC_TOKEN

Application client id and secret

If you prefer not to use a personal access token, you can also create a GitHub application and use its id and secret.

  1. Register for a GitHub application
  2. In your shell export the application client id as AMP_DOC_ID and the client secret as AMP_DOC_SECRET

Build

sh
$ grow build

This will generate a static, complete build of the site into the *build folder.

Develop

Note: Be sure to run grow build at least once to pull in reference docs before running the following command.

sh
$ grow run

You can now open http://localhost:8080/ and continue working on the source files, then reload the page to see changes appear.

Support

If you've found an error or inconsistency, please file an issue:
https://github.com/ampproject/docs/issues

Patches are encouraged, and may be submitted by forking this project and
submitting a pull request through GitHub.

License

All image and audio files except in folders "source/img/partners",
"source/img/publishers" and "source/img/quotes" (including *.png, *.jpg, *.svg,
*.mp3, *.wav and *.ogg) are licensed under the CC-BY-NC license. Images in the
excluded folders are not licensed.

All other files are licensed under the Apache 2 license.


Copyright 2015 Google, Inc.

Licensed to the Apache Software Foundation (ASF) under one or more contributor
license agreements. See the NOTICE file distributed with this work for
additional information regarding copyright ownership. The ASF licenses this
file to you under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy of
the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations under
the License.