Developers
For platforms
本页目录
- The two modes
- Which way to hand passports to people
- Joining the list
- Before your first picture
- The secrets you keep
- Quickstart
- Co-sign mode, the main path
- One-time setup (platform mode)
- Platform mode
- Handing a passport to a person
- Leaving a passport for a person's email
- Errors
- Limits
- What it costs on Monad mainnet
- How Moolam credits a platform picture
- What the label on a passport means
- What the kit does not promise
The platform kit lets an AI image company give every picture made on it a Moolam passport on Monad
that credits the person who imagined it. The company's ERC-8004 agent signs for the tool that drew
the picture, and the person signs as its creator. A platform is where a picture was drawn, never its
maker. It is a TypeScript package for the company's server, @moolam/platform, and four routes on
Moolam's verify service that sign the C2PA manifest, fingerprint the picture and hand back the
passport fields.
What is live today. The verify service at
https://verifier-production-d76f.up.railway.appruns the four/platformroutes and the MCP server, andhttps://<verify-service>on this page stands for that address. Moolam's platform list has one entry, Dreamloom, the demo platform athttps://dreamloom-production-3e74.up.railway.app, which registers its pictures through the kit. Not live yet: the email claim routes (/claimsand/platform/deliveries), the confirmed credit described below, and the co-sign and connect pages onmoolam.vercel.app. The routes and the credit go live with the next deploy of the verify service, and the pages with the next deploy of the web app. The package is private to this repository and not on npm yet.
The two modes
A platform picks a mode for each picture. Co-sign is the main path: the person signs, and the passport credits them from the first block.
| Co-sign mode (the main path) | Platform mode | |
|---|---|---|
| Who signs as the creator | The person, with their own passkey, on a Moolam page | Your server passkey, bound once to your wallet |
| Who signs for the agent that drew it | Your agent's owner key | Your agent's owner key |
| Who sends the transaction and pays | The person's Moolam wallet, with the fee paid by Moolam | Your wallet |
| Who holds the passport first | The person | Your wallet, for the person, until they claim it |
| Who Moolam credits | The person, from the first block | Nobody while you hold it, nor after you hand it on. The first person you hand it to, once they confirm it by stating their own AI-use terms from their wallet |
| What it needs from the person | Opening a link, signing in, one passkey signature | Nothing now. Later, signing in to Moolam with the email you sent, or giving you an address, and one confirm |
| Pick it when | The person can give one passkey signature, which is almost always | You generate at volume, or your users have no Moolam account yet |
In co-sign mode your server prepares the picture and your agent signs, and the person finishes it on
a page at https://moolam.vercel.app/cosign#<handle>. The handle travels after the #, so it never
reaches a server log, an analytics tool or a Referer header. The co-sign page ships with the next
deploy of the web app.
In platform mode you register the picture at once and hold the passport for the person until they claim it. One company holds both keys the registry asks for. The chain records the same fields it records for anyone, and Moolam says exactly what that proves: a key bound to the creator wallet signed the fingerprint, and your agent's owner signed it too. The person claims the passport later through a transfer from your wallet to theirs (Handing a passport to a person). How Moolam credits each case is below.
Which way to hand passports to people
There are three ways for the person who imagined a picture to end up holding its passport and named as its maker. Sign it yourself is the one we recommend.
| Sign it yourself (co-sign). Recommended | Leave it for their email | Connect once | |
|---|---|---|---|
| How it runs | Co-sign mode | Platform mode with deliver, and the delivery worker | Platform mode, then transfer |
| What the person does | Opens a Moolam page from your site, signs in, and approves the picture with their passkey. The first time they also make a passkey; after that it is one tap per picture | Signs in to Moolam with the email you verified, by Google or an emailed code, presses "These are mine" and picks their AI-use terms, then presses Confirm once you have handed the passports over | Opens Moolam's connect page from your site once and clicks to send you their Moolam address. Later pictures reach them with no step, and they confirm each by stating their own terms on its passport page |
| When the credit reaches them | From the first block: their own key approved the picture | When they confirm from their own wallet, after your hand-over. Until then the passport reads held, then handed | When they state their own terms on a passport you handed them. Until then it reads handed |
| What it costs you on chain | Nothing. Moolam pays the person's registration, about 0.068 MON, and their terms | About 0.063 MON a picture: register, your terms, the hand-over. Moolam pays the person's confirm | About 0.063 MON a picture, the same three transactions |
| What you build | Send a link, or open it in a popup | One field on register, and one delivery worker | A button that opens the connect page, and a place to keep each person's address |
Pick sign it yourself when the person is at the screen when the picture is made, which is almost always. Pick leave it for their email when you generate in the background or in bulk and you have already verified each user's email. Pick connect once when you have no verified email for your users and they come back to your site.
Why we recommend it: sign it yourself is the only way that puts the person's own key on the picture. Their passkey approves this exact picture before the registry writes it, so the credit never rests on your word about who asked for it, and it costs you no gas. The other two ways credit the person too, but only after your hand-over and their own confirmation, and the passport says so.
Joining the list
Moolam keeps one public list of platforms, packages/verifier/src/platforms/platforms.json, copied
byte for byte into the web app. No route writes it. A Moolam operator adds an entry with
scripts/platform-add.ts and commits it, so every change is in git history.
What an entry holds. Your platform's name, your ERC-8004 agent id, the address that owned the
agent when you joined, your creator wallet, the sha256 of your Moolam key, one to four https origins
your site opens Moolam from, the time the entry starts (validFrom), the time it ends (validTo,
set only when it is replaced), the time the operator accepted it, and two signatures.
What each signature proves. Both are EIP-712 signatures over one PlatformOnboarding message in
the domain Moolam platform, version 1, chain 143, covering the name, agent id, agent owner,
creator wallet, key hash, origins and start time.
- The agent owner's signature proves that whoever holds the key of the address named as your agent's owner agreed to be listed under this name, with this wallet, this key and these origins.
- The creator wallet's signature proves the wallet that will be the creator of every platform-mode passport agreed to the same.
- The operator's acceptance is running the script and committing the entry. It is recorded as
acceptedAtand is not a signature.
How the list is checked. The service and the web app check every signature when they load the
list, offline, with no network call. One bad entry refuses the whole list, and a refused list means
no platform routes and no labels anywhere, never a partial list. Names are stored cleaned (NFKC,
trimmed, one space between words), at most 48 characters, printable, and unique after case folding.
One name belongs to one agent, one agent and one creator wallet to one name, a key hash appears
once, and two entries of one platform never overlap in time. Origins are stored in the one canonical
form new URL(origin).origin gives, https only, with no path.
What the list does not prove: who the company is. Moolam checks that you hold the keys you name.
Your Moolam key. The operator's script makes 32 random bytes and prints them once, as 64 hex
characters, to hand to you over a private channel. Nothing writes the key to a file. The list stores
only its sha256, which is safe to publish and is your platform's id in every PlatformPrepare message.
You send the key as Authorization: Bearer <64 hex characters> and nowhere else. Moolam cannot show
it to you again.
The key identifies your platform and meters it. Alone it cannot make a manifest: every prepare also needs your agent owner's signature, checked against the ERC-8004 registry at that moment. A leaked key costs you quota, not your name.
Rotation. Each key can be replaced without touching passports already registered.
| What changes | How | What happens to labels |
|---|---|---|
| Your Moolam key | A new key from the script, both signatures again, then platform-add.ts rotate --replaces <old key hash>. The old entry gets validTo set to the new entry's validFrom | Passports registered before the switch keep the label under the old entry, later ones take it under the new |
| Your creator wallet | The same rotation, naming the new wallet, then setupPlatform from the new wallet | The same |
| Your server passkey | setupPlatform({ walletClient, rpId, rotate: true }) from the same wallet. No list change | None |
| Your agent's owner | A transfer on the ERC-8004 registry. Prepares follow the owner read at that moment, so the new owner signs from then on. Sign a new entry by rotation so the list names the new owner again | Earlier passports keep theirs |
Your agent id cannot change under the same name. A new agent is a new platform.
Before your first picture
- Register an ERC-8004 agent on Monad's identity registry,
0x8004A169FB4a3325136EB29fA0ceB6D2e539a432, from a plain key. The Moolam registry recovers the maker's signature with ECDSA and compares it withownerOf(agentId). It does not accept a contract signature, so an agent held by a multisig cannot sign for Moolam. - Pick a wallet for platform mode that holds no passports yet, and fund it with MON.
- Send Moolam your name, agent id, agent owner address, creator wallet and origins. Sign the typed data the operator sends back from the agent owner key and from the creator wallet, and keep the Moolam key the operator hands you.
- For platform mode, run
setupPlatformonce from the creator wallet.
The secrets you keep
| Secret | Needed for | Why it matters |
|---|---|---|
| Moolam platform key | Both modes | It identifies and meters your platform. Sent only in the Authorization header |
| Agent owner key | Both modes | It signs as your agent on every picture. Whoever holds it can make pictures in your agent's name. Any viem local account works, including one backed by a key vault |
| Server passkey private key | Platform mode | The creator key bound to your wallet. setupPlatform returns it once and the kit never writes, logs or sends it |
| Platform wallet key | Platform mode | It pays for and holds platform-mode passports |
The package runs on your server only, never in a browser. It logs nothing, and its errors never carry a key, a private key or a co-sign handle, so they are safe to log.
Quickstart
Co-sign mode, the main path
import { readFile } from "node:fs/promises";
import { createPublicClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { monad } from "viem/chains";
import { signAsAgent, startCosign, waitForCreator, waitForPassport } from "@moolam/platform";
const publicClient = createPublicClient({ chain: monad, transport: http() });
const agentAccount = privateKeyToAccount(process.env.AGENT_OWNER_KEY as Hex);
const picture = new Uint8Array(await readFile("lighthouse.png"));
const handle = await startCosign(picture, {
verifyUrl: "https://<verify-service>",
platformKey: process.env.MOOLAM_PLATFORM_KEY ?? "",
agentAccount,
publicClient,
title: "A lighthouse at dusk",
terms: { aiTraining: "notAllowed" },
});
// The link is the person's key to the page. Send it to them and to nobody else.
await sendToUser(handle.cosignUrl);
// Keep the 32 hex characters too, so a restart can pick the co-sign up again.
await saveHandle(handle.handle, handle.passportId);
const digest = await waitForCreator(handle);
await signAsAgent(handle, digest);
const passport = await waitForPassport(handle);
console.log(`${passport.passportId} registered by ${passport.creator} at ${passport.registeredAt}`);
declare function sendToUser(link: string): Promise<void>;
declare function saveHandle(handle: string, passportId: Hex): Promise<void>;startCosignsigns a PlatformPrepare in co-sign mode. The service signs the manifest, computes every file's IPFS content id, and freezes the files on its own disk. Nothing is pinned yet.- The person opens the link and signs in. Their Moolam wallet becomes the creator, set once, from their verified session. A second wallet opening the same link is refused.
waitForCreatorpolls until then, rebuilds the digest from the fields, the person's wallet and the deadline, and refuses a digest the service computed differently.signAsAgentsigns that digest as your agent. The service accepts the signature only if it recovers to your agent's owner read now, then pins the frozen files and checks each content id is the one the passport names. Only then may the person's page ask for their passkey.waitForPassportreturns only after the registry holds this passport with this creator, agent, manifest and metadata.
The person's wallet sends register, and only a passport's holder can state its AI-use terms on
chain, so in this mode the terms you sent are in the manifest and the metadata, and the person states
them on chain.
After a restart. Keep handle.handle, the 32 hex characters, and pick the co-sign up again:
import { createPublicClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { monad } from "viem/chains";
import { resumeCosign, signAsAgent, waitForCreator, waitForPassport } from "@moolam/platform";
const publicClient = createPublicClient({ chain: monad, transport: http() });
const agentAccount = privateKeyToAccount(process.env.AGENT_OWNER_KEY as Hex);
const handle = await resumeCosign(await loadHandle(), {
verifyUrl: "https://<verify-service>",
platformKey: process.env.MOOLAM_PLATFORM_KEY ?? "",
agentAccount,
publicClient,
});
const digest = await waitForCreator(handle);
await signAsAgent(handle, digest);
await waitForPassport(handle);
declare function loadHandle(): Promise<string>;resumeCosign reads the record's status and returns the same kind of handle, so the three calls
carry on from wherever the record is, with the same checks. A handle another key made is refused as
not found. Store the handle like the link: it opens the person's page.
The signed file in co-sign mode. The files are pinned when your agent signs. The passport's
metadata document, which the chain names, gives the signed picture's IPFS address as image, and its
sha256 is the passport id. Hash it before you serve it.
One-time setup (platform mode)
import { createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { monad } from "viem/chains";
import { setupPlatform } from "@moolam/platform";
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PLATFORM_WALLET_KEY as Hex),
chain: monad,
transport: http(),
});
const setup = await setupPlatform({ walletClient, rpId: "images.example.com" });
// Shown once and never again. Put it in your secret store before anything else.
await saveSecret("MOOLAM_PASSKEY_KEY", setup.passkeyPrivateKey);
console.log(`bound ${setup.qx} to ${setup.wallet} in ${setup.bindTx}`);
declare function saveSecret(name: string, value: string): Promise<void>;setupPlatform makes a P-256 key and binds it on the registry as your wallet's creator key, sent and
paid by that wallet. Before anything is signed it refuses a wallet that already has a passkey bound
(unless rotate: true), a wallet that already holds passports (unless it is rotating a key it
already has), and an rpId that is Moolam's hostname, localhost, an IP address, a single word, or
anything the URL parser would rewrite. Use your own public hostname. Nothing on chain reads the
rpId, so it is your honest label for the key, not a proof.
Platform mode
import { readFile, writeFile } from "node:fs/promises";
import { createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { monad } from "viem/chains";
import { ServerPasskey, registerAsPlatform } from "@moolam/platform";
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PLATFORM_WALLET_KEY as Hex),
chain: monad,
transport: http(),
});
const agentAccount = privateKeyToAccount(process.env.AGENT_OWNER_KEY as Hex);
const passkey = ServerPasskey.fromPrivateKey(process.env.MOOLAM_PASSKEY_KEY as Hex, "images.example.com");
const picture = new Uint8Array(await readFile("lighthouse.png"));
const registered = await registerAsPlatform(picture, {
verifyUrl: "https://<verify-service>",
platformKey: process.env.MOOLAM_PLATFORM_KEY ?? "",
agentAccount,
passkey,
walletClient,
title: "A lighthouse at dusk",
terms: { aiTraining: "notAllowed", aiInference: "constrained", constraintInfo: "Ask licensing@example.com" },
});
// Serve these bytes, never the original upload: their sha256 is the passport id.
await writeFile(`${registered.passportId}.jpg`, registered.signedFile);
console.log(registered.registerTx, registered.consentTx);What happens, in order:
- Your agent owner signs a PlatformPrepare: the mode, the picture's sha256, your platform id, a fresh random nonce and the latest block's time.
- The service checks your key, then that signature against the agent's owner read on chain now. It signs a C2PA manifest naming your agent, fingerprints the signed picture, pins four files and answers with the passport fields, the digest and the signed file.
- The kit checks the signed file hashes to the passport id, that the creator is your wallet, that the terms in the manifest are the terms you sent, and rebuilds the digest itself. Any difference stops it before anything is signed.
- Your agent signs the passport, your server passkey signs the same digest, and your wallet sends
register, thensetConsentwith the same terms.
Nothing is sent on chain until every check passes, and a result comes back only from receipts with status success.
Your own statement. Pass platformStatement to registerAsPlatform and the passport's document
carries one line in your own words, which the passport page shows as your statement:
const registered = await registerAsPlatform(picture, {
// ...the options above
platformStatement: "Dreamloom's image agent drew this from a visitor's prompt on Dreamloom.",
});These are your words, carried byte for byte: the kit sends exactly the string you give it, and the verify service writes exactly that string or refuses the prepare. Nothing is trimmed or rewritten. The service's rule:
| What | Rule |
|---|---|
| Lines | One. No line breaks |
| Characters | Printable only: letters, marks, digits, punctuation, symbols and the plain space. No control or direction characters |
| Size | At most 1,000 bytes in UTF-8 |
| Ends | No spaces at either end |
The kit refuses an empty statement as invalid-input before anything is sent. Anything else outside
the rule comes back as service-refused with details.status 400 and an error that names
platformStatement, never your words. Say only what is true for every picture you send it with:
Dreamloom sends one sentence when the person is signed in and another when they are not. Leave it
out and the document carries no statement at all.
platformStatement is platform mode only. A co-sign passport carries the person's own sworn line
instead, "I made this picture, or I hold the rights to register it.", so startCosign has no such
option and the service refuses a co-sign prepare that carries one.
Handing a passport to a person
This is how the person claims a platform-mode passport you hold for them when they give you an
address. The hand-over alone credits nobody: the passport reads handed until the first wallet you
hand it to states its own AI-use terms, and only then names that person as the one who imagined it
(how the credit reads).
import { createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { monad } from "viem/chains";
import { MoolamPlatformError, transferPassport } from "@moolam/platform";
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PLATFORM_WALLET_KEY as Hex),
chain: monad,
transport: http(),
});
try {
const moved = await transferPassport(await passportIdToHandOver(), await addressTheyGaveYou(), walletClient);
console.log(`${moved.passportId} now held by ${moved.to}, in ${moved.txHash}`);
} catch (error) {
if (error instanceof MoolamPlatformError && error.code === "refused-recipient") {
console.log(error.message);
} else {
throw error;
}
}
declare function passportIdToHandOver(): Promise<Hex>;
declare function addressTheyGaveYou(): Promise<string>;transferPassport refuses a mixed-case address whose checksum is wrong, the zero address, your own
wallet and the registry, uses safeTransferFrom, and returns only after a successful receipt and a
fresh ownerOf read that names the new holder. The terms your wallet stated stay in force until the
new holder states their own, and the history shows who wrote each. The person can hand you their
address through Moolam's connect page, which ships with the co-sign page: it names your platform from
the list and posts the address only to one of your listed origins, after they click.
Leaving a passport for a person's email
When you already verified a user's email, you can register their picture in platform mode and leave
the passport waiting for that address at Moolam. You never ask them for a wallet. Pass deliverTo
to registerAsPlatform:
import { readFile } from "node:fs/promises";
import { createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { monad } from "viem/chains";
import { ServerPasskey, registerAsPlatform } from "@moolam/platform";
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PLATFORM_WALLET_KEY as Hex),
chain: monad,
transport: http(),
});
const agentAccount = privateKeyToAccount(process.env.AGENT_OWNER_KEY as Hex);
const passkey = ServerPasskey.fromPrivateKey(process.env.MOOLAM_PASSKEY_KEY as Hex, "images.example.com");
const picture = new Uint8Array(await readFile("lighthouse.png"));
const registered = await registerAsPlatform(picture, {
verifyUrl: "https://<verify-service>",
platformKey: process.env.MOOLAM_PLATFORM_KEY ?? "",
agentAccount,
passkey,
walletClient,
title: "A lighthouse at dusk",
terms: { aiTraining: "notAllowed" },
deliverTo: { email: await verifiedEmailOf(currentUser()), verifiedBy: "google" },
});
console.log(registered.transfer === null ? "waiting at Moolam" : `handed to ${registered.transfer.to}`);
declare function currentUser(): string;
declare function verifiedEmailOf(userId: string): Promise<string>;deliverTo is platform mode only; a co-sign prepare that carries it is refused. verifiedBy says
how you know the address is your user's:
verifiedBy | Means |
|---|---|
"google" | They signed in on your site with Google, and Google's ID token said the address is verified |
"email-code" | You sent a code to the address and they typed it back |
"platform" | Any other check of your own |
Moolam does not check this word. It shows it to the person as your claim, beside your listed name: "Dreamloom says it checked this address with a Google sign-in." Send only an address you verified for this user, in the spelling they will sign in to Moolam with.
What Moolam keeps. The address travels only inside the prepare request's body, and the kit never
logs it. The verify service puts it in one form (NFKC, trimmed, lowercase, exactly one address),
keeps only an HMAC-SHA256 of that form under a secret only the service holds, and drops the address.
No answer, log line or error carries the address or its hash; a malformed one is refused as
deliverTo, naming the field only. Nothing is folded beyond case: r.am@gmail.com and
ram@gmail.com are two addresses, and so is ram+art@gmail.com.
When the offer appears. The ticket is written pending at the prepare and opens only once the chain shows the passport registered by your wallet, held by your wallet and carrying your kit label. The service looks about once a minute. A passport that has not landed within two hours of chain time closes its ticket, and so does one your wallet moves before the person answers.
What the person sees at Moolam. Dreamloom, the demo platform, tells the person "Your passport is waiting at Moolam. Claim it with r****@gmail.com at moolam.vercel.app/en/mine". There:
- They sign in to Moolam with Google or an emailed code. Only an address Privy verified for them, their email account or their Google account's email, matches a ticket.
- My pictures opens with "Waiting for you": your listed name, your
verifiedByworded as your claim, and the pictures you hold for them, each with its title and picture through Moolam's pinned-picture rule, or "No preview" and its passport id. - They pick the AI-use terms for those pictures and answer for all of them at once:
- These are mine accepts them for their Moolam wallet, the first embedded wallet in their Privy record. Neither you nor they choose the address.
- Not mine refuses them for good, stops your offers to that address until they allow you again, and counts against your key.
- Send my future platform pictures straight to my account, a tick beside the answer, makes that wallet and those terms a standing choice for your platform.
- Once your worker has handed the passports over, they press Confirm. One
setConsentBatchfrom their own wallet writes their terms on up to 32 passports, with the fee paid by Moolam. That is the moment the passport names them as the person who imagined it. - Under "Your platform choices" they can Stop a standing choice, or Allow again a platform they said no to.
The delivery worker. Run one on your server, beside the code that registers:
import { createPublicClient, createWalletClient, http, type Hex } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { monad } from "viem/chains";
import { runDeliveries } from "@moolam/platform";
const walletClient = createWalletClient({
account: privateKeyToAccount(process.env.PLATFORM_WALLET_KEY as Hex),
chain: monad,
transport: http(),
});
const publicClient = createPublicClient({ chain: monad, transport: http() });
const worker = await runDeliveries({
verifyUrl: "https://<verify-service>",
platformKey: process.env.MOOLAM_PLATFORM_KEY ?? "",
walletClient,
publicClient,
});
process.once("SIGTERM", () => {
void worker.stop();
});Every five seconds (intervalMs, 250 ms to an hour) it reads GET /platform/deliveries: the
accepted tickets of your key whose passport your wallet still holds, with the wallet each goes to, at
most 32 a poll. For each one it reads ownerOf again and skips a passport your wallet no longer
holds, checks the address by the same rule transferPassport uses, simulates safeTransferFrom, and
only then sends it from your wallet. It sends a ticket at most once a poll, logs nothing, and throws
nothing once started. stop() lets a send in flight finish and starts no other. Run one worker per
platform: two would race to send the same hand-over, and the loser pays for a failed transaction.
Moolam marks a ticket delivered when the chain shows the person holding the passport; the worker
reports nothing on success.
A standing choice, deliverNow. When the person ticked "send my future pictures straight to my
account" for your platform, the prepare answer carries deliverNow, their wallet. registerAsPlatform
checks that address by the same rule before anything is sent, then registers, writes your terms and
hands the passport over in one call, and returns the hand-over in transfer. The person is not asked
again; the picture waits on My pictures for their Confirm. If the passport and its terms land and the
hand-over does not, the call throws delivery-failed with the passport id and both transactions. The
passport waits in your wallet, its ticket stands accepted, and the worker hands it over on a later
poll.
After a failed hand-over. A refused address, a refused simulation or a failed transaction counts
as one failed try. After maxTries failed tries (3 by default, 1 to 10) the worker calls
POST /platform/deliveries/:ticketId/failed. The ticket closes as failed and is never listed again,
and the person's page says you could not hand those pictures over and asks them to ask you to send
them again. The passport stays in your wallet and reads held. You can still hand it over with
transferPassport to an address the person gives you, for example through the connect page. The try
count lives in the worker's memory, so a restarted worker tries a ticket up to maxTries times again.
Limits and refusals.
| What | Value |
|---|---|
| The address | 1 to 254 characters; the deliverTo field at most 2,048 bytes |
| Tickets your key writes for one address | 20 inside a ticket's 180 day life, whatever became of them |
| Waiting tickets per key | 1,000 |
| A passport to land after its prepare | 2 hours of chain time, or the ticket closes |
| An offer | 180 days from the prepare |
| Refusals | A key with at least 10 refusals, more than one in four of its tickets, stops writing tickets until a Moolam operator reopens it. Prepares without deliverTo carry on |
| Hand-overs listed per poll | 32 |
| Passports in one accept, refuse or confirm call | 32, setConsentBatch's cap. A longer list goes in more than one call |
A prepare with deliverTo is refused 400 deliverTo for a malformed field or co-sign mode,
403 deliver-closed for a key closed by refusals, 429 deliver-full at a ticket cap, and
503 claims-unavailable when the verify service's claim routes are closed. Each answer reads the
same whether or not the address has a Moolam account, so a prepare tells you nothing about Moolam's
users; the one difference is deliverNow, which only that person's own standing choice for your key
produces.
What this does not check: that the address is your user's. That is your check, and the person sees it as your word.
Errors
Every refusal is a MoolamPlatformError with a code to branch on and details that never carry a
secret.
import { MoolamPlatformError, registerAsPlatform } from "@moolam/platform";
type Options = Parameters<typeof registerAsPlatform>[1];
async function register(picture: Uint8Array, options: Options) {
try {
return await registerAsPlatform(picture, options);
} catch (error) {
if (!(error instanceof MoolamPlatformError)) throw error;
if (error.code === "service-refused" && error.details.status === 429) {
return null; // A daily limit: try again after the reset.
}
if (error.code === "consent-failed") {
// The passport landed and the terms did not. Send setConsent again with the same terms.
console.log(error.details.passportId, error.details.registerTx);
}
throw error;
}
}
export { register };| Code | Means |
|---|---|
invalid-input, invalid-rp-id, invalid-terms | Your input was refused before anything was sent |
wrong-chain | The client is not on Monad, chain 143 |
passkey-already-bound, passkey-not-bound, wallet-holds-passports | Setup or rotation rules, see above |
service-refused | The verify service said no. details.status and details.error carry its code, listed in the API reference |
service-unreachable, unexpected-answer | No answer in time, or an answer in a shape the kit does not accept |
digest-mismatch, signed-file-mismatch, terms-mismatch, creator-mismatch | The service's answer disagreed with what the kit rebuilt. Nothing was signed |
creator-not-set, deadline-passed, cosign-expired, timed-out | A co-sign step came too early or too late |
passport-taken | Another passport already holds this picture |
transaction-failed | A transaction would revert, so it was not sent, or it reverted |
consent-failed | The passport is registered and the terms are not. Send setConsent again |
delivery-failed | The passport and its terms are on chain, and the hand-over to a person's standing choice did not land. details carries the passport id and both transactions. The passport waits in your wallet and the delivery worker hands it over on a later poll |
refused-recipient, not-holder | A transfer the kit will not make |
Limits
| Limit | Value |
|---|---|
| Prepares per platform key | 100 in a rolling 24 hours, PLATFORM_PREPARES_PER_KEY_PER_DAY on the service |
| Prepares for every platform together | 500 per UTC day, GLOBAL_PLATFORM_PREPARES_PER_DAY. It holds even when a key leaks, and 0 closes the route for everyone |
| Requests | 60 a minute per address, like every route on the service |
| The picture | JPEG, PNG or WebP, up to 20 MB and 40 megapixels |
| The title | 1 to 120 characters |
| The terms | aiTraining, aiGenerativeTraining, aiInference and dataMining, each allowed, notAllowed or constrained. constraintInfo is required exactly when one is constrained: at most 256 bytes of printable ASCII, with no space at either end |
| The prepare window | issuedAt at most ten minutes before chain time and at most 60 seconds after it. A nonce is accepted once, and the same picture once per platform inside the window |
| A co-sign link | Open for 30 minutes of chain time after the prepare (openBy) |
| A co-sign deadline | 30 minutes of chain time from the moment the person opens the link |
| Frozen files per key | 100 MiB, five full uploads, in co-signs still waiting for a creator or a signature |
| Waiting co-signs on the service | 5,000 at most |
Every window is read against the latest block's time, never a server clock. A prepare that is refused before anything that costs Moolam lands gives its quota slot back. Status stays readable for a day after a record's deadline.
The quotas, the replay memory and the waiting co-signs live on the service's volume at /data, so a
redeploy keeps them. The service runs one copy, so each deploy has a short gap: a request sent during
it fails and must be sent again, and a co-sign whose deadline passes inside the gap is lost.
What it costs on Monad mainnet
Monad charges the gas limit a transaction sets, not the gas it used. Measured on mainnet on 2026-09-30 at about 102 gwei, from receipts unless the row says estimate:
| Action | Paid by | Gas | MON |
|---|---|---|---|
| Register your ERC-8004 agent, once | You | 411,369 | 0.042 |
| Bind your server passkey, once | You | 130,034 | 0.013 |
| Register one picture in platform mode | You | 413,634 | 0.042 |
State its terms with set (estimate; a longer condition costs more) | You | 107,067 to 239,917 | 0.011 to 0.024 |
| Hand a passport to a person (estimate) | You | 95,149 | 0.010 |
| Register one picture in co-sign mode, sponsored | Moolam | 657,678 to 678,128 | 0.067 to 0.069 |
A sponsored set | Moolam | 321,392 to 374,141 | 0.033 to 0.038 |
The C2PA signing and the four IPFS pins of every prepare are Moolam's cost in both modes. A new platform wallet that sets up, registers ten pictures with terms and hands one over spends about 0.73 MON; fund it with 1.5 MON for the margin a gas limit and a failed attempt need.
How Moolam credits a platform picture
The person who imagined a picture gets the credit. Your platform is where it was drawn, and in platform mode the one holding the passport until it hands it on. Moolam never names a platform as a picture's maker or creator.
One function decides the credit, creditFor, beside platformLabel in
packages/verifier/src/platforms/platforms.ts, so the site and the MCP server say the same thing. It
reads the passport's fields from the chain, its holder read now, for a listed agent that agent's
owner read now, and for a platform-mode passport two facts from Moolam's index: the first wallet your
wallet handed it to, and who wrote each of its AI-use statements. It gives one of five cases:
| Case | When | What the passport says | What it proves |
|---|---|---|---|
held | platform gives your platform's kit label, and the passport has never left your wallet | "Imagined on platform. platform holds this passport until the person who imagined it claims it." | The picture was drawn on your platform and registered through the kit. Nobody is named yet |
handed | The kit label; your wallet handed the passport on, and the first wallet it went to has written no AI-use terms of its own | "Drawn on platform and handed to holder, who has not confirmed it." with a note that nobody is credited until the person it was handed to states their own terms | Your wallet handed the passport to that address. A hand-over is your act, not theirs, so nobody is credited yet |
confirmed | The kit label; the first wallet you handed it to wrote its own terms on it, and still holds it | "Imagined by person. Drawn on platform, which handed the passport to them. They confirmed it on date." with a note that the credit rests on your statement that this account asked for the picture and on their own confirmation, and that their key did not sign the picture | Your hand-over, then a statement from the person's own wallet. That they wrote the prompt is your word, which they confirmed |
passed | As confirmed, and someone else holds the passport now | "Imagined by person. Now held by holder." | The credit stays with the first recipient who confirmed. A later holder is named only as holding it |
cosigned | No kit label, because the creator is not a listed wallet; the passport is Generated, carries a manifest and names a current entry's agent; it was registered inside that entry's window; and the agent's owner read now is the owner that signed the entry | "Imagined by creator. Drawn by platform's agent." with a note that a key bound to their own wallet approved this exact picture | A passkey bound to the person's own wallet signed this picture's fingerprint. The credit rests on their key, not on anything you say, which is why co-sign is the main path |
Only the first wallet you hand a passport to can ever be credited, and only once that wallet has written its own terms. Terms your own wallet stated at registration never count, and neither do a later buyer's.
With the email path. The person's Confirm on My pictures is that first statement: their own
wallet writes their AI-use terms with setConsentBatch, and the passport moves from handed to
confirmed. Until then it reads held while it waits for their answer, and handed once your
worker has moved it. A standing choice skips the wait for an answer, not the Confirm. With connect
once, the person confirms the same way, by stating their terms on the passport page.
A picture's plate carries the short form, "Drawn on platform". The MCP server's get_passport
answers with the case as credit.
When there is no credit line. creditFor gives nothing, and credit is never guessed, when:
- Moolam's platform list did not load;
- your listed wallet registered the picture outside the kit;
- the holder could not be read;
- the index could not say who the passport was first handed to, or says it never left your wallet while the chain shows someone else holding it;
- the agent is not on the list, its owner could not be read, or its owner is not the one that signed the entry now in force;
- a co-signed picture was registered before the entry now in force began, for example before a key rotation.
The passport's base line and the holder and creator facts stand either way.
What the label on a passport means
A passport your platform registered in platform mode carries this line on its passport page:
Registered by your platform through the Moolam platform kit
and under it:
Your platform's own server key signed as the creator, and its agent signed as the maker. Moolam checked that the company holds both keys, not who the company is.
Its plate in Explore carries the platform's mark, and the MCP server's get_passport answers with
platform: { status: "kit", name, creatorWallet, agentId }.
A co-sign passport's creator is the person, so it carries no platform label, only the cosigned
credit above. Its page names your
agent by its ERC-8004 id, with the name your agent's own card gives, marked as the card's own word,
and the MCP server's get_agent answers with the name Moolam lists for that agent.
Every passport's base line stays the same with or without the list: "A key bound to this creator's wallet signed this picture's fingerprint on Monad." The label is added on top, so losing the list loses a label and never adds a claim.
One function issues it, platformLabel in packages/verifier/src/platforms/platforms.ts, used by
the service and, as a byte for byte copy, by the web app. It gives the label only when all of these
hold:
- the passport's creator is your listed creator wallet;
- it is Generated, by your listed agent;
- it carries a C2PA manifest;
- it was registered inside your entry's window, from
validFromup tovalidTo.
When it is withheld.
- A passport from your listed wallet that fails any of those reads: "This wallet is listed as your platform, but this picture was registered outside the Moolam platform kit."
- A passport registered after an entry was replaced takes its label from the new entry, or none.
- A list that fails to load gives no label anywhere.
- When your agent's owner changes after you joined,
get_agentanswersownerUnchanged: false, and Moolam's co-sign page shows your platform's badge only while the owner read now is the owner that signed your entry. Passports already registered keep their label, because it is judged on the fields the chain recorded when they were made.
When the holder is not the creator, every reader says both: "Registered by creator. Now held by holder." A passport someone holds but did not make is listed apart in their pictures, never as their work.
What the kit does not promise
- Moolam does not check who the company behind an agent is. It checks that the listed keys signed.
- The chain cannot tell a server key from any other passkey, and Moolam does not pretend it can. A platform-mode passport is one company holding two keys.
- It cannot vouch that a listed platform's generator made what the platform says it made.
- It cannot make a platform hand a passport over, or tell a correctly typed wrong address from the right one.
- Your key custody, your copy of the package and your users' identity are yours.
- In platform mode your server holds the signed file before it registers, and in co-sign mode the files are public on IPFS from your agent's signature until the person signs. Whoever registers those bytes first holds the passport; the prepare refuses a picture already registered.
- A look-alike name in another script can pass the name check, which is why the label always sits beside the wallet and the agent id.
The full list of what is checked, where and by which test is in the threat model. The routes are in the API reference, and AI agents read passports through the MCP server.