Cassette for agents: the protocol
Cassette is an HTTP API plus contracts on Polygon, with nothing to install: you call the API with
curl (or any HTTP client), sign with any Ethereum library, and hash with sha256sum. The examples use curl, jq,
sha256sum and Foundry's cast (curl -L https://foundry.paradigm.xyz | bash && foundryup); viem, ethers or
web3.py work the same way.
API=https://cassette.gg # the API base; use this exact string everywhere
WEB=https://cassette.gg
CFG=$(curl -s $API/v1/config)
RPC=$(jq -r .rpcUrl <<<"$CFG"); CHAIN=$(jq -r .chainId <<<"$CFG")
Errors are {"error":{"code","message","fix"}} with an HTTP status (codes: https://cassette.gg/agents/errors.md). Reads
need no key. Amounts are decimal strings of USDC units (6 decimals).
1. Config and contracts
GET /v1/config →
{chainId, rpcUrl, deployment: {forwarder, profiles, gameRegistry, storefrontFactory, storefrontRouter, refundLedger,
abuseRegistry, storefrontLens, …}, paymentToken: {address, symbol, decimals, permit: {name, version}}, webUrl, api}.
Read addresses from it every time; never hard-code them.
REG=$(jq -r .deployment.gameRegistry <<<"$CFG"); PROFILES=$(jq -r .deployment.profiles <<<"$CFG")
FWD=$(jq -r .deployment.forwarder <<<"$CFG"); FACTORY=$(jq -r .deployment.storefrontFactory <<<"$CFG")
ROUTER=$(jq -r .deployment.storefrontRouter <<<"$CFG"); USDC=$(jq -r .paymentToken.address <<<"$CFG")
The functions agents use (Solidity signatures, ready for cast calldata / cast call):
| Contract | Function |
|---|---|
| Profiles | studioOf(address owner) returns (uint256) |
| GameRegistry | idOfSlug(string slug) returns (uint256) |
| GameRegistry | listGame((uint8 status, uint8 pricing, uint256 price, bool licenseTransferable, uint8 hosting, bytes32 buildHash, uint256 capabilities, string url, string metadataURI, string slug) input, uint256 profileId) |
| GameRegistry | publishBuild(uint256 gameId, string metadataURI, uint8 hosting, string url, bytes32 buildHash, uint256 capabilities) |
| GameRegistry | changeStatus(uint8 status, uint256 gameId) |
| Forwarder | nonces(address owner) returns (uint256) |
| StorefrontFactory | predict(address owner, bytes32 salt) returns (address), isStorefront(address) returns (bool), storefrontsOf(address owner) returns (address[]), referralPartner() returns (address) |
| StorefrontFactory | create((address payout, string name, string symbol, (uint32 window, uint16 maxSelfRefunds30d, address registry) refundPolicy, (address partner, uint16 bps) referral, string sellerRecordURI, bytes32 sellerRecordHash, address[] operators) params, bytes32 salt) |
| Storefront | createListing((uint8 kind, bytes32 game, bytes32 sku, uint256 price, uint64 supply, bool transferable, bool consumable, bool active, string metadataURI, string termsURI, bytes32 termsHash) input) |
| Storefront | updateListing(uint256 listingId, uint256 price, bool active, string metadataURI, string termsURI, bytes32 termsHash) |
| Storefront | setSellerRecord(string uri, bytes32 hash), setOperator(address operator, bool allowed), setRefundPolicy((uint32,uint16,address)), refund(uint256 saleId), release(uint256[] saleIds) |
| Storefront | setRefundOverride(address buyer, uint8 value) (0 default, 1 allow, 2 deny) |
| Storefront (reads) | listingIdOf(bytes32 sku) returns (uint256), listingsOfGame(bytes32 game) returns (uint256[]), listing(uint256 listingId) returns ((uint8 kind, bytes32 game, bytes32 sku, uint256 price, uint64 supply, uint64 sold, bool transferable, bool consumable, bool active, string metadataURI, string termsURI, bytes32 termsHash)), owner() returns (address), sellerRecord() returns (string uri, bytes32 hash), quote(uint256 listingId, uint64 qty, address referrer) returns (uint256 total, uint256 fee), sale(uint256 saleId), releasable(uint256 saleId) returns (bool), hasLicense(address account, bytes32 game) returns (bool) |
| Storefront (players) | buy(uint256 listingId, uint64 qty, address recipient, uint256 maxTotal, address referrer, bytes32 ref, bytes32 expect), buyWithPermit(…, bytes32 expect, uint256 deadline, bytes signature), selfRefund(uint256 saleId), termsCommitment(uint256 listingId, address payer) returns (bytes32), balanceOf(address account, uint256 id) returns (uint256) |
| StorefrontRouter | routeBatch((address target, bytes data)[] calls), nonces(address) returns (uint256) |
Enums: status draft 0, published 1, unlisted 2; pricing free 0, free-with-items 1, paid 2; hosting external 0,
ipfs 2; listing kind license 0, item 1. capabilities is a bit mask: phone 0, tablet 1, desktop 2, touch 3, mouse 4,
keyboard 5, gamepad 6, multiplayer-local 7, multiplayer-online 8, offline 9, audio 10 (phone,desktop,touch = 1+4+8
= 13). The full storefront interface, events and errors: https://cassette.gg/agents/paywall.md.
cast call prints numbers of 10000 and above as 10000 [1e4]: keep the first word (| cut -d" " -f1) before
reusing one. Example: is a slug free?
cast call $REG "idOfSlug(string)(uint256)" blob-run --rpc-url $RPC # 0 = free
2. Your key
Make one EOA for this developer and keep it outside the game folder. It signs your API logins and your gas-free transactions; it never holds the human's money and never owns their studio or storefront (it's an operator).
mkdir -p ~/.cassette && cast wallet new --json | jq '.data[0] // .[0]' > ~/.cassette/agent.json && chmod 600 ~/.cassette/agent.json
KEY=$(jq -r .private_key ~/.cassette/agent.json); ME=$(cast wallet address --private-key $KEY)
Never print the key, put it in a URL, or send it anywhere (Cassette included).
3. Pairing: the human links you to their studio
You start it; the human needs no id from the website. Request a code for your key:
curl -s -X POST $API/v1/agent-links -H 'content-type: application/json' \
-d "{\"operator\":\"$ME\",\"agentName\":\"Claude\"}"
# {"code":"K7QX-M2PD","approveUrl":"https://cassette.gg/link/K7QX-M2PD","expiresAt":1793212345,"expiresIn":600}
Tell the human: "Open <approveUrl>, sign in, and approve the code K7QX-M2PD (or type the code at https://cassette.gg/publish)." The code is on the page so they can check it's yours. Then wait:
until s=$(curl -s $API/v1/agent-links/$CODE | jq -r .status); [ "$s" != pending ]; do sleep 3; done
curl -s $API/v1/agent-links/$CODE # {"status":"approved","profileId":"12","operator":"0x…",…}
Before the first publish tell the human: "By publishing you agree to the Developer Distribution Agreement: https://cassette.gg/developers/terms". Don't publish if they object.
Approved: your key is now an operator of studio profileId (a first-time human creates the studio on that page).
A code lasts 10 minutes and works once. Expired (410, or still pending after expiresAt): request a new one and
show it again. One key gets at most 10 codes an hour.
4. Sign in (SIWE, EIP-4361)
NONCE=$(curl -s "$API/v1/auth/nonce?address=$ME" | jq -r .nonce)
HOST=$(sed -E 's#^https?://([^/]+).*#\1#' <<<"$API"); ORIGIN=$(sed -E 's#^(https?://[^/]+).*#\1#' <<<"$API")
MSG="$HOST wants you to sign in with your Ethereum account:
$ME
Sign in to Cassette
URI: $ORIGIN
Version: 1
Chain ID: $CHAIN
Nonce: $NONCE
Issued At: $(date -u +%Y-%m-%dT%H:%M:%S.000Z)"
SIG=$(cast wallet sign --private-key $KEY "$MSG")
TOKEN=$(jq -n --arg m "$MSG" --arg s "$SIG" '{message:$m,signature:$s}' \
| curl -s -X POST $API/v1/auth/verify -H 'content-type: application/json' -d @- | jq -r .token)
Send Authorization: Bearer $TOKEN on writes (valid 7 days). $ME must be the checksummed address (what
cast wallet address prints); the domain is the API's host.
5. Gas-free transactions (the relay)
Your key never needs POL. You sign an ERC-2771 ForwardRequest and Cassette's relayer sends it.
| Target | EIP-712 domain | nonce from | forwarder in the body |
|---|---|---|---|
| Profiles, GameRegistry | {name:"CassetteForwarder", version:"1", chainId, verifyingContract: deployment.forwarder} | forwarder.nonces(you) | omit |
| a storefront or the factory (operator calls) | {name:"CassetteStorefrontRouter", version:"1", chainId, verifyingContract: deployment.storefrontRouter} | router.nonces(you) | "storefront-router" |
Worked example: register a new game (listGame) through the platform forwarder.
DATA=$(cast calldata "listGame((uint8,uint8,uint256,bool,uint8,bytes32,uint256,string,string,string),uint256)" \
"(1,0,0,false,0,$BUILD_HASH,13,$PLAY_URL,$MANIFEST_URI,blob-run)" $PROFILE_ID)
NONCE=$(cast call $FWD "nonces(address)(uint256)" $ME --rpc-url $RPC | cut -d" " -f1)
DEADLINE=$(( $(date +%s) + 900 ))
jq -n --arg from $ME --arg to $REG --arg data $DATA --arg nonce $NONCE --argjson deadline $DEADLINE \
--argjson chain $CHAIN --arg fwd $FWD '{
types: {
EIP712Domain: [{name:"name",type:"string"},{name:"version",type:"string"},{name:"chainId",type:"uint256"},{name:"verifyingContract",type:"address"}],
ForwardRequest: [{name:"from",type:"address"},{name:"to",type:"address"},{name:"value",type:"uint256"},{name:"gas",type:"uint256"},{name:"nonce",type:"uint256"},{name:"deadline",type:"uint48"},{name:"data",type:"bytes"}]
},
primaryType: "ForwardRequest",
domain: {name:"CassetteForwarder", version:"1", chainId:$chain, verifyingContract:$fwd},
message: {from:$from, to:$to, value:"0", gas:"1000000", nonce:$nonce, deadline:$deadline, data:$data}
}' > /tmp/req.json
SIG=$(cast wallet sign --data --from-file /tmp/req.json --private-key $KEY)
TX=$(jq '{request: ((.message | {from,to,value,gas,deadline,data}) + {signature: $sig})}' --arg sig $SIG /tmp/req.json \
| curl -s -X POST $API/v1/relay -H 'content-type: application/json' -d @- | jq -r .txHash)
until s=$(curl -s $API/v1/relay/$TX | jq -r .status); [ "$s" != pending ]; do sleep 2; done; echo $s # mined
gas: 1000000 suits every call here; at most 1500000.valueis always"0".deadlinewithin the hour.- Storefront calls: the same with the router domain,
noncefromcast call $ROUTER "nonces(address)(uint256)" $ME | cut -d" " -f1,to= the storefront, and"forwarder":"storefront-router"next torequest. - A call that would revert is refused with the decoded reason in
error.message(e.g.SlugInUse,Forbidden: this key isn't an operator of that studio).
Limits: per key 20 relays per minute and 500 per day; at most 8 calls in one routed batch, one storefront creation
per batch (GET /v1/wallet/rules has the current numbers). On a 429 read error.message: "retry in Ns" means wait
that long once; "for today" or relay_budget means stop and tell the human (they can send the transaction from their
own wallet and pay the network fee). Never retry in a loop. Before bulk work (dozens of listings), read the rules and
tell the human how long it will take.
6. Sign links: what only the human may sign
Creating the storefront, changing its refund policy, refunding a sale and adding an operator need the storefront
owner's wallet. Prepare the calls and give the human a link; the page shows each call in plain words and sends them
as one router.routeBatch from their wallet. Cassette stores nothing: the link carries it all.
{"v":1,"chainId":137,"purpose":"create-storefront","agent":"0xYou","owner":"0xHuman",
"calls":[{"to":"0xFactory","data":"0x…"},{"to":"0xStorefront","data":"0x…"}],
"summary":["Create your storefront …","List Blob Run for 4.99 USDC"],
"review":{"documents":[{"kind":"terms","title":"Terms of sale","url":"https://…/legal/terms-of-sale.html","sha256":"…"}],
"summary":{"game":"blob-run","title":"Blob Run","priceUsd":"4.99","items":0,"refundWindowSec":21600,
"maxSelfRefunds30d":3,"abuseRegistry":false,"referralBps":0,"operator":"0xYou"}}}
purpose: create-storefront, sync-listings, set-refund-policy, set-refund-override, set-seller-record,
set-operator or refund. calls go to the factory or one of its storefronts (≤ 8). approvals
([{token, spender, amount}]) only for an owner refund after release. review makes it the "Adopt and sign" page:
the page fetches every document and checks its sha256, and signing adopts them.
URL="$WEB/studio/sign#$(jq -cj . req.json | base64 -w0 | tr '+/' '-_' | tr -d '=')"
Send the link and wait for the effect on chain, e.g. cast call $FACTORY "isStorefront(address)(bool)" $SF.
7. Hashes
| What | How |
|---|---|
| A document or file | sha256sum legal/terms-of-sale.html (64 hex; on chain as 0x<hex>) |
The build (buildHash) | the recipe in https://cassette.gg/agents/host-game.md (.well-known/cassette-build.json) |
| The seller record | jq -cSj 'del(.claims, .signature)' .well-known/cassette.json \| sha256sum (keys sorted, no whitespace) |
| Game key | cast keccak blob-run |
| Licence sku | cast keccak blob-run:licence |
| Item sku | cast format-bytes32-string neon-trail |
Purchase commitment (expect) | cast keccak $(cast abi-encode "f(bytes32,bytes32,uint32,uint16,address,uint8)" $TERMS_HASH $RECORD_HASH $WINDOW $CAP $REGISTRY $OVERRIDE), equal to termsCommitment(listingId, payer) |
8. Formats
cassette.json(the game manifest, in the build folder):https://cassette.gg/agents/manifest.md. Upload it withPOST /v1/metadata(bearer, the JSON as the body) →{"uri":"sha256:…","url":"…"};uriis themetadataURI.Seller record (
/.well-known/cassette.jsonon the game's host, or its ownar://item):{"version":1, "seller":{"name":"Pixel Forge","business":true,"country":"ES","contact":"[email protected]", "legalName":"Pixel Forge S.L.","address":"…","email":"[email protected]","vatNumber":"ESB12345678"}, "storefronts":[{"chainId":137,"address":"0xStorefront"}], "legal":{"terms":{"url":"https://…/legal/terms-of-sale.html","sha256":"…"}, "refunds":{"url":"…","sha256":"…"},"privacy":{"url":"…","sha256":"…"}}, "games":["blob-run"],"issuedAt":"2026-10-30T16:00:00.000Z"}Only these fields. The storefront commits its hash (
sellerRecordHash);claims(community claims) andsignaturesit outside the hash. A business addsimprintandwithdrawalFormtolegal.- Build manifest
/.well-known/cassette-build.json:https://cassette.gg/agents/host-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.