For the complete documentation index, see llms.txt. This page is also available as Markdown.

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.

Before you start: you need the two prerequisites β€” a mol_ consumer credential and a funded Base Sepolia wallet β€” plus the shared setup block, which defines the config constants and the graphql() / assertOk() helpers every snippet below uses. The complete script at the end of this page carries all of it inline and runs standalone. Unfamiliar with a term used here? See the Glossary.

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 value didn't match mintFeeWei(). Read the fee live and forward it; don't hardcode 0.

  • parseEventLogs returns [] β€” the logs weren't filtered to the LabNFT. OclIdentityCreated fires on the LabNFT contract, not the factory; the factory's own AccountProvisioned event does not carry oclId as 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.

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:

Symptom
Cause
Fix

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

Need help?

Ask the team on Discord. Reporting an API error? Include the requestId from the failing response so we can trace it.

Ask on Discord

Page wrong, unclear or missing something? Let us know with Was this helpful? on this page.

Last updated

Was this helpful?