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).
| File | What | Uploaded |
|---|---|---|
seller.json | the human's answers, plus storefront, host, version, effectiveDate | never: it can hold a private seller's home address (https://cassette.gg/agents/host-game.md leaves it out) |
.cassette/legal/<kind>.md | the filled templates: the text the human reads | never (dot folder) |
legal/<kind>.html | the documents | yes, at <origin>/legal/<kind>.html with Access-Control-Allow-Origin: * (https://cassette.gg/agents/host-game.md) |
.well-known/cassette.json | the seller record | yes (CORS *, no-cache) |
legal/adoption.json | how and when the human adopted, with each file's sha256 | never |
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.
| Field | Ask | Answer | Template |
|---|---|---|---|
business | Do 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 / false | none: never guess |
name | What name should players see as the seller (your studio or your own name)? | text, ≤ 80 | |
legalName | Business: the full legal name of the person or company that sells? | text, ≤ 120 | |
address | Business: the seller's geographic address (street and number, postcode, city, country)? | text, ≤ 240 | |
email | Business: which email address should the documents publish for the seller? | ||
vatNumber | Business: your VAT number? Answer none if you don't have one. | text ≤ 40, or "" | |
taxId | Business, optional: a national tax ID that is not a VAT number (for example a Spanish NIF)? None if not. | text ≤ 40, or "" | |
companyRegister | Business: are you in a company or trade register? Which register and number? None if not. | text ≤ 120, or "" | |
country | In which country do you live, or is your business established? | English name: Spain | |
contact | Where should players write about purchases and refunds? An email address or an https:// page. | email or https:// URL | |
governingLaw | Which country's law should govern your terms of sale? | as printed after "the law of": Spain, the Netherlands, England and Wales | their country |
refundWindowHours | How 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–336 | 6 |
selfRefundCap | After 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–65535 | 3 |
abuseRegistry | Use 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 / false | none: ask (off unless they opt in) |
availabilityYears | For how many years after each purchase will you keep a paid game playable for its buyers? | whole number 1–100 | 2 |
licenceTransferable | Can buyers transfer a game licence to another wallet (the right to play goes with it)? | true / false | false |
processors | Besides 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 |
localSaves | Does the game save progress in the browser (localStorage, IndexedDB, cookies)? | true / false | read the code first |
gameAccounts | Does the game have its own accounts, or collect email addresses? | true / false | read the code first |
vatEvidence | Business, optional: if you charge VAT, do you record each buyer's country at purchase as VAT evidence? | true / false | false |
"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.nameheads 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.governingLawis 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.selfRefundCaplimits 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":falseand""forlegalName,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 inhttps://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: alwayshttps://cassette.gg/refund-abuse.version: the date of the change plus a counter (2026-10-30.1,.2for 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).
| Kind | Becomes | Who publishes it |
|---|---|---|
terms-of-sale | legal/terms-of-sale.html (its sha256 is every listing's termsHash) | every seller |
refunds | legal/refunds.html | every seller |
privacy | legal/privacy.html | every seller |
imprint | legal/imprint.html | business sellers |
withdrawal-form | legal/withdrawal-form.html | business 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.
| Tag | Becomes |
|---|---|
{{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 line | the 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) andsignaturemay 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.legallists whatever is inlegal/: a seller who stops selling as a business deleteslegal/imprint.htmlandlegal/withdrawal-form.htmlfirst.issuedAt: when nothing else changed, keep the previous value so the hash stays the same.- Copy
legalintocassette.jsonas 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-Typetext/html), usear://<id>as eachurl, 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 (forar://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)
- Send the human every
.cassette/legal/<kind>.mdin 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?" - 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). - 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.jsonas above with"statement"= their reply, verbatim. - 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.
| Setting | Template | Allowed | On chain |
|---|---|---|---|
| Self-refund window | 6 hours | 0–336 hours (14 days); 0 = no self-refund, paid out at once | window = hours × 3600 (seconds) |
| Self-refunds per wallet | 3 in 30 days, across all Cassette storefronts | 0–65535; 0 = no cap | maxSelfRefunds30d |
| Cassette's abuse registry | off: opt-in, ask | on or off | registry = 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:
- Fill, convert and hash again (steps 3–5); the human reads the new text and adopts it (step 6).
- Deploy (
https://cassette.gg/agents/host-game.md) so the new files are served. - On chain:
setSellerRecord(string,bytes32)with the new record and, if the terms changed,updateListingwith the newtermsURI/termsHashon every listing (https://cassette.gg/agents/sell-items.md). Both are operator calls: your key relays them, or the human signs them in aset-seller-recordsign link (at most 8 calls). - A new refund window, cap or registry choice also needs
setRefundPolicyfrom 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.