Cassette

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):

ContractFunction
ProfilesstudioOf(address owner) returns (uint256)
GameRegistryidOfSlug(string slug) returns (uint256)
GameRegistrylistGame((uint8 status, uint8 pricing, uint256 price, bool licenseTransferable, uint8 hosting, bytes32 buildHash, uint256 capabilities, string url, string metadataURI, string slug) input, uint256 profileId)
GameRegistrypublishBuild(uint256 gameId, string metadataURI, uint8 hosting, string url, bytes32 buildHash, uint256 capabilities)
GameRegistrychangeStatus(uint8 status, uint256 gameId)
Forwardernonces(address owner) returns (uint256)
StorefrontFactorypredict(address owner, bytes32 salt) returns (address), isStorefront(address) returns (bool), storefrontsOf(address owner) returns (address[]), referralPartner() returns (address)
StorefrontFactorycreate((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)
StorefrontcreateListing((uint8 kind, bytes32 game, bytes32 sku, uint256 price, uint64 supply, bool transferable, bool consumable, bool active, string metadataURI, string termsURI, bytes32 termsHash) input)
StorefrontupdateListing(uint256 listingId, uint256 price, bool active, string metadataURI, string termsURI, bytes32 termsHash)
StorefrontsetSellerRecord(string uri, bytes32 hash), setOperator(address operator, bool allowed), setRefundPolicy((uint32,uint16,address)), refund(uint256 saleId), release(uint256[] saleIds)
StorefrontsetRefundOverride(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)
StorefrontRouterrouteBatch((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).

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.

TargetEIP-712 domainnonce fromforwarder 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. value is always "0". deadline within the hour.
  • Storefront calls: the same with the router domain, nonce from cast call $ROUTER "nonces(address)(uint256)" $ME | cut -d" " -f1, to = the storefront, and "forwarder":"storefront-router" next to request.
  • 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.

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

WhatHow
A document or filesha256sum 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 recordjq -cSj 'del(.claims, .signature)' .well-known/cassette.json \| sha256sum (keys sorted, no whitespace)
Game keycast keccak blob-run
Licence skucast keccak blob-run:licence
Item skucast 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 with POST /v1/metadata (bearer, the JSON as the body) → {"uri":"sha256:…","url":"…"}; uri is the metadataURI.
  • Seller record (/.well-known/cassette.json on the game's host, or its own ar:// 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) and signature sit outside the hash. A business adds imprint and withdrawalForm to legal.

  • 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.