Deploying agent2web

agent2web runs on Cloudflare Workers with D1 for metadata and R2 for the files. There is no server to maintain and nothing to keep patched. Run it locally first — that part needs no Cloudflare account at all — then deploy either from a browser or from the terminal.

You will need: a Cloudflare account on the Workers Paid plan ($5/mo), and a domain on Cloudflare only if you want <slug>.yourdomain.com URLs. The paid plan is for CPU time, not scale — see why.

Run it locally first

Worth doing even if you intend to deploy immediately: it proves the build works on your machine, and it is where you will do all subsequent development. No Cloudflare account is involved — D1 and R2 are emulated on disk.

01

Install and run the tests

git clone https://github.com/raveli/agent2web && cd agent2web npm install npm test
Expect
# tests 131 # pass 131 # fail 0

The suite bundles the Worker exactly as deployment does, boots it in Miniflare with local D1 and R2, and drives it over HTTP. A green run means the code path that deploys works.

02

Create local secrets and start it

npm run gen-secrets # save the admin password it prints cp .dev.vars.example .dev.vars # paste the values in npm run dev
Expect
[wrangler:info] Ready on http://localhost:8787

Then publish something and open it:

curl -s localhost:8787/mcp \ -H "authorization: Bearer $A2W_API_TOKEN" \ -H 'content-type: application/json' \ -H 'accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{ "name":"site_publish", "arguments":{"slug":"hello","html":"<h1>Hello</h1>"}}}' open http://localhost:8787/s/hello/ open http://localhost:8787/admin # sign in with the admin password
Full local walkthrough docs/local-testing.html covers the password gate, connecting Claude Code, and a troubleshooting table of the failures we actually hit.

Deploy from the browser

The button reads wrangler.jsonc, provisions what it declares, and connects Workers Builds so every push to your copy redeploys.

01

Generate your secrets first

The setup flow asks for them, so have them ready. Run this locally:

npm install && npm run gen-secrets
Prints
A2W_SECRET=… signs cookies, hashes tokens at rest A2W_API_TOKEN=… optional bearer token for Claude Code / curl / CI A2W_ADMIN_PASSWORD_HASH=pbkdf2c.100000.6.… # Admin password (stored nowhere else — save it now): …
Save the admin password now Only its hash is stored. Lose it and the only way back is to set a new A2W_ADMIN_PASSWORD_HASH on the Worker.
02

Press the button

From the repository README, or directly:

https://deploy.workers.cloudflare.com/?url=https://github.com/raveli/agent2web

Cloudflare clones the repo into your GitHub account, then provisions the two resources declared in wrangler.jsonc:

BindingResourceHolds
DBD1 databaseSites, versions, file index, OAuth tokens, sessions
BLOBSR2 bucketThe published files themselves

Paste the secrets from step 01 when asked. Workers Builds is wired up as part of the flow, so later pushes to that repository deploy themselves.

Nothing else to fill in There is no URL to enter — you could not know it yet, and the Worker records the origin you reach it on the first time you sign in as the owner. The optional variables are deliberately not declared, because the setup form treats every declared variable as required. Add them if and when you need them, but note that every deploy replaces the Worker's plain-text variables with the ones in its config, so a variable added only under Settings → Variables disappears on the next push. The fork route keeps them as build variables instead, which survive.
Read this before you rely on the button The repository Cloudflare creates is not a fork. It is a new repository holding one squashed commit — source repo import, with no parents — so it shares no history with this project. GitHub's Sync fork button will never appear, and git merge upstream/main refuses outright without --allow-unrelated-histories, after which every file conflicts. Cloudflare publishes no guidance on taking upstream changes after a button deploy.

This happens even if you fork this repository first and point the button at your fork: the button always creates a new repository. So if you intend to keep up with the project, take the fork route below instead. If you just want it running and do not care about updates, the button is fine.
03

The fork route, if you want upstream fixes

Slightly more setup, and then updates are one merge forever. Fork this repository on GitHub, then create the two resources the placeholder cannot know:

npx wrangler d1 create agent2web # note the database_id it prints npx wrangler r2 bucket create agent2web-sites
Do not commit your ids to the fork A fork of a public repository is public, and GitHub will not let you make it private. Your database id, Worker name and domains belong in Cloudflare, not in the fork's wrangler.jsonc. Leave that file exactly as it is upstream; that also means merging upstream can never conflict.

Connect the fork: Workers & Pages → Create application → Import a repository, pick your fork, and set:

SettingValue
Build commandnpm run build
Deploy commandnpm run deploy:live
Production branchmain, with builds for other branches turned off

Then, under Settings → Build → Variables, add the build variables deploy:live reads:

Build variableValue
A2W_DEPLOY_NAMEThe Worker's name, exactly as the dashboard shows it
A2W_DEPLOY_D1_IDThe database_id from above
A2W_SITES_BASE_DOMAIN, A2W_PUBLIC_URL, …Any plain-text setting you use, optional

deploy:live puts these into the deployed config and never into git. It passes through only known plain-text settings, so a secret entered here by mistake is not deployed as a readable variable. Add the secrets from step 01 on the Worker itself, under Settings → Variables and secrets, as type Secret; deploys leave those alone.

Then taking a release is one command
git fetch upstream && git merge upstream/main && git push
A real fork shares history and carries no local edits, so this fast-forwards, and the push deploys it.
Deploying from your machine instead Copy deploy.local.example.json to deploy.local.json (git-ignored), fill in the same values, and run npm run deploy:live. An environment variable with the same name wins over the file. Plain npm run deploy uses the placeholder id and fails.
Two things that will bite you Workers Builds can only see repositories the Cloudflare Workers & Pages GitHub App has been granted access to, which means an account or organisation where you can install it. A repository being public does not make someone else's connectable, so you need your own fork, not this one. You cannot fork a repository into the account that already owns it; if you own the upstream, connect it directly.

A2W_DEPLOY_NAME must match the Worker's name in the dashboard. A different name deploys a second, empty Worker beside yours instead of updating it.
04

Check it came up

Copy your URL from the end of the build log — the line under Deployed … triggers. Do not try to guess it: Cloudflare forks the repo as agent2web-deploy and names the Worker after the fork, so the address is usually agent2web-deploy.<your-subdomain>.workers.dev rather than agent2web.….

curl -s https://agent2web-deploy.<your-subdomain>.workers.dev/healthz
Expect
{"status":"ok","sites":0,"version":"0.2.0"}

This request proves the Worker is up, but it deliberately teaches it nothing. The origin that becomes the OAuth issuer is recorded from the first request that proves it came from you — signing in at /admin, or an authenticated MCP call. An anonymous request is served on whatever host it arrived at and stores nothing, so a stranger cannot point your issuer at their domain by being first through the door.

So sign in on the address you intend to keep Use the workers.dev URL above, not a per-version preview URL. Whichever origin you first sign in on is the one that sticks.

A 500 with a configuration message instead means a secret is missing or malformed — the body names the variable. That is deliberate: bad configuration stops the Worker rather than half-working.

Later, when you attach a custom domain Set A2W_PUBLIC_URL to the new origin, as a build variable if you took the fork route, otherwise under Settings → Variables. From then on it must match exactly — scheme, host, no trailing slash — because it is the audience of every token this server issues, and a mismatch breaks Claude's connector while the sites themselves keep working.

Connect Claude

01

Claude web or desktop

Settings → Connectors → Add custom connector, with the URL:

https://<your-worker>/mcp

Claude registers itself, then sends you to your sign-in page. Enter the admin password and approve the connection. Claude never sees the password — it receives a token scoped to publishing, which you can revoke at /admin/connections.

Needs a deploy This flow cannot run against localhost: it requires public HTTPS with a certificate a browser trusts. To try it before deploying, use cloudflared tunnel --url http://localhost:8787 and set A2W_PUBLIC_URL to the tunnel hostname.
02

Claude Code

claude mcp add --transport http agent2web https://<your-worker>/mcp # or skip the browser entirely with the static token: claude mcp add --transport http agent2web https://<your-worker>/mcp \ --header "Authorization: Bearer $A2W_API_TOKEN"

Then ask it to publish something. site_publish returns the URL.

Your own domain

Optional. Path URLs work from the moment you deploy; this is about prettier addresses and, for subdomains, giving each site its own browser origin.

URL formNeedsNotes
/s/<slug>/ nothing Works immediately, including on workers.dev. Pages get a sandbox CSP because they share the Worker's origin, so localStorage and same-origin fetch will not work in them.
<slug>.sites.example.com a domain on Cloudflare Each site gets its own origin, so no sandbox and root-absolute paths like /style.css resolve.
reports.customer.example Cloudflare for SaaS Per-site custom domains. Supported by the app; the provisioning is manual.
01

Subdomain hosting

Three things, all in the Cloudflare dashboard for a domain you have added:

1. DNS → a proxied (orange cloud) record for *.sites.example.com 2. Worker → Settings → Domains & Routes → add route *.sites.example.com/* 3. Variable → A2W_SITES_BASE_DOMAIN = sites.example.com

Use a different domain for sites than for the app. Published pages are arbitrary HTML; giving them their own origin is what keeps them away from your admin session.

workers.dev has no wildcards There is no way to get <slug>.your-worker.workers.dev — wildcard subdomains do not exist there. Subdomain hosting requires a domain you control.
02

A custom domain for one site

# from Claude, or any MCP client site_set_domain(slug: "quarterly", domain: "reports.customer.example")

The Worker answers for that hostname immediately; DNS and the certificate are yours to arrange. The tool prints the steps, and the site's admin page repeats them.

Plan requirements and cost

The $5/mo Workers Paid plan is required for CPU time per request, not for traffic. Password hashing costs 600,000 PBKDF2-SHA256 iterations, about 130 ms. The Free plan caps a request at 10 ms of CPU — enough for roughly 40,000 iterations, an order of magnitude below current guidance. Rather than weaken the hash, agent2web asks for the paid plan.

MeasuredFree planPaid plan
10,000 iterations · 2.3 msfitsfits
50,000 iterations · 10.3 msat the ceilingfits
210,000 iterations · 43 msoverfits
600,000 iterations · 130 msover0.4% of budget
Why the hash is chained rather than one call A deployed Worker refuses any single PBKDF2 derivation above 100,000 iterations — NotSupportedError: Pbkdf2 failed: iteration counts above 100000 are not supported — because Cloudflare's CPU limiter cannot interrupt BoringSSL mid-loop and so declines the work up front. This is a hard cap on both plans, separate from the CPU budget above. So the 600,000 iterations run as six chained rounds of 100,000, each feeding the next: an attacker still pays the full cost per guess, and no single call breaks the rule.

Worth knowing if you fork this: the cap does not exist locally. workerd removed its own default in commit 12bc98a9, so wrangler dev, Miniflare and the test suite all run uncapped, and only the deployed enforcer objects. Code that passes every test can still be broken in production, which is why test/crypto.test.ts asserts the ceiling against the runtime call itself instead of waiting for a runtime to raise it.

In practice the cost is negligible. The paid plan includes 30M CPU-milliseconds a month, which is about 230,000 password hashes. Real usage is a handful: the owner signs in once per 12-hour session, and a visitor to a protected site pays it once per seven-day cookie. Serving pages is a few milliseconds. R2 gives 10 GB and charges nothing for egress.

Where it is not per-session HTTP Basic auth (curl -u :password) issues no cookie, so it pays the hash on every request. That path is throttled on the same counter as the login form, which caps both the brute-force risk and the CPU it can consume.

Troubleshooting

SymptomCause and fix
Worker returns 500 with a configuration message Deliberate — bad config stops startup instead of half-working, and the body names the variable. A scrypt. hash from an older self-hosted build lands here too: Cloudflare has no scrypt, so regenerate with npm run gen-secrets.
Claude's connector will not authenticate, but the site loads fine Almost always A2W_PUBLIC_URL not matching the URL you dialled. It is the OAuth issuer and token audience, so it must be exact — scheme, host, no trailing slash or path.
406 Not Acceptable from /mcp The accept header must list both application/json and text/event-stream. That is the MCP spec, not a quirk of this server.
Admin login rejects the right password Confirm A2W_ADMIN_PASSWORD_HASH was stored whole. Hashes are dot-separated precisely so an unquoted $ cannot be eaten by a shell.
Subdomain URLs 404 All three pieces are needed: proxied wildcard DNS, a Workers route for *.sites.example.com/*, and A2W_SITES_BASE_DOMAIN as a bare hostname with no port. On workers.dev it cannot work at all.
A published page cannot use localStorage Expected on /s/<slug>/ URLs: they are sandboxed because they share the Worker's origin, and that cannot be turned off. Give sites their own subdomain (A2W_SITES_BASE_DOMAIN) or a custom domain, where they are not sandboxed.
Want to see what the Worker is doing npx wrangler tail streams live logs. The dashboard's Observability tab keeps history.

What is verified, and what is not

This guide was written alongside the port, and not all of it has been exercised against a live Cloudflare account. Where it has not, that is said rather than implied.

ClaimStatus
The 98 tests pass against the real Worker bundle in Miniflare with local D1 and R2verified
npm run dev serves, publishes, gates on password, and runs the admin UIverified
wrangler deploy --dry-run resolves both bindings; 259 KB gzippedverified
Every wrangler subcommand quoted here exists in wrangler 4.114verified
PBKDF2 timings in the table abovemeasured, Apple silicon
The Deploy button provisions D1 and R2 from wrangler.jsonc and collects the secretsverified on a real deploy
A deployed Worker boots, serves /healthz, and publishes correct OAuth metadata on a custom domainverified on a real deploy
Production Workers reject a single PBKDF2 call above 100,000 iterationsfound the hard way — it 500s the login form
Chained hashing verifies a password on a deployed Workerverified — admin sign-in succeeds, wrong passwords get 401
600,000 chained iterations fit the CPU budget on Cloudflare's own hardwareverified — ~130–190 ms over baseline on a live Worker
The OAuth server completes DCR, PKCE authorize, consent and token errors when deployedverified against a live Worker
Claude web's connector completes the OAuth flow against a deployed Workerthe server side is verified; Claude's client not yet run

The unverified rows all need an account, which is the next thing to do. Cloudflare's x86 cores may be 1.5–2× slower than the machine these timings came from, which would put the hash at 200–260 ms — still under 1% of the 30-second budget.