Create a lab and upload a public file
The default path: self-issue a token, mint a LabNFT, register the lab, upload a public file, and verify it landed.
The default path, and the one to run first. Five steps: get a token, mint the LabNFT, register the lab, upload the file, verify. A public file is stored as-is β no key management, no access conditions.
Want the file to be confidential instead? Steps 1β3 are identical; branch at Step 4 into Upload an encrypted file.
Step 1: Get a service token
Prove control of the wallet instead of waiting on a manually issued token β the self-service path for agents, bots and CI/CD. Fetch a sign-in message, sign it as a plain wallet message (EIP-191 personal_sign β not typed data), then exchange the signature for a token. Full reference: Service Tokens.
The message carries a server-issued single-use nonce and is valid for 10 minutes, so these two calls belong together: fetch, sign, redeem. Neither the message nor the signature can be cached or reused.
import { createPublicClient, createWalletClient, http } from "viem";
import { privateKeyToAccount } from "viem/accounts";
const SERVICE_NAME = "tutorial-agent";
const account = privateKeyToAccount(WALLET_PRIVATE_KEY);
// publicClient: read-only RPC calls (readContract, waitForTransactionReceipt).
const publicClient = createPublicClient({ chain: CHAIN, transport: http() });
// walletClient: everything that needs the private key β signing and sending.
const walletClient = createWalletClient({ account, chain: CHAIN, transport: http() });
// Fetch this immediately before signing: the message embeds a single-use
// nonce that expires 10 minutes after issuance, and requesting a new one
// invalidates any previous message for this wallet + service.
const signInMessage = await graphql(
`query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
getServiceSignInMessage(walletAddress: $walletAddress, serviceName: $serviceName) {
message
expiresAt
}
}`,
{ walletAddress: account.address, serviceName: SERVICE_NAME },
);
// Sign the message VERBATIM β the backend recomposes the same string from the
// stored nonce and verifies it, so re-wording, re-formatting or rebuilding it
// client-side breaks verification.
const messageSignature = await walletClient.signMessage({
message: signInMessage.getServiceSignInMessage.message,
});
const tokenResult = await graphql(
`mutation GenerateServiceToken(
$serviceName: String!
$walletAddress: String!
$messageSignature: String!
) {
generateServiceToken(
serviceName: $serviceName
walletAddress: $walletAddress
messageSignature: $messageSignature
) {
token
tokenId
expiresAt
message
error { code message requestId retryable details }
}
}`,
{ serviceName: SERVICE_NAME, walletAddress: account.address, messageSignature },
);
assertOk(tokenResult.generateServiceToken, "generateServiceToken");
serviceToken = tokenResult.generateServiceToken.token;Expected response:
If it fails:
error.code
What happened
Fix
UNAUTHENTICATED, reason: INVALID_SIGNATURE
The signed bytes are not the message the backend recomposes β altered text, a message superseded by a later getServiceSignInMessage call, or a walletAddress that is not the address that produced the signature
Sign the most recent message byte-for-byte, and send the signing address as walletAddress. Use personal_sign / viem's signMessage, not signTypedData
UNAUTHENTICATED, reason: NONCE_NOT_FOUND
No nonce on file β never requested for this wallet + service, or already redeemed by an earlier token
Re-run the query and sign the new message. Retrying the same signature never works
UNAUTHENTICATED, reason: NONCE_EXPIRED
More than 10 minutes passed between fetching the message and redeeming it
Re-run the query and sign the new message
VALIDATION_FAILED
Malformed walletAddress
Send a checksummed or lowercase 0x-prefixed 20-byte address
INTERNAL_ERROR, reason: TOKEN_GENERATION_FAILED
Bad expiresIn β the value is not validated before use, so a malformed or out-of-bounds one surfaces as a masked server error
Validate before sending: format is <int><unit>, unit one of s m h d w M y, between 1 hour and 2 years. Despite retryable: true on INTERNAL_ERROR, retrying will not fix it
HTTP 401 before GraphQL runs
Consumer credential missing or malformed
Check Authorization β no Bearer prefix. See Authentication
expiresIn defaults to 180d when omitted. The returned token is wallet-bound, not lab-bound: it carries this wallet's identity, and authorisation is resolved per request from that wallet's onchain role on whichever lab you name.
Step 2: Mint the LabNFT
Mint onchain via OnChainLabFactory.mintAndCreateAccount and read oclId off the OclIdentityCreated event. Reuses account / publicClient / walletClient from Step 1 and FACTORY_ADDRESS / LABNFT_ADDRESS from the config block. Full detail β the fee call and how oclId is derived β is on Lab Management.
Expected result: mintReceipt.status === "success", and
If it fails:
Transaction reverts β the wallet is unfunded, or
valuedidn't matchmintFeeWei(). Read the fee live and forward it; don't hardcode0.parseEventLogsreturns[]β the logs weren't filtered to the LabNFT.OclIdentityCreatedfires on the LabNFT contract, not the factory; the factory's ownAccountProvisionedevent does not carryoclIdas a topic.
Step 3: Register the lab
Register the Kamu-backed data room for the freshly-minted oclId. Full reference: Create Lab.
Expected response:
When a lab is minted shortname is lab-<token-id> by default. Once the owner renames the lab, the slug becomes the shortname derived from the new name and the lab-<tokenId> form stops resolving (see Step 5).
If it fails:
error.code
What happened
Fix
CONFLICT, reason: PROJECT_CONFLICT
The lab is already registered
Treat as success and continue β this is what a re-run looks like
NOT_FOUND
The oclId isn't indexed yet
The indexer trails the mint by a few seconds. Retry with backoff
UNAUTHENTICATED, reason: NO_AUTH
No X-Service-Token and no Privy session
Step 1 didn't set serviceToken
UPSTREAM_UNAVAILABLE
A dependency is down (reason: KAMU)
retryable: true β retry with backoff
DID-linking for the new lab starts automatically in the background; getDidLinkStatus reports its progress. You do not need to wait for it.
Step 4: Upload the file
This section is for uploading public files, if you are interested in uploading an encrypted file, please jump to this tutorial instead uploading an encrypted file.
Three calls: get a presigned URL, PUT the bytes, finalise with metadata. Full reference: Files.
This is the step that most often fails on a lab you have just created.
Molecule runs onchain and offchain systems side by side, and the offchain side learns about onchain events through an indexer. Keeping the two in step takes a moment, so a call that succeeded does not mean every read has caught up yet.
That is exactly what happens here. createLab can succeed before your mint has been indexed, because it falls back to checking ownership onchain. The file mutations have no such fallback β they read the indexed record, and return NOT_FOUND until it arrives.
So wrap the first call in withIndexerLagRetry so the helper will retry until the record arrives.
Expected responses:
The PUT returns HTTP 200 with an empty body. Then:
message on this result is passed through from the storage layer, so its exact wording varies and is deliberately not shown here β it is not part of the contract. Assert on error == null, never on message.
Keep datasetId β it is the file's stable identifier for later reads and updates.
If it fails:
initiate β NOT_FOUND, "Project 0xβ¦ does not exist"
The mint is not indexed yet. createLab can succeed before this is true, so a successful Step 3 is no guarantee
Retry with backoff β withIndexerLagRetry above. Usually seconds; observed up to ~4 minutes under indexer backlog. Do not re-run createLab, which returns CONFLICT once registered
initiate β UNAUTHORIZED
The wallet behind the token has no write role on this lab
You must be Owner or Contributor. See Agent access
PUT β 403
URL expired (~15 min), or headers altered
Re-run initiate; send the returned headers verbatim
PUT β 400/411
Body wasn't sent as raw bytes
Send the buffer, not a JSON wrapper. In curl: --data-binary
finish β VALIDATION_FAILED, details.field: "path"
path contains an underscore, or both path and ref were sent
Underscores are not allowed in path; use path for a new file or ref for a new version, never both
finish β VALIDATION_FAILED, reason: INVALID_TAGS_OR_CATEGORIES
Unknown tag or category
Valid values come from the public fileCategoriesAndTags query
finish β VALIDATION_FAILED, reason: INVALID_ACCESS_LEVEL
Bad accessLevel
One of PUBLIC, HOLDERS, ADMIN
finish β UPSTREAM_UNAVAILABLE, "Path is occupied"
A file already lives at that path β the usual cause is re-running this tutorial against the same lab
Not retryable despite the code: retrying sends the identical request and fails identically. Either pick a new path, or send ref (the previous datasetId) instead of path to add a version to the existing file
Step 5: Verify it worked
Two checks. The first needs nothing but your consumer credential:
Your file is in dataRoom.files with accessLevel: "PUBLIC" and version: 1. Because the file is public, downloadUrl is a fetchable presigned URL β fetch it and compare the bytes to what you uploaded for an end-to-end check.
If labWithDataRoomAndFiles comes back null, the lab is not registered: Step 3 didn't complete. This is one of only two nullable queries on the Labs API, so a missing lab nulls the field instead of throwing.
The second check is visual. The lab has a page at ${LAB_APP_URL}/projects/<slug>. Until the lab is renamed, that slug is lab-<labNftTokenId> β the token id createLab returned in Step 3 β so a lab you have just created is at https://testnet.labs.molecule.xyz/projects/lab-1274 on staging. Once the owner renames the lab, the slug becomes the shortname derived from the new name and the lab-<tokenId> form stops resolving. oclId does not work here.
Complete script
All five steps in one file, against staging. No pre-issued service token needed.
Usage:
Next
Make the next file confidential
Let an agent write into this lab
Run it against mainnet
Per-operation reference
Last updated
Was this helpful?