This wrapper exposes controls at three depths. Start in the Admin UI, move to deployment configuration when the trust or infrastructure boundary changes, and write code only for capabilities that neither layer can express.
Use /admin for runtime policy that should not require a deployment:
- Site name, logo, and accent color
- Announcements and agent instructions
- Connector availability and auto-provisioning policy
- Signup behavior, featured blueprints, and output formats
Authentication and authorization are deliberately absent. Sign-in configuration and administrator identities remain deployment-controlled so a compromised admin session cannot redefine the trust boundary.
Set the site name, logo, and accent color from the General tab in /admin. Logo uploads accept PNG, JPEG, WebP, and SVG files up to 5 MB. The browser scales the longest edge to 256 pixels without cropping and converts the result to PNG. The server then checks the PNG header and rejects anything over 256 KB or 512 pixels before storing it in the deployment's blueprint-content R2 bucket. Square images work best.
The custom logo appears in the app chrome, sign-in screens, and browser tab on each user's next connection. Use Restore default to remove it.
deployment.jsonc is an annotated, non-secret control surface. Its groups map directly to generated Wrangler configuration:
| Path | Controls | Choices |
|---|---|---|
accountId |
Resource ownership | A 32-character Cloudflare account ID |
publicBaseUrl |
The deployment's public origin | null to derive it from the router's custom domain; on a workers.dev route, the router's own https://<router-name>.<subdomain>.workers.dev |
workers.*.name |
Stable Worker service identities | Unique lowercase names; changing one creates a differently named Worker |
workers.router.route |
The deployment's public address | customDomain for production or workersDev: true for evaluation |
gatekeepers |
Optional upstream Gatekeepers, one Worker each | Slugs from upstream's catalog, each with a Worker name and optional vars; see Optional Gatekeepers |
access |
Cloudflare Access trust and administrator list | Access team issuer, application audience, and verified email list |
aiGateway |
Deployment-managed model catalog | Enabled by default over the Workers AI binding; which providers to advertise, and which gateway |
context |
Context sharing boundary, snapshot KV, and optional Artifacts repositories | null to scope data to the public origin, or a pinned stable label; automatic or existing KV; Git-backed collections disabled or enabled |
customGatekeeper |
Whether the example integration deploys, and its identity and guidance | enabled: false to deploy none; organization-specific display text |
errorReporting |
Private explicit-issue destination | Console Reporter enabled state, environment, and release metadata |
resources |
Blueprint/avatar KV and blueprint-content R2 | null to provision or explicit IDs/names to reuse |
observability |
Worker telemetry | Structured logs, invocation logs, traces, and sampling; see the observability guide |
Secrets are never valid values in this file. Install them interactively with Wrangler against the Worker that consumes them.
The deployment is up to seven Workers of its own, plus one per optional Gatekeeper. Keep their names unique: service bindings use these names, so update and deploy them together.
| Worker | Role |
|---|---|
router |
Owns the public route and serves the frontend. Proxies /api and /blueprint-screenshot to the Workshop, and /gatekeeper/<name> to the Gatekeeper whose service binding matches. |
workshop |
The Cloudflare OS backend, holding all user data in Durable Objects. |
context |
The Context Gatekeeper. |
scheduler |
The Scheduler Gatekeeper, which gives agents scheduled and recurring work. |
websearch |
The Web Search Gatekeeper, which lets agents search the public web. |
customGatekeeper |
This repository's example integration. Not deployed when customGatekeeper.enabled is false. |
errorReporter |
The private explicit-issue destination. |
Context, Scheduler and Web Search are ambient: upstream's release marks all three PREINSTALL, so the hosted flow installs them on every instance and this starter deploys them for the same reason. None takes configuration beyond its name. Web Search runs on its own Workers AI binding and searches through the Workshop's gateway, aiGateway.name, so its searches share that gateway's provider keys, limits and logs: the deploy sets it as the Worker's CF_AI_GATEWAY, as upstream's deploy service does. With aiGateway.enabled: false, or a gateway in another account, which the binding cannot reach, it searches through the account's default gateway instead, which Cloudflare creates on first use.
Web Search does need the account to pay for searches, which the free Workers AI allowance does not cover: chats can work while every search fails. Before enabling it, either load AI Gateway credits on the account or store a Ceramic API key on the gateway it searches through, under the alias default, since upstream pins Ceramic as the provider and AI Gateway uses no other alias for it. With credits, each search spends them, and there is no per-user limit. With a key, Ceramic bills you instead, and a search no longer falls back to credits: a revoked or empty key fails every search. On a public server, set Web Search to disabled in /admin unless that is acceptable: in the default mode, optional, onboarding offers it switched on. See upstream's Web Search guide.
Only the router takes a route; every other Worker is reachable only over service bindings, and the deploy turns off workers.dev and Preview URLs on all of them. That keeps the router the single Access-protected way in.
For production, set a Custom Domain on it:
The hostname must belong to an active Cloudflare zone and cannot conflict with an existing CNAME. Wrangler creates the DNS record and certificate, and publicBaseUrl can stay null — the deploy derives the public origin from the domain. For evaluation, use the account's workers.dev subdomain instead:
"publicBaseUrl": "https://acme-os.<subdomain>.workers.dev",
"workers": { "router": { "name": "acme-os", "route": { "workersDev": true } } }publicBaseUrl is required there, because nothing in deployment.jsonc knows your account's workers.dev subdomain. If using workers.dev that value must be https://<router-name>.<subdomain>.workers.dev. Two things read the origin — PUBLIC_BASE_URL, which upstream builds absolute links and OAuth redirect URIs from, and the Context sharing boundary under Storage — so a typo here would deploy successfully and then hide existing Context data and break every redirect.
On a custom domain the hostname is yours and has nothing to do with any Worker name, so pnpm cf-os check compares publicBaseUrl against customDomain instead: leave it null and the deploy derives the origin from the domain, or set it to exactly https://<customDomain>.
Upstream ships more Gatekeepers than the ones this starter always deploys: GitHub, Google, Slack and others. pnpm cf-os init offers the ones the hosted deploy offers, each package once, from a catalog built from the pinned submodule; one the starter cannot deploy from a name alone (Email, or any with KV, R2, service or Artifacts bindings) is never offered, and selecting it fails pnpm cf-os check with the reason.
Each one is an entry under gatekeepers, keyed by its slug:
"gatekeepers": {
"github": { "name": "acme-os-gk-github" },
"mcpportal": {
"name": "acme-os-gk-mcpportal",
"vars": { "MCP_PORTAL_URL": "https://portal.example.com/mcp" }
}
}-
The slug is the hosted install slug (
gatekeeper-mcp-portalismcpportal). It names theGATEKEEPER_<SLUG>binding and the/gatekeeper/<slug>route. -
nameis its Worker's name, a permanent service identity like those underworkers.initsuggests<router name>-gk-<slug>, as the hosted flow names it. -
varsare plain-text vars, merged over the package's own and replacing any set on the Worker another way; the deploy setsBASE_URL. One with a Workers AI binding of its own, such as Google's for reading Drive PDFs, also getsCF_AI_GATEWAY, set toaiGateway.nameas for Web Search, unlessaiGateway.enabledisfalseor the gateway is in another account; without it, those calls go through no gateway. The MCP portal needsMCP_PORTAL_URL:initasks for it, andcheckwarns while it is unset. -
Secrets go to the Worker, never into this file. Register
<public origin>/gatekeeper/<slug>/oauthas the redirect URI of an OAuth app with the provider, then runpnpm cf-os secrets <slug>;initoffers this when you deploy. By hand, after the first deploy:CLOUDFLARE_ACCOUNT_ID=your-account-id pnpm exec wrangler secret put CLIENT_ID --name acme-os-gk-githubcheckwarns while one is missing. -
Removing an entry unbinds it. The next deploy drops its two bindings, so the Workshop stops offering it and users' connections to it disappear. Its Worker, and the Durable Objects holding its state, keep running unbound: the CLI never deletes a Worker. To delete it,
CLOUDFLARE_ACCOUNT_ID=your-account-id pnpm exec wrangler delete --name <its Worker name>.
Cloudflare OS supports three ways to sign users in. This starter deploys Cloudflare Access.
| Method | How it works | In this starter |
|---|---|---|
| Cloudflare Access | Access verifies identity before the request reaches the Worker, and the Workshop trusts the signed Access JWT. The password login and signup pages are disabled. | Deployed by default |
| Built-in password accounts | Cloudflare OS serves its own username and password login plus signup. This is the upstream default. | Requires deploy script changes |
| Auth Gatekeepers | Gatekeepers that advertise providesAuth add "Continue with ..." buttons, alongside or instead of password login. |
Requires deploy script changes |
Access mode is the default here because unauthenticated requests never reach application code. packages/cf-os/src/core/deployment.ts implements it by setting CF_ACCESS_ISS and CF_ACCESS_AUD on the Workshop and building the frontend with VITE_CF_ACCESS_MODE=true.
To run another method, drop those two variables and the build flag, then set upstream's AUTH_GATEKEEPERS allowlist for provider sign-in. DISABLE_PASSWORD_AUTH=true makes a deployment provider-only. Upstream ignores it unless at least one auth Gatekeeper is allowlisted, so a deployment cannot lock everyone out. The wrapper's validation assumes Access mode, so review the upstream Workshop backend and frontend documentation before changing it.
The admins list gates /admin in every method.
Create a self-hosted Access application covering the router's hostname. Then configure:
issuer: the team origin, such ashttps://acme.cloudflareaccess.com, with no path.audience: the application's AUD tag.admins: Access-verified email addresses allowed into/admin.
Access policies decide who can sign in. The admins list decides which signed-in identities can change runtime policy. Keep both narrow.
Wrangler supports automatic provisioning for KV and R2. Leave these values as null for a new deployment:
"context": {
"sharingDomain": null,
"kvNamespaceId": null
},
"resources": {
"blueprintsKvNamespaceId": null,
"avatarsKvNamespaceId": null,
"blueprintContentBucket": null
}Wrangler creates resources with the Worker name as a prefix and reconnects them on future deploys. To adopt existing data, replace the relevant null with a KV namespace ID or R2 bucket name.
context.sharingDomain is not storage but a data-isolation boundary: Context collections are visible only within it. null scopes them to the deployment's public origin, which is what the hosted deploy does. Changing the boundary hides existing collections even with the right KV bound, so pin it to a literal string when a hostname change must not move it:
"context": { "sharingDomain": "https://os.example.com" }The Context Gatekeeper can use Artifacts as Git-compatible storage for Context collections. This is disabled when enabled is omitted or false and requires Artifacts access on the deployment account. Enable it without specifying a namespace to use gatekeeper-context-collections:
"artifacts": { "enabled": true }To isolate repositories under another stable namespace, add the optional property:
"artifacts": {
"enabled": true,
"namespace": "acme-context-collections"
}Artifacts creates the namespace implicitly when the first repository is created. Keep the selected namespace stable: existing Git-backed collections refer to repositories in it. Disabling the binding later stops repository refresh and token management but does not delete repositories; the last synchronized Context content remains readable. Write tokens grant repository mutation authority, so protect them like other credentials and revoke them when no longer needed.
Every provider, Workers AI included, is reached through AI Gateway. The transport is the Workshop's WORKERS_AI binding, which is pre-authenticated inside your own account — so the default configuration needs no API token at all:
"aiGateway": {
"enabled": true,
"name": "default",
"accountId": null,
"providers": ["cloudflare"]
}Cloudflare can create the default gateway on first use. accountId: null means the gateway lives in the deployment's own account, which is what makes the binding transport usable.
The binding stays bound whatever you configure here: as well as carrying gateway traffic, it is what the agent's webFetch tool runs document-to-Markdown conversion on.
| Configuration | Result |
|---|---|
enabled: true, providers: ["cloudflare"] |
Workers AI models over the binding. No token, no keys of your own. The default. |
Add anthropic or openai |
Their models appear too. Keys live on the gateway (Unified Billing or BYOK), not in this repository. Still no token. |
Add google |
Needs CF_AI_GATEWAY_API_TOKEN. pi's Google adapter refuses a custom fetch, so Google inference cannot ride the binding. |
accountId set to another account |
Needs CF_AI_GATEWAY_API_TOKEN. The binding only reaches gateways in the Worker's own account, so the generated config sets CF_AI_GATEWAY_USE_BINDING: "false" and the HTTPS transport takes over. |
enabled: false |
No deployment-managed catalog. Each user supplies their own model API keys — and a Workshop migrated from the hosted deploy will show an empty model picker. |
pnpm cf-os check reports which of the last two applies before it deploys anything.
Only the two rows above need one. Create a narrowly scoped API token following the current AI Gateway authentication guidance — a Run + Read token; current guidance calls for Account permissions AI Gateway - Read, AI Gateway - Edit, and Workers AI - Read. Install it without putting the value on the command line:
CLOUDFLARE_ACCOUNT_ID=your-account-id pnpm exec wrangler secret put CF_AI_GATEWAY_API_TOKEN --name your-workshop-workerNote: Use the accountId from your own deployment.jsonc, i.e the account the Workshop deploys to.
In exactly those cases the generated Wrangler config declares the secret as required, so the deploy fails clearly if it is missing. On the default path it does not, so a deployment that needs no token is never blocked waiting for one.
The starter enables structured custom logs and a private console-backed Error Reporter, while invocation logs, traces, and browser reporting remain separate controls. See Observability and error reporting for signal selection, sampling, triage, privacy, source maps, frontend reporting, and external destinations.
Keep deployment-owned Gatekeepers under packages/, outside the cloudflare-os submodule. packages/cf-os/src/core/deployment.ts binds this repository's example as GATEKEEPER_CUSTOM and Context as GATEKEEPER_CONTEXT, twice each: on the Workshop with the GatekeeperVendor entrypoint for RPC, and on the router with no entrypoint, where the binding name is what routes /gatekeeper/custom and /gatekeeper/context to it. A Gatekeeper that serves HTTP — an OAuth redirect, for instance — needs both.
The minimal example flow is:
types.d.tsdefines the API visible to TypeScript callers.CustomSessionImpl.getDeploymentInfo()authorizes an observation before returning data.CustomGatekeeperreads deployment values and creates the session.CustomAccountexposes that session as a singleton.GatekeeperVendoradvertises credential-free auto-provisioning.- The Workshop service binding makes the vendor available to Cloudflare OS.
Read the package guide and upstream write-gatekeeper skill before adding OAuth, URL-scoped resources, writes, simulations, hooks, configurator UI, or stricter observer verification.
Prefer wrapper-owned Workers and service bindings over patches inside the submodule. Modify upstream only when a Worker boundary cannot express the behavior, and keep the change as a reviewable upstream commit or fork rather than a generated overlay.
npx cf-os@latest update brings the project up to date with the starter: it merges the starter's main (fetched from its URL, so no remote is needed), checks the submodule out at the pin that merge brings, runs pnpm install, and writes .cf-os/update.md. The brief lists both commit ranges and any conflicts, which git leaves in place, and gives a coding agent the steps to finish and summarise the update, starting from the operator skill's updating guide. The starter's pin has already been reviewed against the starter, so for that path the list below is a check on your own customisations. --dry-run lists what would be merged, and git reset --keep <old HEAD> (the brief names it) undoes it.
Moving the submodule past the starter's pin is a review of its own. npx cf-os@latest update --upstream picks upstream's main as the target and stages it without committing; or check out the commit by hand. It first merges the starter's main, committed, as plain update does, so run that and commit it first to leave the staged change a gitlink-only review. Then:
- Record the current
cloudflare-osgitlink for rollback. - Update the submodule to the intended upstream commit.
- Review the Wrangler base-config changes for the Workshop, router and the three ambient Gatekeepers (Context, Scheduler, Web Search), along with the Gatekeeper contracts.
pnpm lintcatches one class of drift for you:packages/cf-os/src/core/deployment-config.tsreuses upstream'sWranglerConfig, so a changed key there is a type error, not a silently dropped one. A new upstream Gatekeeper arrives as optional; to deploy it everywhere, as the hosted topology does, it needs a Worker and bindings inpackages/cf-os/src/core/deployment.tsand a row in the naming table incore/naming.ts. - Compare three entries of
cloudflare-os/pnpm-workspace.yaml'scatalog:with this repository's:capnweb,typescriptand@cloudflare/workers-types. Only those three have to match. The packages herelink:to the submodule's rather than sharing a workspace with them. So the submodule's dependencies come from its own lockfile, and this catalog does not govern them. Types still cross the link, though. Each install has its owncapnwebcopy whatever the version, and today only its types cross. Keeping the ranges in step keeps those types compatible, and keeps the two copies from diverging if runtime code ever crosses too. - Run
pnpm install, thenpnpm lintandpnpm cf-os check. The rootpostinstall(cf-os install) reinstalls the submodule's workspace from its lockfile.optimisticRepeatInstall: falsemakes it run even when this repository's own lockfile has not changed, which is what a gitlink bump looks like from here. If the hook was skipped (--ignore-scripts),pnpm cf-os install --checkand every build, test and deploy entry point stop and tell you to runpnpm cf-os install. - Deploy and verify Access, administrator access, storage, configured AI, Context, Scheduler, Web Search, custom observations, a connection through each optional Gatekeeper, and the Error Reporter query surface.
- If needed, restore the previous gitlink and redeploy, or use Workers rollback when bindings remain compatible. Neither works across a Durable Object migration: once a pin that adds a
migrationstag to a Worker'swrangler.jsonchas deployed, that Worker can only be repaired forward. Check for new tags in step 3, the Workshop's especially, before deploying.
Do not update the submodule blindly. The deploy derives from upstream configs so incompatible base changes remain visible during review and checks.