Cassette

set-up-seller

Online page: https://cassette.gg/agents/set-up-seller.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 developer is the seller. seller.json holds the human's own answers, legal/*.html are documents the human has read in full and adopted as theirs, and the seller record (.well-known/cassette.json) lists every document with its sha256. The storefront commits the record's hash, and every listing commits the terms' hash. Cassette is not the seller and never writes, hosts or approves these documents.

How the human adopts them (the usual way): "Adopt and sign" on one review page. The sign link that creates the storefront (https://cassette.gg/agents/publish-game.md, step 7) carries a review (step 6 below): the page fetches every document from the developer's site, checks its hash, shows it in full next to the storefront's terms, and the one signature adopts the documents and creates the storefront. Adoption in chat (step 6, fallback) is for when there is no review page, e.g. documents changed after the storefront exists.

Everything here builds on https://cassette.gg/agents/protocol.md (config, your key, sign-in, relay, sign links, hashes) and uses its shell variables ($API, $CFG, $RPC, $CHAIN, $FACTORY, $ROUTER, $KEY, $ME).

Rules

  • Ask the human every question. Never guess the legal name, the address, the VAT number, or whether they sell as a business. Offer the template default as a suggestion ("the template uses 6 hours: keep it, or another number?") and write what they answer, even when it is the default.
  • The human sees the full text of every document before adoption: on the review page, or (fallback) in chat. Not a summary, not a diff.
  • Never adopt without the human. On the review page, they sign. In chat, their explicit yes in their own words, recorded verbatim. Never write it yourself.
  • Say once, plainly: "These are a starting point. They're yours: you can change any wording, and as a business you should check them against your own country's law."
  • Never add a promise from Cassette (that Cassette refunds, guarantees, sells or is responsible for the game).
  • The templates are legal text: fill them, never rewrite them. Only the human's own edits change the wording.

0. Files and order

All paths are in the game's build folder (the one with cassette.json).

FileWhatUploaded
seller.jsonthe human's answers, plus storefront, host, version, effectiveDatenever: it can hold a private seller's home address (https://cassette.gg/agents/host-game.md leaves it out)
.cassette/legal/<kind>.mdthe filled templates: the text the human readsnever (dot folder)
legal/<kind>.htmlthe documentsyes, at <origin>/legal/<kind>.html with Access-Control-Allow-Origin: * (https://cassette.gg/agents/host-game.md)
.well-known/cassette.jsonthe seller recordyes (CORS *, no-cache)
legal/adoption.jsonhow and when the human adopted, with each file's sha256never

Order (https://cassette.gg/agents/publish-game.md): interview (its step 3) → host (4) → reserve the storefront address (5) → documents and seller record (6, steps 2–5 here) → deploy → "Adopt and sign" (7). The documents name the storefront contract and the host, so they need both. $OWNER (the studio owner's wallet) and $SF (the storefront) come from its steps 1 and 5.

1. Interview the human

Ask in plain words, grouped into one or two messages, and wait for the answers. "Business" rows: only when the human sells as a business.

FieldAskAnswerTemplate
businessDo you sell regularly, for profit, or as part of a business? If so, you sell as a business; if not, as a private individual.true / falsenone: never guess
nameWhat name should players see as the seller (your studio or your own name)?text, ≤ 80
legalNameBusiness: the full legal name of the person or company that sells?text, ≤ 120
addressBusiness: the seller's geographic address (street and number, postcode, city, country)?text, ≤ 240
emailBusiness: which email address should the documents publish for the seller?email
vatNumberBusiness: your VAT number? Answer none if you don't have one.text ≤ 40, or ""
taxIdBusiness, optional: a national tax ID that is not a VAT number (for example a Spanish NIF)? None if not.text ≤ 40, or ""
companyRegisterBusiness: are you in a company or trade register? Which register and number? None if not.text ≤ 120, or ""
countryIn which country do you live, or is your business established?English name: Spain
contactWhere should players write about purchases and refunds? An email address or an https:// page.email or https:// URL
governingLawWhich country's law should govern your terms of sale?as printed after "the law of": Spain, the Netherlands, England and Walestheir country
refundWindowHoursHow long after buying can a player refund themselves, no questions asked? In hours: 0 = no self-refund, at most 336 (14 days).whole number 0–3366
selfRefundCapAfter how many self-refunds in 30 days (on any Cassette storefront) should a wallet lose self-refund with you? 0 = no limit.whole number 0–655353
abuseRegistryUse Cassette's abuse registry? Wallets it restricts can't self-refund with you (they can still buy, play and ask you for a refund).true / falsenone: ask (off unless they opt in)
availabilityYearsFor how many years after each purchase will you keep a paid game playable for its buyers?whole number 1–1002
licenceTransferableCan buyers transfer a game licence to another wallet (the right to play goes with it)?true / falsefalse
processorsBesides your host, does the game use any online service that sees player data (analytics, multiplayer, error reporting, ads)?["Name (Company, Country)", …] or []read the code first
localSavesDoes the game save progress in the browser (localStorage, IndexedDB, cookies)?true / falseread the code first
gameAccountsDoes the game have its own accounts, or collect email addresses?true / falseread the code first
vatEvidenceBusiness, optional: if you charge VAT, do you record each buyer's country at purchase as VAT evidence?true / falsefalse

"Read the code first": look for analytics, multiplayer, error-reporting or ad libraries, localStorage/indexedDB/cookies, sign-up or email forms; tell the human what you found and let them confirm.

If they ask why it matters:

  • business: it decides which consumer rights apply (14-day cancellation, legal guarantee) and whether they publish an imprint. legalName, address, email: a business must identify itself in its terms, imprint and cancellation form. vatNumber, taxId, companyRegister: printed on the terms and imprint where the law asks for them.
  • name heads every document and receipt. contact: buyers reach them there; Cassette only forwards messages to it.
  • country: where they sell from; it decides their taxes and is the default law of their terms. governingLaw is a legal choice; consumers keep their own country's mandatory protections either way.
  • refundWindowHours: the storefront enforces it and holds each payment until the window closes: it is their refund policy and their cash flow. selfRefundCap limits refund abuse by plain counting; how strict to be is their call.
  • abuseRegistry: opt-in; whether Cassette's reviews may switch off self-refund in their shop. Their refund policy says so.
  • availabilityYears: a promise in their terms, and they host the game. licenceTransferable: a term of their licence; the listings must match it.
  • processors, localSaves, gameAccounts, vatEvidence: the privacy notice must cover them (VAT evidence is kept 10 years).

Before the human chooses, tell them (they decide, not you):

  • Refunds are always the full price. Inside the self-refund window the payment comes back from escrow; after the payout, a refund comes from their own wallet (it needs that USDC), and a referral fee already paid on that sale isn't returned (0% for now).
  • Limited editions (a licence or item with a supply cap) never have self-refund, whatever the policy says, so a drop can't be held "sold out" by refund cycling. Buyers can still ask them for a refund.
  • Gifts (payer ≠ recipient) can't be self-refunded; only their own refund unwinds one, paid to the payer.
  • The abuse registry is Cassette-upgradeable, unlike the storefront: a bad version could stop self-refunds at an opted-in storefront, including on existing sales (opting out covers later sales only). It can't touch buying, play, transfers, their refunds or funds, and they can always allow a buyer themselves.

2. Write seller.json

{"business":true,"name":"Pixel Forge","legalName":"Pixel Forge S.L.","address":"Calle Mayor 1, 28013 Madrid, Spain",
 "email":"[email protected]","vatNumber":"ESB12345678","taxId":"","companyRegister":"","country":"Spain",
 "contact":"[email protected]","governingLaw":"Spain","refundWindowHours":6,"selfRefundCap":3,"abuseRegistry":false,
 "abuseCriteriaUrl":"https://cassette.gg/refund-abuse","availabilityYears":2,"licenceTransferable":false,"processors":[],
 "localSaves":true,"gameAccounts":false,"vatEvidence":false,
 "storefront":"0x5FbDB2315678afecb367f032d93F642f64180aa3","host":"Cloudflare Pages (Cloudflare, Inc., USA)",
 "version":"2026-10-30.1","effectiveDate":"2026-10-30"}
  • Every field is present. A private seller: "business":false and "" for legalName, address, email, vatNumber, taxId, companyRegister.
  • country: the English name (ES → Spain). governingLaw: as it reads after "the law of" (NL → the Netherlands, US → the United States); their country unless they chose another.
  • storefront: the address reserved in https://cassette.gg/agents/publish-game.md, step 5 (or their existing storefront).
  • host: who serves the game and sees players' IP addresses: Cloudflare Pages (Cloudflare, Inc., USA), GitHub Pages (GitHub, Inc., USA), Netlify (Netlify, Inc., USA), Vercel (Vercel Inc., USA); for their own server or Arweave, ask the human for the provider or gateway operator, company and country.
  • abuseCriteriaUrl: always https://cassette.gg/refund-abuse.
  • version: the date of the change plus a counter (2026-10-30.1, .2 for a second change that day); effectiveDate: its date. Unchanged text keeps its adopted version.
  • No <, >, {{ or }} in any answer: ask again or write it without them.

Check it (prints true; false = fix the answer that breaks a rule above):

jq -e '(.business|type=="boolean") and (.name|length>0 and length<=80)
  and (if .business then ([.legalName,.address,.email]|all(length>0)) else true end)
  and (.email=="" or (.email|test("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$")))
  and (.contact|test("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$") or test("^https://[^\\s/]+\\.\\S+$";"i"))
  and (.refundWindowHours|.==floor and .>=0 and .<=336) and (.selfRefundCap|.==floor and .>=0 and .<=65535)
  and (.availabilityYears|.==floor and .>=1 and .<=100) and (.storefront|test("^0x[0-9a-fA-F]{40}$"))
  and (.processors|length<=20) and ([..|strings|test("[<>]|[{][{]|[}][}]")]|any|not)' seller.json

3. Fill the templates

The templates are Cassette's legal workstream's text, served at https://cassette.gg/agents/files/templates/<kind>.md (field list and the points above: templates/README.md there).

KindBecomesWho publishes it
terms-of-salelegal/terms-of-sale.html (its sha256 is every listing's termsHash)every seller
refundslegal/refunds.htmlevery seller
privacylegal/privacy.htmlevery seller
imprintlegal/imprint.htmlbusiness sellers
withdrawal-formlegal/withdrawal-form.htmlbusiness sellers

Fill each one with the values in seller.json (Mustache syntax). Do it yourself, or with any Mustache 4 implementation with HTML escaping off; both give the same text.

TagBecomes
{{field}}, {{{field}}}the value as plain text
{{#field}}…{{/field}}the inside when the value is true, a non-zero number, a non-empty string or list; for a list, the inside once per element, with {{.}} = the element
{{^field}}…{{/field}}the inside when the value is false, 0, "" or []
a section tag alone on its linethe line disappears (no blank line left behind)
mkdir -p .cassette/templates .cassette/legal legal .well-known
KINDS="terms-of-sale refunds privacy"; [ "$(jq -r .business seller.json)" = true ] && KINDS="$KINDS imprint withdrawal-form"
for k in $KINDS; do curl -sf https://cassette.gg/agents/files/templates/$k.md -o .cassette/templates/$k.md; done
# … fill each into .cassette/legal/<kind>.md …
grep -n '{{\|}}' .cassette/legal/*.md || echo "all filled"

Expected for the example above, the top of .cassette/legal/terms-of-sale.md (a private seller gets ", a private individual" instead of the brackets, and its refunds section is ## 3. Refunds, not ## 5. Refunds):

# Terms of sale

Seller: **Pixel Forge** (Pixel Forge S.L., Calle Mayor 1, 28013 Madrid, Spain, VAT ESB12345678)
Contact: [email protected] · Version 2026-10-30.1, effective 2026-10-30

4. Make the pages and hash them

Each .cassette/legal/<kind>.md becomes legal/<kind>.html: one self-contained page with <!doctype html>, <meta charset="utf-8">, the viewport meta, <title> = the # heading, ·, the seller's name, and the Markdown as HTML (headings, paragraphs, lists, tables, blockquotes, links; a single newline inside a paragraph is a space). No scripts, no external files; links only to https:, mailto: and the relative pages the templates use (refunds.html, …). Any Markdown converter does it, or write the HTML yourself:

NAME=$(jq -r .name seller.json)
for k in $KINDS; do
  TITLE=$(jq -rn --arg h "$(sed -n 's/^# //p' .cassette/legal/$k.md | head -1)" --arg n "$NAME" '"\($h) · \($n)" | @html')
  { printf '<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>%s</title>\n</head>\n<body>\n<main>\n' "$TITLE"
    npx -y [email protected] -i .cassette/legal/$k.md
    printf '</main>\n</body>\n</html>\n'; } > legal/$k.html
done
sha256sum legal/*.html
# 3f6c…e1a0  legal/privacy.html
# 9b21…77c4  legal/refunds.html
# d04e…5b19  legal/terms-of-sale.html   (+ imprint, withdrawal-form for a business)

The hash is of the exact bytes served. After the human adopts, never regenerate or reformat these files: any change (a formatter, a rebuild, a host that rewrites HTML) is a new document that needs a new adoption. When the human wants other wording, edit legal/<kind>.html and .cassette/legal/<kind>.md exactly as they ask (it's their text), show them the result, and hash again.

5. The seller record

ORIGIN=https://blob-run.pages.dev        # the developer's origin (host-game.md), no trailing slash
LEGAL=$(for e in terms:terms-of-sale refunds:refunds privacy:privacy imprint:imprint withdrawalForm:withdrawal-form; do
  f=legal/${e#*:}.html; [ -f "$f" ] || continue
  jq -n --arg k "${e%%:*}" --arg url "$ORIGIN/$f" --arg sha "$(sha256sum "$f" | cut -c1-64)" '{($k):{url:$url,sha256:$sha}}'
done | jq -s add)
jq --argjson legal "$LEGAL" --argjson chain $CHAIN --arg slug blob-run --arg at "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" '
  {version:1,
   seller:({name,business,country,contact}
     + ({legalName,address,email,vatNumber,taxId,companyRegister}|with_entries(select(.value!="")))),
   storefronts:[{chainId:$chain,address:.storefront}],
   legal:$legal, games:[$slug], issuedAt:$at}' seller.json > .well-known/cassette.json
RECORD_URI=$ORIGIN/.well-known/cassette.json
RECORD_HASH=0x$(jq -cSj 'del(.claims, .signature)' .well-known/cassette.json | sha256sum | cut -c1-64)
jq -c '{legal: (.legal|keys), hash: $h}' --arg h $RECORD_HASH .well-known/cassette.json
# {"legal":["imprint","privacy","refunds","terms","withdrawalForm"],"hash":"0x7d1e…"}
  • Only these fields (protocol.md §8): the API hashes only them, so anything else breaks the match. claims (community claim tokens, https://cassette.gg/agents/claim-listing.md) and signature may sit next to them, outside the hash.
  • storefronts: the current one first, then the human's earlier ones, newest first (cast call $FACTORY "storefrontsOf(address)(address[])" $OWNER --rpc-url $RPC, reversed).
  • games: every game the record covers; keep earlier slugs when adding one.
  • legal lists whatever is in legal/: a seller who stops selling as a business deletes legal/imprint.html and legal/withdrawal-form.html first.
  • issuedAt: when nothing else changed, keep the previous value so the hash stays the same.
  • Copy legal into cassette.json as well: jq --argjson legal "$LEGAL" '.legal = $legal' cassette.json.
  • Arweave (coming soon; https://cassette.gg/agents/host-game.md): each document and the record are their own permanent items. Upload the documents first (Content-Type text/html), use ar://<id> as each url, then upload the record (application/json): RECORD_URI=ar://<its id>.

RECORD_URI and RECORD_HASH go into factory.create (https://cassette.gg/agents/publish-game.md, step 7) or setSellerRecord(uri, hash). The listings' terms are termsURI = .legal.terms.url and termsHash = 0x + .legal.terms.sha256 (https://cassette.gg/agents/sell-items.md).

6. Adoption

On the review page ("Adopt and sign", the usual way)

Deploy first (https://cassette.gg/agents/host-game.md): the page fetches each document from its URL and checks its sha256, and refuses to sign if one is missing, different, or served without Access-Control-Allow-Origin: *. Check what's served:

for f in legal/*.html; do
  [ "$(curl -sf "$ORIGIN/$f" | sha256sum | cut -c1-64)" = "$(sha256sum "$f" | cut -c1-64)" ] && echo "ok $f" || echo "NOT SERVED $f"
done

Then add the review to the link that creates the storefront (protocol.md §6; the calls are https://cassette.gg/agents/publish-game.md, step 7):

REVIEW=$(jq -n --slurpfile rec .well-known/cassette.json --slurpfile s seller.json \
  --arg game blob-run --arg title "Blob Run" --arg price 4.99 --argjson items 0 --arg op $ME '
  {documents: ($rec[0].legal | to_entries | map({kind: .key, url: .value.url, sha256: .value.sha256,
     title: {terms:"Terms of sale", refunds:"Refund policy", privacy:"Privacy notice", imprint:"Imprint",
             withdrawalForm:"Cancellation form"}[.key]})),
   summary: {game: $game, title: $title, priceUsd: (if $price == "" then null else $price end), items: $items,
     refundWindowSec: ($s[0].refundWindowHours * 3600), maxSelfRefunds30d: $s[0].selfRefundCap,
     abuseRegistry: $s[0].abuseRegistry, referralBps: 0, operator: $op}}')
jq -c '{docs: (.documents|length), summary}' <<<"$REVIEW"
# {"docs":5,"summary":{"game":"blob-run","title":"Blob Run","priceUsd":"4.99","items":0,"refundWindowSec":21600,…}}

The page reads the storefront's terms from the calls and won't sign when your summary disagrees with them:

  • priceUsd: the licence price listed in this batch, "" (→ null) when the batch lists no licence;
  • items: the number of item listings created in this batch (not the manifest's count, if some come later);
  • the window, cap and registry: those in create's refund policy; referralBps: 0; operator: your key.
  • Documents use https:// URLs (for ar:// items, https://arweave.net/<id>).

Give the human the link ("Read your documents and the storefront terms, then press Adopt and sign"). Signing adopts the documents. Once the storefront exists, confirm it committed this record and write the adoption down:

cast call $SF "sellerRecord()(string,bytes32)" --rpc-url $RPC    # "$RECORD_URI" and $RECORD_HASH
jq -n --slurpfile s seller.json --slurpfile rec .well-known/cassette.json --arg at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
  --arg statement "Adopted by $OWNER with \"Adopt and sign\": the transaction that created storefront $SF committed these documents' hashes." '
  {version:1, adoptedAt:$at, statement:$statement, docsVersion:$s[0].version, effectiveDate:$s[0].effectiveDate,
   refund:($s[0]|{refundWindowHours,selfRefundCap,abuseRegistry}),
   docs:[$rec[0].legal|to_entries[]|{kind:.key, url:.value.url, sha256:.value.sha256}]}' > legal/adoption.json

In chat (fallback, and after the storefront exists)

  1. Send the human every .cassette/legal/<kind>.md in full, one after another. Ask, for example: "These are your documents: you're the seller, and they're yours. Read them and change anything you like. Do you adopt them as your own?"
  2. Different answers (refund window, transferability, …): update seller.json, fill and hash again (steps 3–5), show the new text. Different wording: edit the files exactly as they ask (step 4).
  3. Adoption needs an explicit yes in their own words. Silence, your own "looks fine", or approval of a summary is not adoption. Write legal/adoption.json as above with "statement" = their reply, verbatim.
  4. Publish the hashes (step 8).

7. Refund policy: defaults and limits

What the human chose goes into the storefront's refund policy (create, or later setRefundPolicy, owner only: https://cassette.gg/agents/handle-refunds.md, section 6). The documents must say what the storefront does.

SettingTemplateAllowedOn chain
Self-refund window6 hours0–336 hours (14 days); 0 = no self-refund, paid out at oncewindow = hours × 3600 (seconds)
Self-refunds per wallet3 in 30 days, across all Cassette storefronts0–65535; 0 = no capmaxSelfRefunds30d
Cassette's abuse registryoff: opt-in, askon or offregistry = deployment.abuseRegistry or 0x0

Out of range reverts BadRefundPolicy. A policy applies to future sales only; each sale keeps the one it was sold under. Limited editions and gifts are never self-refundable, whatever the policy.

8. After any change

A change to seller.json or any document is a new version (2026-10-30.2) with new hashes:

  1. Fill, convert and hash again (steps 3–5); the human reads the new text and adopts it (step 6).
  2. Deploy (https://cassette.gg/agents/host-game.md) so the new files are served.
  3. On chain: setSellerRecord(string,bytes32) with the new record and, if the terms changed, updateListing with the new termsURI/termsHash on every listing (https://cassette.gg/agents/sell-items.md). Both are operator calls: your key relays them, or the human signs them in a set-seller-record sign link (at most 8 calls).
  4. A new refund window, cap or registry choice also needs setRefundPolicy from the human (https://cassette.gg/agents/handle-refunds.md, section 6).

Earlier sales keep the version they were sold under. Check at any time that the chain, the served record and your files agree:

cast call $SF "sellerRecord()(string,bytes32)" --rpc-url $RPC
curl -sf $RECORD_URI | jq -cSj 'del(.claims, .signature)' | sha256sum   # the same hash, as served

Limits on sponsored transactions

Cassette pays the network fee for storefront calls within published caps (GET https://cassette.gg/v1/wallet/rules).

  • A sign link carries at most 8 calls and at most one storefront creation; more changes = more links, given in order.
  • Your relayed calls go one per request: 20 per minute and 500 per day per key.
  • Tell the human before large work (more than 8 changes): how many signatures, or how many of the day's relays.
  • On a refusal (429, relay_budget) never retry in a loop: wait once for the window the message names, or let the human send it from their own wallet and pay the network fee (protocol.md §5).

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.