host-game
Online page: https://cassette.gg/agents/host-game.md. Fetch it again before you act on it; don't work from a saved copy. Start of every task: https://cassette.gg/agents/guide.md.
Goal: the build is live in the developer's own hosting account at an HTTPS play URL, Cassette may frame it, and its build hash can
be verified. Cassette never hosts, pins, caches, proxies, mirrors or pays for the build: it only indexes a pointer (the play URL, or
ar://<id> on Arweave once it opens, plus the build hash). Everything below uses the providers' own tools (pinned versions, run through npx), gh, git,
curl, jq, sha256sum and GNU find/cp.
Credentials stay on this machine. Logins live in the providers' tools; tokens (CLOUDFLARE_API_TOKEN, NETLIFY_AUTH_TOKEN, VERCEL_TOKEN,
PINATA_JWT, the agent's key) come from the environment. Never send them to Cassette, never put them in the game folder, never print them, never
pass a token or key as a command-line argument (process lists show arguments; use the environment or a file read by the tool).
0. Conventions
GAME=/abs/path/to/dist # the BUILT folder: index.html, cassette.json, assets
API=https://cassette.gg # the platform API
cd "$(mktemp -d)" # run the providers' tools from an empty folder: no wrangler.toml, netlify.toml, functions/ or .vercel/ can interfere
Pinned versions (use exactly these): [email protected], [email protected], [email protected], @ardrive/[email protected] (its command is turbo).
gh: any recent release (these flags are from 2.103.2).
1. Hosts
| Host | Play URL | Same URL on every deploy | Response headers | Deploy tool | The human's one step |
|---|---|---|---|---|---|
| Cloudflare Pages (default) | https://<project>.pages.dev/ | yes | _headers file | wrangler | approve a device code |
| Netlify | https://<site>.netlify.app/ | yes | _headers file | netlify-cli | approve a login URL |
| Vercel | https://<project>.vercel.app/ | yes | vercel.json | vercel | approve a device code |
| GitHub Pages (user site) | https://<owner>.github.io/ | yes | none (no X-Frame-Options, CORS * by default) | gh + git | approve a device code |
| Own server | your origin | yes | your server config | rsync/scp | none |
| Arweave (coming soon, not open yet) | https://<base32(id)>.arweave.net/<id>/ | no: stable game id; the play URL changes per build | none (gateways send no X-Frame-Options/CSP, CORS *) | turbo | 0 when the build fits the free tier, otherwise one top-up (card or POL) |
| IPFS (mirror only) | https://<cid>.ipfs.dweb.link/ | no: a new CID per build | the gateway's | curl to Pinata | a Pinata key |
Technical facts:
/.well-known/must be at the origin root. GitHub Pages: only the user site (<owner>.github.io, one game at its root) or a custom domain has it; a project site (<owner>.github.io/<repo>/) does not, so it only suits a game that sells nothing (no seller record).- A game that sells needs its seller record and documents at one stable place: on IPFS the address changes per build, so IPFS is a mirror only.
Arweave (coming soon) will work because the seller record and each document are their own permanent items (
ar://<id>). - Cloudflare Pages: 20,000 files, 25 MiB per file. GitHub Pages: 100 MiB per file, 1 GB per site.
- Arweave (coming soon) gateways cannot send headers or encodings, so these games need another host: cross-origin isolation (COOP/COEP in
_headersorvercel.json, threaded Unity or Godot exports,SharedArrayBuffer), precompressed.br/.gzassets orContent-Encoding. Use relative asset paths (./assets/…; root-absolute/assets/…only works on some gateways). Above about 50 MB, gateways load slowly and storage costs grow. - Saves on Arweave (coming soon): each build has a new origin, so
localStorageand IndexedDB start empty after an update. Keep saves in the Cassette shell's saves (bridge{type:'saves'},https://cassette.gg/agents/add-paywall.md§4), which survive builds. Keep no secrets inlocalStorage.
2. Detect first
Prefer a host the human already uses. Ask each tool (read-only, nothing prompts):
npx -y [email protected] whoami --json # logged in: {"loggedIn":true,"authType":"OAuth Token","email":"[email protected]","accounts":[…]}
# not: {"loggedIn":false} (exit 1)
npx -y [email protected] status --json # logged in: {"loggedIn":true,"account":{"Name":"Dev","Email":"[email protected]",…},…} (exit 1 outside a linked folder: read the JSON)
# not: {"loggedIn":false,"error":{"code":"NOT_LOGGED_IN",…}}
npx -y [email protected] whoami --json # logged in: {"username":"dev","email":"[email protected]","team":…}
# not: {"loggedIn":false,…} (exit 1)
gh auth status --hostname github.com --json hosts --jq '.hosts["github.com"][]? | select(.active) | {login, scopes}'
# logged in: {"login":"octo","scopes":"gist, read:org, repo, workflow"}; not: nothing
env | grep -oE '^(CLOUDFLARE_API_TOKEN|NETLIFY_AUTH_TOKEN|VERCEL_TOKEN|GH_TOKEN)=' || true # names only, never values
Use the first that is ready, in this order: Cloudflare Pages, Netlify, Vercel, GitHub Pages (needs the repo scope; gh not installed = not
ready). None ready: Cloudflare Pages, and the human approves one device login (step 3). Use another host only when the developer asks for it.
3. Device logins (no localhost callback: works in containers and over SSH)
Start the login in the background, read the URL and code from its output, give them to the human, then wait for the tool to say it is logged in.
nohup npx -y [email protected] login --device --browser=false > /tmp/cf-login.log 2>&1 < /dev/null &
sleep 20; grep -A3 'please visit' /tmp/cf-login.log
# To authorize Wrangler, please visit: https://dash.cloudflare.com/oauth2/device
# and enter the code: ABCD-1234
# You have 5 minutes to approve this request.
| Host | Start | Output to read | Done when |
|---|---|---|---|
| Cloudflare | npx -y [email protected] login --device --browser=false (background) | please visit: <url> and enter the code: <code> (5 minutes) | whoami --json says "loggedIn":true |
| Vercel | npx -y [email protected] login (background) | Visit <url> (the URL carries user_code=<code>) | whoami --json has a username |
| GitHub | gh auth login --hostname github.com --git-protocol https --web --scopes repo (background) | one-time code: <code> and https://github.com/login/device (15 minutes) | gh auth status lists the login with repo |
| Netlify | npx -y [email protected] login --request "Cassette: host <slug>" --json (returns at once) | {"ticket_id":"…","url":"https://app.netlify.com/authorize?…"} | npx -y [email protected] login --check <ticket_id> --json says {"status":"authorized"} (pending: wait; denied: start again) |
Tell the human exactly: "Open <url> on any device (your phone works), enter <code> and approve. You have 5 minutes. Create a free
<Cloudflare|Vercel|GitHub|Netlify> account in that same page if you don't have one." Then stop and wait for them. Poll the "done when" command
every few seconds; if the code expired (the background process ended and the tool is still logged out), start again and send the new URL and code.
Token alternative (no human step): CLOUDFLARE_API_TOKEN (+ CLOUDFLARE_ACCOUNT_ID), NETLIFY_AUTH_TOKEN, VERCEL_TOKEN, GH_TOKEN in the
environment.
4. The build hash and .well-known/cassette-build.json
Every host serves /.well-known/cassette-build.json: the list of the build's files with their sha256. Its own sha256 is the build hash you
register. The exact rules:
- Files: every regular file under the build folder, recursively, except anything whose name starts with
.(so.well-known/,.git/,.env,.cassette/and every dotfile at any depth), anything namednode_modulesorThumbs.db(any depth), symlinks, and these paths at the root:_headers,_redirects,_routes.json,_worker.js,netlify.toml,vercel.json(host config),seller.json,legal/adoption.json(private).cassette.jsonis included. - Order: by path, byte order (
LC_ALL=C sort). - JSON:
{"version":1,"files":[{"path":"<relative/path>","sha256":"<64 hex>","size":<bytes>},…]}, keys in exactly that order, no whitespace, no trailing newline. Paths use/, without./. - Build hash:
0x+ sha256 of exactly those bytes.
# recipe: build-hash (run in "$GAME"; writes .well-known/cassette-build.json and prints the build hash)
set -euo pipefail
mkdir -p .well-known
find . -mindepth 1 \( -name '.*' -o -name node_modules -o -name Thumbs.db \) -prune -o -type f -printf '%P\0' \
| grep -zvxF -e _headers -e _redirects -e _routes.json -e _worker.js -e netlify.toml -e vercel.json \
-e seller.json -e legal/adoption.json \
| LC_ALL=C sort -z \
| while IFS= read -r -d '' f; do
jq -nc --arg path "$f" --arg sha256 "$(sha256sum < "$f" | cut -d' ' -f1)" --argjson size "$(wc -c < "$f")" \
'{path: $path, sha256: $sha256, size: $size}'
done \
| jq -sjc '{version: 1, files: .}' > .well-known/cassette-build.json
echo "0x$(sha256sum < .well-known/cassette-build.json | cut -d' ' -f1)"
Expected: one line, 0x + 64 hex (e.g. 0x3936a314…218f8d). Run it again after any change to the build. cassette.json is hashed and served (the
paywall reads its storefront pointer from it): never write the play URL or mirrors into it.
Then stage what the host gets: the hashed files plus .well-known/, never anything private.
# recipe: stage (run in "$GAME"; prints the staging folder)
set -euo pipefail
STAGE="$(mktemp -d)/site"
mkdir -p "$STAGE"
{ find . -mindepth 1 \( -name '.*' -o -name node_modules -o -name Thumbs.db \) -prune -o -type f -printf '%P\0'
if [ -d .well-known ]; then find .well-known -type f ! -name '.*' -print0; fi; } \
| grep -zvxF -e seller.json -e legal/adoption.json \
| xargs -0 -r cp --parents -t "$STAGE"
echo "$STAGE"
Upload $STAGE (not $GAME) everywhere below. Its host config files (_headers, vercel.json) come from step 5.
5. Headers
Cassette frames the game from its web origins: https://cassette.gg, its subdomains, and the platform's webUrl (a different origin only in
development).
# recipe: frame-ancestors (prints the Content-Security-Policy value)
WEB=$(curl -fsS "$API/v1/config" | jq -r '.webUrl // empty')
WEB_ORIGIN=$(printf '%s' "$WEB" | sed -E 's#^(https?://[^/]+).*#\1#')
FA="frame-ancestors 'self' https://cassette.gg https://*.cassette.gg"
case "$WEB_ORIGIN" in https://cassette.gg | https://*.cassette.gg | '') ;; *) FA="$FA $WEB_ORIGIN" ;; esac
echo "$FA"
Expected: frame-ancestors 'self' https://cassette.gg https://*.cassette.gg. Send no X-Frame-Options. /.well-known/* needs
Access-Control-Allow-Origin: * and Cache-Control: no-cache; /legal/* needs Access-Control-Allow-Origin: * (the review page and the wallet
sheet fetch the documents).
Cloudflare Pages, Netlify ($STAGE/_headers; add your own rules after it if you have any):
# recipe: _headers (needs $FA and $STAGE)
cat > "$STAGE/_headers" <<EOF
/*
Content-Security-Policy: $FA
/.well-known/*
Access-Control-Allow-Origin: *
Cache-Control: no-cache
/legal/*
Access-Control-Allow-Origin: *
EOF
Vercel ($STAGE/vercel.json; merge into an existing one):
# recipe: vercel.json (needs $FA and $STAGE)
jq -n --arg fa "$FA" '{headers: [
{source: "/(.*)", headers: [{key: "Content-Security-Policy", value: $fa}]},
{source: "/.well-known/(.*)", headers: [{key: "Access-Control-Allow-Origin", value: "*"}, {key: "Cache-Control", value: "no-cache"}]},
{source: "/legal/(.*)", headers: [{key: "Access-Control-Allow-Origin", value: "*"}]}]}' > "$STAGE/vercel.json"
nginx (inside the game's server { }; nginx drops server-level add_header inside a location that has its own, so repeat the CSP):
add_header Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg" always;
location /.well-known/ {
add_header Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg" always;
add_header Access-Control-Allow-Origin "*" always;
add_header Cache-Control "no-cache" always;
}
location /legal/ {
add_header Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg" always;
add_header Access-Control-Allow-Origin "*" always;
}
Apache (.htaccess at the game root, mod_headers, AllowOverride FileInfo):
<IfModule mod_headers.c>
Header always set Content-Security-Policy "frame-ancestors 'self' https://cassette.gg https://*.cassette.gg"
Header always unset X-Frame-Options
<If "%{REQUEST_URI} =~ m#^/\.well-known/#">
Header always set Access-Control-Allow-Origin "*"
Header always set Cache-Control "no-cache"
</If>
<If "%{REQUEST_URI} =~ m#^/legal/#">
Header always set Access-Control-Allow-Origin "*"
</If>
</IfModule>
GitHub Pages: no headers possible, none needed (it sends no X-Frame-Options and CORS *); add an empty $STAGE/.nojekyll so .well-known/
is published. Arweave: none possible, none needed.
6. Deploy
Each example assumes $STAGE from step 4 with the headers file from step 5, and NAME = the game's slug (lowercase a-z 0-9 -).
Cloudflare Pages
npx -y [email protected] pages project create "$NAME" --production-branch main # first time; "already exists [code: 8000002]" is fine
npx -y [email protected] pages deploy "$STAGE" --project-name "$NAME" --branch main --commit-dirty=true
# ✨ Deployment complete! Take a peek over at https://4f2a9c1e.<subdomain>.pages.dev
Play URL: drop the first label: https://<subdomain>.pages.dev/ (<subdomain> is $NAME, or $NAME-xyz when the name was taken).
Errors: "non-interactive environment … CLOUDFLARE_API_TOKEN" / "not authenticated" = step 3; "More than one account" = export CLOUDFLARE_ACCOUNT_ID=<id>.
Netlify
SITE_ID=$(npx -y [email protected] sites:list --json | jq -r --arg n "$NAME" '.[] | select(.name == $n) | .id')
[ -n "$SITE_ID" ] || SITE_ID=$(npx -y [email protected] sites:create --name "$NAME" --disable-linking --json | jq -r .id)
# add --account-slug <team> when the account has several teams; a name taken by someone else gets a -NNN suffix (read .name)
npx -y [email protected] deploy --dir "$STAGE" --prod --no-build --site "$SITE_ID" --json
# {"site_id":"…","site_name":"blob-run","deploy_id":"…","deploy_url":"https://<id>--blob-run.netlify.app","url":"https://blob-run.netlify.app",…}
Play URL: https://<site_name>.netlify.app/.
Vercel
npx -y [email protected] deploy --cwd "$STAGE" --prod --yes --project "$NAME" --create-if-missing --json --non-interactive
# add --scope <team-slug> to deploy to a team other than the tool's current one
# {"status":"ok","deployment":{"id":"dpl_…","url":"https://blob-run-abc123-dev.vercel.app","productionUrl":"https://blob-run.vercel.app",…},…}
Play URL: productionUrl (jq -r '(.deployment // .).productionUrl'), plus /.
GitHub Pages (the user site <owner>.github.io, one game at its root)
OWNER=$(gh api user --jq .login)
REPO="$OWNER/$(printf '%s' "$OWNER" | tr A-Z a-z).github.io"
gh repo view "$REPO" --json name >/dev/null 2>&1 || gh repo create "$REPO" --public
gh api "repos/$REPO/contents/" --jq '.[].name' 2>/dev/null # empty, or this game's files only; another game or a homepage = use another host
touch "$STAGE/.nojekyll"
cd "$STAGE" && git init -q -b main && git add -A \
&& git -c user.name="$OWNER" -c user.email="[email protected]" commit -qm "Cassette: $NAME" \
&& git -c credential.helper= -c 'credential.helper=!gh auth git-credential' push -q --force "https://github.com/$REPO.git" main
gh api -X POST "repos/$REPO/pages" -f build_type=legacy -f 'source[branch]=main' -f 'source[path]=/' # "already enabled" (409) is fine
gh api "repos/$REPO/pages/builds/latest" --jq .status # repeat every 5 s until "built" (about a minute); "errored" = see the repo's Pages settings
Play URL: https://<owner lowercase>.github.io/. Each deploy replaces the repository's content with one commit.
Own server (the developer's origin, with the step-5 nginx or Apache headers installed)
rsync -a --delete "$STAGE"/ [email protected]:/var/www/blob-run/ # or: scp -r "$STAGE"/. [email protected]:/var/www/blob-run/
Arweave: not available yet (coming soon; paid by the developer, never by Cassette). Kept here for when it opens: don't host a game on Arweave today.
The developer pays: the Turbo free tier covers small items (up to 105 KiB each); otherwise the agent address's Turbo balance pays, topped up by the human by card or with POL. Cassette never pays for or sponsors storage.
The agent signs with its own EVM key (the one it registers games with, publish-game.md), stored as a JSON string in a file outside the game folder and readable only by you:
umask 077; mkdir -p ~/.config/cassette-agent
printf '"%s"\n' "$AGENT_PRIVATE_KEY" > ~/.config/cassette-agent/turbo-key.json # "0x…" with quotes: the turbo tool reads it as JSON
KEY=~/.config/cassette-agent/turbo-key.json; ADDR=<the agent's 0x address>
TURBO="npx -y -p @ardrive/[email protected] turbo"
$TURBO free-status --address "$ADDR" --token pol # free-tier bytes left for this address
$TURBO price --value "$(du -sb "$STAGE" | cut -f1)" --type bytes # Current price estimate for … bytes is ~0.0016 Credits
$TURBO balance --address "$ADDR" --token pol # Turbo Balance for Native Address "0x…" / Effective Credits: 0.25
$TURBO upload-folder --folder-path "$STAGE" --index-file index.html --token pol --wallet-file "$KEY" --skip-confirmation > /tmp/ar-upload.txt
sed -n '/^Uploaded folder:/,$p' /tmp/ar-upload.txt | sed '1s/^Uploaded folder: //' | jq -r .manifestResponse.id
# 43 characters, e.g. xDk0q5dXMPN4fs2uPmn1D1cNrvwbePdLqo7ixMyQtUg: the manifest id. Register ar://<manifestId>.
upload-folder uploads every file in the folder (dotfiles included), so upload only $STAGE. It sets each file's Content-Type and builds the
arweave/paths manifest with index.html as its index. Play URL: https://arweave.net/<manifestId>/ (it redirects to the build's own sandbox
origin https://<base32(id)>.arweave.net/<id>/).
# recipe: sandbox-label (prints the base32 label of $ID)
printf '%s=' "$ID" | tr '_-' '/+' | base64 -d | base32 | tr -d '=' | tr 'A-Z' 'a-z'
Balance too low (the upload fails with 402 / insufficient balance, or the price exceeds the balance): give the human both choices and stop:
- card ($5 minimum), a checkout crediting the agent address:
$TURBO top-up --address "$ADDR" --token pol --value 5 --currency usdprintsGot Checkout Session {"url":"https://checkout.stripe.com/…",…}(it then tries to open a browser and may exit with an error on a headless machine; the printed URL is what you send); - POL: the human sends POL on Polygon to the agent address; when they say it arrived, and only then, convert it:
$TURBO crypto-fund --token pol --value <POL> --wallet-file "$KEY" --skip-confirmation(a normal Polygon transaction from the agent's own key, paying its own gas). Credits can take a few minutes to land; checkbalance, then upload again.
What to tell the human: "Arweave storage for this build costs about $<x>, paid from the agent address <addr>'s Turbo balance. Choose one: pay by
card (minimum $5) at <checkout url>, or send <n> POL on Polygon to <addr> and tell me."
IPFS mirror (Pinata; the play URL changes every build, so never the primary host of a game that sells)
FORM=()
while IFS= read -r -d '' f; do FORM+=(-F "file=@$STAGE/$f;filename=$NAME/$f"); done \
< <(cd "$STAGE" && find . -type f -printf '%P\0')
curl -fsS -X POST https://api.pinata.cloud/pinning/pinFileToIPFS \
-H @<(printf 'Authorization: Bearer %s\n' "$PINATA_JWT") "${FORM[@]}" \
-F 'pinataOptions={"cidVersion":1,"wrapWithDirectory":false}' -F "pinataMetadata={\"name\":\"$NAME\"}"
# {"IpfsHash":"bafy…","PinSize":48211,"Timestamp":"…"}
Mirror URL: https://<IpfsHash>.ipfs.dweb.link/ (a subdomain gateway, never ipfs.io/ipfs/…).
7. Preview (optional, no login)
A temporary deploy to show the human the game in seconds. It expires in about an hour and is never registered with Cassette. Run with the
tokens unset (env -u CLOUDFLARE_API_TOKEN -u NETLIFY_AUTH_TOKEN -u VERCEL_TOKEN …); a tool that is logged in may refuse or deploy into the account.
# Cloudflare (Workers static assets): a wrangler.jsonc next to the staged folder
printf '{"name":"%s","compatibility_date":"%s","assets":{"directory":"./site"}}\n' "$NAME" "$(date -u +%F)" > "$(dirname "$STAGE")/wrangler.jsonc"
(cd "$(dirname "$STAGE")" && npx -y [email protected] deploy --temporary)
# Temporary account ready: … Claim URL: https://… ; then https://<name>.<account>.workers.dev
npx -y [email protected] deploy --temporary --yes --json --non-interactive --cwd "$STAGE"
# {"status":"ok","deployment":{"url":"https://…vercel.app","claimUrl":"https://…","expiresAt":…}}
npx -y [email protected] deploy --dir "$STAGE" --no-build --allow-anonymous --json
# {"site_url":"https://….netlify.app","claim_url":"https://app.netlify.com/drop/…","password":"My-Drop-Site",…}
Wrangler's temporary deploy creates a temporary Cloudflare account and prints a notice about Cloudflare's terms; pass that notice on to the human with the URL. Show the human the preview URL (and the Netlify password); for the real, stable URL deploy as in step 6.
8. Check with curl
BASE is the folder the build lives in: the play URL itself (https://blob-run.pages.dev/, or https://arweave.net/<id>/ on Arweave: always under
/<id>/, never the gateway root).
BASE=https://blob-run.pages.dev/
curl -fsSL -o /dev/null -w '%{http_code}\n' "$BASE" # 200
curl -fsSIL "$BASE" | grep -iE '^(x-frame-options|content-security-policy):' || true # no X-Frame-Options; a CSP, if any, has frame-ancestors with https://cassette.gg
curl -fsSL "$BASE" | sha256sum # equals: sha256sum < "$GAME/index.html"
curl -fsSIL "${BASE}.well-known/cassette-build.json" | grep -i '^access-control-allow-origin: \*'
echo "0x$(curl -fsSL "${BASE}.well-known/cassette-build.json" | sha256sum | cut -d' ' -f1)" # equals the build hash from step 4
All five as expected = done. A fresh Arweave upload can take a few minutes to reach every gateway: retry for about 2 minutes, then try
https://ar-io.dev/<id>/ and https://ardrive.net/<id>/. A host that rewrites HTML (e.g. injected analytics) breaks the entry-hash line: turn
that off.
9. What to register
Register the stable play URL (https://<project>.pages.dev/, https://<site>.netlify.app/, https://<project>.vercel.app/,
https://<owner>.github.io/, your origin), or ar://<manifestId> for Arweave once it opens, together with the build hash from step 4 (and an IPFS mirror
URL if you made one). Never a preview URL, never a per-deployment URL. How to register it with the Cassette API: page publish-game (https://cassette.gg/agents/publish-game.md).
Code in this document (and paywall.js) is MIT-licensed: SPDX-License-Identifier: MIT, Copyright (c) 2026 Delegas
Labs, S.L. Copy and adapt it freely.