Every HTML artifact Claude builds for you has the same problem: it dies in the sandbox. Beautiful in the chat, useless as a deliverable. This page is the artifact that fixes that — and it tells you how to do the same thing for everything you build next.
What you'll do
// the end-state, before we build it
By the end of this guide, your setup will do seven things:
share.yourdomain.com/<slug>. No expiring tokens, no sandbox links.main is the only way anything goes live.Cost: $0 if you already own a domain on Cloudflare. Workers' free tier is 100k requests/day, Web Analytics is free, Access is free to 50 users, and the comments database fits in a free Postgres tier. Items 1–5 stand on their own; stop after section 10 if you don't want comments.
// what you need on your machine and in your accounts
npx runs Wrangler; you don't install it globally.gh CLI.jq, for the Access scripts.Put your Cloudflare credentials in a file outside the repo, and never commit it:
~/.cloudflare-env# Workers + zone management CLOUDFLARE_API_TOKEN=... # Zero Trust: Access Apps and Policies (Edit), Identity Providers (Read) CLOUDFLARE_ZT_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_ZONE_ID_YOURDOMAIN=...
// skip if your domain is already there
Add the site in the Cloudflare dashboard, then point your registrar's nameservers at the two Cloudflare gives you. Propagation is usually minutes. You want the zone showing Active before you go on — everything downstream assumes Cloudflare is authoritative for the domain.
You do not need to move your registrar. Only the nameservers change.
// a Worker whose whole job is serving a directory
— Step 1. Make the folder structure. This is also a git repo, so make it one now:
bashmkdir -p ~/share/deploy ~/share/src
cd ~/share && git init && npm init -y
— Step 2. Save this as wrangler.jsonc:
wrangler.jsonc{ "name": "share-worker", "main": "src/index.ts", "compatibility_date": "2026-08-01", "compatibility_flags": ["nodejs_compat"], "workers_dev": false, "assets": { "directory": "./deploy", "binding": "ASSETS", "not_found_handling": "404-page" // "run_worker_first": [...] <- section 11 adds this } }
Everything under deploy/ is the live site. deploy/foo/index.html becomes
share.yourdomain.com/foo. That directness is the whole point of the design, and it is also
the reason section 6 exists.
— Step 3. Drop a placeholder deploy/index.html so the
first deploy has something to serve. An <h1>ok</h1> is fine.
— Step 4. Authenticate and deploy once, by hand, to prove the pipe works:
bashnpx wrangler login
npx wrangler deploy
This is the only time you run wrangler deploy yourself. Section 6 explains why, and takes
the ability away on purpose.
// one dashboard field, no DNS record to hand-write
Cloudflare dashboard → Workers & Pages → your worker →
Settings → Domains & Routes → add
share.yourdomain.com. Cloudflare creates the DNS record and provisions the certificate.
bashcurl -sI https://share.yourdomain.com | head -3 # HTTP/2 200
// the section that exists because v1 got this wrong
Version one of this guide told you to publish by running npx wrangler deploy. That
works exactly as long as you are the only person and the only process that ever deploys. The moment
that stops being true, it becomes a way to destroy work.
deploy/ directory, not a diff. So a deploy
from a checkout that is behind origin/main doesn't just skip the newer pages — it
deletes them from the live site. And the next deploy from CI reverts whatever you had locally
but hadn't committed. We lost a rebuilt page this way on 21 August 2026, in both directions within
the same afternoon.
The fix isn't discipline, it's removing the option. Make CI the only thing holding the deploy credentials, and make the local script refuse.
— Step 1. Push the repo to GitHub and add two secrets:
bashgh secret set CLOUDFLARE_API_TOKEN
gh secret set CLOUDFLARE_ACCOUNT_ID
— Step 2. Save this as
.github/workflows/deploy.yml:
deploy.ymlname: deploy on: push: branches: [main] paths: ['deploy/**', 'src/**', 'wrangler.jsonc', 'package.json', 'package-lock.json', '.github/workflows/deploy.yml'] concurrency: group: deploy cancel-in-progress: false # never race two deploys jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20, cache: npm } - run: npm ci - run: npx --no-install wrangler deploy # the pinned copy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Pin wrangler. Add it as an exact dev dependency
(npm install --save-dev --save-exact wrangler@<version>) and commit the lockfile,
so npm ci installs the same version on every run. --no-install makes the
step fail if the pin goes missing, instead of quietly fetching the newest release. Unpinned, ours
once fetched a release that arrived without its Linux runtime binary, and the deploy failed until
re-run. To upgrade, run the same install with a newer version, then commit and push.
cancel-in-progress: false matters more than it looks. If two deploys can run at once,
the slower one finishes last and wins, and "last" has nothing to do with "newest".
— Step 3. If you keep a local deploy script for emergencies, make it
refuse by default. A script documented as deprecated that still runs cleanly is not a control — it's a
comment. Ours exits non-zero and prints the git commands instead, unless you pass an explicit
--emergency-github-is-down flag.
From here on, publishing is four lines:
bashgit add deploy/<slug>
git commit -m "Publish <slug>"
git push origin main
gh run watch "$(gh run list --limit 1 --json databaseId -q '.[0].databaseId')" --exit-status
Anything sitting in deploy/ that isn't committed and pushed is not durably live. It is
one CI run away from disappearing.
// so "publish this" means something
A skill is a folder with a SKILL.md in it. Claude reads the frontmatter to
decide when the skill applies, and the body as instructions once it does. You can write your
own from the rest of this guide, or start from ours.
Download the skills (.zip, 36KB)
bashunzip share-publishing-skills.zip
cp -R share-publishing-skills/publish-artifact ~/.claude/skills/
cp -R share-publishing-skills/share-platform-setup ~/.claude/skills/
cp -R share-publishing-skills/share-comment-sweep ~/.claude/skills/
~/.claude/skills/, not in a project's
.claude/skills/. A project-level skill only loads when you are working inside
that project, and publishing is something you do from wherever you happen to be.
Ours sat in the wrong place for months and simply never fired: it worked only because a
line in an always-loaded memory file happened to point at the file directly. If a skill of
yours seems never to trigger, check this first.
| Path | What it does |
|---|---|
publish-artifact/ | The daily driver. Publish an artifact, gate it, update it. |
references/workspace-config.md | Every value specific to your install, in one file. The only one you must edit. |
references/comments-and-stars.md | Data model, both endpoints, the anchoring scheme, SQL read recipes. |
references/troubleshooting.md | Every trap in this guide, plus the ones too dull to write up here. |
share-platform-setup/ | Stands the whole surface up from zero, in phases. The executable twin of this page. |
share-comment-sweep/ | The nightly routine, and the rails it runs under. Section 13. |
starter/wrangler.jsonc | Worker config, with the widget scoping commented out until you need it. |
starter/deploy.yml | The Actions workflow from section 6. |
starter/setup-access.sh | Reusable policy first, then the app. Takes your domain from $SHARE_DOMAIN. |
starter/refresh-access-apps.sh | Regenerates the audience-to-path map. Run it on every deploy. |
starter/sweep-config.example.json | Scope and limits for the sweep. |
The Worker source and the comment widget are deliberately not in the zip. They are shaped closely enough around our own setup that copying them would mislead more than help. What's worth taking is the design, which sections 11 and 13 describe in enough detail to rebuild.
The frontmatter is the whole routing mechanism. description is the only
signal Claude has for whether the skill applies, so it should read as a list of the things
someone would actually say, and should say what the skill is not for when a
neighbouring skill exists:
SKILL.md--- name: publish-artifact description: > Publish an HTML artifact to share.yourdomain.com with a clean URL and, for client work, a Cloudflare Access gate. Triggers on "publish this", "make this shareable", "give me a URL for this", "put this behind Access", "republish". Does NOT cover standing up a new surface from scratch — that is the share-platform-setup skill. ---
Then the body. The sections that earn their place, in the order we found we needed them:
Ours runs about two hundred lines with roughly the same again in the reference files. That
split matters: the body is loaded every time the skill fires, so anything an agent needs only
occasionally belongs in references/, which it reads when it needs it.
// what the loop actually looks like
claude code# after Claude has built an HTML artifact: > publish this # Claude: picks a slug, confirms it, writes deploy/<slug>/index.html, # injects analytics, commits, pushes, watches the run, hands you the URL.
Assets can ride along. Put them inside the slug folder and reference them relative to
index.html — assets/logo.png, never ../assets/logo.png, which
escapes the slug and 404s. The Worker serves the whole directory, nested folders included.
// who opened it, without a cookie banner
Cloudflare Web Analytics, in Automatic Setup mode, injects its beacon at the edge. No snippet in your HTML, nothing to remember per page. Turn it on for the hostname in the dashboard under Web Analytics.
If you also want GA4 — for funnels, or because someone else on the team lives in it — that one does
need a snippet in <head>, which is a good reason to make your skill inject it rather
than trusting yourself:
html<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script> <script> window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'G-XXXXXXXXXX'); </script>
curl never qualifies — not bare, not with Accept: text/html, not with a
browser User-Agent, not with both. So any curl check reports the beacon missing on every page you
own, and looks exactly like an outage. Open the page and ask the browser instead:
js · devtools console// expect 1 document.querySelectorAll('script[src*="cloudflareinsights"]').lengthAn earlier version of this section said the
Accept header fixes the curl check. It
doesn't; we tested every combination. GA4 is a literal tag in your own HTML, so it shows up either
way, which is why only one of the two checks ever looks broken. And never switch Web Analytics to
its JS-snippet mode just to read the site token: that turns Automatic Setup off, and every check
after it will confirm the outage you just caused.
// Cloudflare Access, at the edge, before your Worker runs
An Access application protects a path pattern. A visitor who isn't authenticated gets Cloudflare's login screen; one who is gets a signed token attached to the request. Your Worker never sees the unauthenticated ones.
Create the policy first, then the app, and attach the policy by id. If you inline the rules when creating the app, you get a legacy app-bound policy that can't be reused anywhere else, and Cloudflare files it separately in the dashboard. Two API calls, in order:
bash# A. reusable, account-level policy POST /accounts/{acct}/access/policies { "name": "Acme team", "decision": "allow", "include": [{ "email_domain": { "domain": "acme.com" } }] } # B. the app, attaching the policy by id POST /accounts/{acct}/access/apps { "name": "Acme <> You", "type": "self_hosted", "self_hosted_domains": ["share.yourdomain.com/c/acme*"], "policies": [{ "id": "<policy-id>", "precedence": 1 }] }
Conventions worth stealing:
/c/<client>/<asset>, so
the namespace matches your repo and collisions are structural rather than accidental./c/acme* and every
asset you ever put under it inherits. You configure Access once per relationship./c/acme* also admits /c/acme-private. Slug naming is a security
decision.@acme.com" trusts anyone who can receive mail at that domain. The
one-time PIN proves inbox control, not employment — not that they still work there, not that they're
the person you meant. That's usually a fine trade for a read-only page. Once section 13 is running it
is no longer read-only, so read that section's rails before you turn it on.
Access controls who can open a page. It does not stop someone who opened it from saving, screenshotting or forwarding what they saw. Gate for accountability and friction, not for secrecy.
// proof the pattern generalizes
Once the endpoint-underneath-the-document pattern exists, a second annotation feature is an
afternoon. We added per-viewer stars — /c/acme/event/__stars, same table shape, same role,
same inherited gate — so reviewers could star people on an event list and each see their own picks.
It's worth building mostly for what it tells you: any per-viewer annotation you want is this shape. Reactions, approvals, sign-off, a per-reader checklist. The auth question, which is the hard one, is already answered by where you mounted the route.
// the part that changes how review feels
Add one checkbox to the comment box: apply as an edit. It sets a flag on the row. Then run a routine nightly that reads the flagged comments and actually edits the pages.
What that buys is small to describe and large to use. Feedback on an AI-written document normally lives somewhere the document isn't — a Slack thread, an email reply, a call where someone reads changes out loud — and a human carries it across. Here the note lands on the sentence, and the sentence is what changes. Nothing is retyped and nothing is lost in the carrying.
The routine's night job, in order: read the queue; pull the repo; resolve each comment's page to a file; apply the smallest edit that satisfies the request; commit; push; reply on the thread; write a run log.
You are about to let an unattended agent edit live pages based on text written by anyone your Access policy admits. That is a prompt-injection surface with your client's document as the target. The rails below are not polish; they are the reason this is shippable.
Two bugs worth inheriting rather than rediscovering. A publish step that rewrote a timestamp on every
run left the tree permanently dirty, which blocked the routine's git pull every single
night. And the diff-size guard originally read only git diff — which never shows newly
created files, so a run that created a file sailed straight through it. Check
git status too.
Drop a marker into any page you want to be self-documenting, and have the routine append a row each time it applies an edit there:
html<ul class="changelog">
<!-- sweep-changelog -->
</ul>
Date, what changed, who asked, which comment. The page then carries its own audit trail, visible to
exactly the people who can see the page — which is a more useful place for it than a log file only you
read. Mask the email addresses (s***@acme.com): on any page that strangers
can reach, printing them in full would expose one commenter's address to every other visitor.
The demo page has one at the bottom. Leave it a comment and you'll appear in it tomorrow.
// six subsystems, one paste
bash# public page serves curl -sI https://share.yourdomain.com/<slug> | head -3 # gated page redirects to Access, rather than serving curl -sI https://share.yourdomain.com/c/<client> | head -3 # analytics present — curl can't see the edge-injected beacon; open the page and # run the devtools check in section 9 (expect 1) # widget injected on a gated page (after authenticating) curl -s https://share.yourdomain.com/c/<client> | grep -c __share-comments.js # CI deployed it, and your local script refuses to gh run list --limit 1 ./publish.sh ; echo "exit=$?" # expect a refusal
Then the loop itself, once, by hand: leave a comment on a gated page, tick apply-as-an-edit, run the routine, and check five things — the file changed, the commit touches only that page, the thread has a reply, the thread is resolved, and a run log exists. If any one of those is missing, the routine will fail silently at 2am rather than loudly at your desk.
// not in this guide, but worth your weekend
Two things from v1's version of this list got built, and they became sections 11 and 13. What's left:
acme.share.yourdomain.com is
one DNS record away. Makes the deliverable feel bespoke.If you build any of these, I'd like to see them. Or leave a comment on the demo — which is, at this point, the more fitting way to close the loop.
Comments on a published page new in v2
// highlight a sentence, say what's wrong with it
A reader selects text on the page and a comment box appears anchored to that text. Everyone who can open the page sees the thread. No accounts to create, no second tool to log into.
Do section 10 first. This whole feature rests on the gate, and on an ungated page it does nothing at all — quietly, which reads like a bug and isn't.
The one idea worth stealing
Put the comments endpoint underneath the document it belongs to:
The Access app protecting
/c/acme*now protects the API too, for free. Cloudflare hands your Worker a signed token; you check that the token's audience covers this path, and you're done. "Can open the page" and "can comment on the page" become the same permission by construction — not two things you keep in sync, one thing seen twice. There is no allowlist to drift, and no way to leave the API accidentally open after tightening the page.The corollary is the part that surprises people: a public page cannot have comments. No gate, no token, no identity to attach. That's a real limitation, not an oversight — and it's why the demo linked at the top of this page asks you for an email address.
The pieces
— Storage. One table. Anchors are stored as quoted text, not offsets (more on that below), and deletion is soft:
Give the Worker its own database role, scoped to that one schema, with
select, insert, updateand no delete grant. If the only way to remove a row is to setdeleted_at, then no bug and no injected instruction can destroy a comment thread.— The connection. Workers reach Postgres through Hyperdrive:
bashnpx wrangler hyperdrive create comments-db --connection-string="postgres://..."Related:
wrangler devdoesn't route through Hyperdrive at all, so a completely broken binding passes every local test. Usewrangler dev --remotefor anything touching the database, or you're testing nothing.— Verifying tokens across several apps. Each Access app signs with its own audience id. Keep a generated map of audience → the paths that app covers, refresh it from the Access API on every deploy, and accept a token only if its audience covers the path being commented on. Refreshing on deploy is what stops a newly created app going live against a stale map. (When it is stale, comments 403 on that path — make the error say so.)
— Injecting the widget. Use
HTMLRewriterto add the script tag to HTML responses, and scope which paths it runs on:That list is the blast radius of the feature. Widen it deliberately, and keep the negations: ours keeps roughly 740 images and a 13MB spreadsheet off the Worker entirely, since none of them have HTML to inject.
— The widget. No framework, no build step. Two decisions carried it:
<span>— mutates the document, which breaks any artifact with its own JavaScript. Highlights paint ranges without touching the DOM.keydownlistener ondocumentsees the widget's host element, never a<textarea>. A slide deck that checkse.target.tagNamebefore callingpreventDefault()on Space and the arrow keys turns slides while someone types a comment, and a deck that advances on click turns the slide when they drag to select text. None of it shows up locally, because the widget only exists on gated paths. The fix belongs in the page: walke.composedPath(), which crosses the boundary, and ignore clicks that were really drags. Only listeners ondocument,windowor another ancestor of the host are exposed; a handler on one specific element never sees the widget's keystrokes.share:comment-focusevent ondocumentcarrying the anchor's DOMRangeindetail.range, then scroll on the next animation frame, and only if the range is visible. The page does the revealing: Listen ondocument: the event doesn't bubble, so a listener onwindowmisses it. Reveal inside the handler rather than after a timeout, because the widget measures on the next frame. Tabs and collapsed sections take the same listener with a different reveal.— Anchoring. Store the quoted text plus a little on either side, and re-find it on load. Character offsets are a hint at best: republish the page and every offset is wrong, while the sentence is still findable. An anchor whose text has genuinely gone becomes an orphan — shown in the sidebar without a highlight, never silently dropped.
— Internal notes. If your team wants notes on a client-visible page, filter them in SQL, by the viewer's email domain. Never send them to the browser and hide them with CSS. Someone will open dev tools eventually, and it will be the one time it matters. Then make private the default: on a page the client can open, start your own team's composer with the internal switch on, and let people turn it off when they mean the client to read the comment. A default that has to be remembered is a leak waiting for a busy afternoon. Do it on the server too, so a comment that arrives without the flag from a team member is internal, and remember that resolving a thread only tucks it away in the sidebar; the client's response still carries it. The internal flag is the only thing that keeps it out.