The Labs API allows developers to interact with Molecule Labs datarooms without requiring browser-based user interaction. This enables integration with automated workflows, data pipelines, CI/CD systems, and external applications.
- Automated Data Pipelines: Schedule regular data synchronization from research systems
- CI/CD Integration: Automatically publish build artifacts and test results
- External System Integration: Connect third-party tools and platforms to your Lab
- Batch Operations: Upload multiple files programmatically
- Monitoring & Alerting: Automated upload of logs and metrics
Ready for Production: This API is production-ready and actively used by projects for automated data management. To request API access, please join our Discord community and reach out to our team.
The Labs API has different authentication requirements depending on the operation type:
Rule of thumb: Most queries are public (API Key only) and all write mutations require a Service Token (API Key + Service Token). The exceptions are called out below — a couple of queries are role-gated, and
generateServiceTokenbootstraps a token with a wallet signature.
These queries are public and only require an API Key:
labs- List all labs with paginationlabWithDataRoomAndFiles- Get lab details and fileslabActivity- Get activity feed for a lab, (available filters: ANNOUNCEMENT | FILE)activities- Get global activity feed, (available filters: ANNOUNCEMENT | FILE)dataRoomFile- Get file by pathsearchLabs- Search across labs, files, and announcementsfileCategoriesAndTags- List valid file categories and their tagsgetServiceSignInMessage- Get the message a service signs to obtain a tokengetDidLinkStatus- Get background DID-linking status for a lablegalAgreementStatus- Check whether a lab's legal agreement is signedonChainActivity- Onchain event feed for a lab or walletlistLabMembers- List a lab's members
x-api-key: YOUR_API_KEYAuthenticated query — API Key plus a Service Token, or an authenticated user session:
legalAgreementTemplate- Get the populated agreement to sign (the signer's authenticated session, or a service token)
All write mutations require authentication with two headers:
- API Key - For general API authentication
- Service Token - For lab-specific write access control
Protected mutations include:
createLab- Create a lab (data room) for an onchain lab (OCL) · 💳 also available pay-per-call via x402 GatewayinitiateCreateOrUpdateFile- Initiate file upload · 💳 also available pay-per-call via x402 GatewayfinishCreateOrUpdateFile- Complete file upload · 💳 also available pay-per-call via x402 GatewayupdateFileMetadata- Update file metadatadeleteDataRoomFile- Delete a filecreateAnnouncement- Create an announcement · 💳 also available pay-per-call via x402 GatewayupdateLabNftMetadata- Update LabNFT display metadata (OCL admin only)generateLabImageUploadUrl- Get a presigned URL to upload a LabNFT image (OCL admin only)signLegalAgreement- Record acceptance of a legal agreementgenerateDataEncryptionKey- Generate a standalone data encryption key · 💳 also available pay-per-call via x402 GatewaydecryptDataKey- Decrypt a file's data key for an authorized caller · 💳 also available pay-per-call via x402 GatewayextendServiceToken- Extend service token expirationrevokeServiceToken- Revoke a service token
generateServiceTokenis the bootstrap exception: it mints a Service Token, so it needs only an API Key plus either a Privy session or a wallet signature — not a pre-existing Service Token. See Obtaining Tokens.
Pay-per-call alternative. Mutations tagged 💳 above can also be called through the x402 Gateway, which settles a USDC payment on Base per request and mints a short-lived service token on the fly — no long-lived credentials required. Useful for autonomous AI agents and third-party tools that pay for users.
x-api-key: YOUR_API_KEY
X-Service-Token: YOUR_SERVICE_TOKENTo obtain access credentials:
- Join our Discord community
- Contact the Molecule team and provide:
- Your wallet address (will be linked to the service token)
- Intended use case / service name
- Which lab/dataroom you need access to
- Desired token expiration period
- The team will generate and provide you with:
- API Key - Used for all Molecule APIs
- Service Token (JWT string) - Grants access to specific lab
- Token ID - For management operations
For all queries (read-only operations):
x-api-key: YOUR_API_KEYFor all mutations (write operations):
x-api-key: YOUR_API_KEY
X-Service-Token: YOUR_SERVICE_TOKENWhy two tokens for mutations?
- API Key: Authenticates you as a valid Molecule API user
- Service Token: Identifies which specific lab/dataroom you have write access to
Security Warnings:
- Service tokens are shown only once during generation - store them securely immediately
- Never commit tokens or API keys to version control
- Never log credentials in application logs
- Store in environment variables or secure secret management systems
- Rotate tokens regularly (quarterly recommended)
The Programmatic File Upload API uses a 3-step workflow:
Production: https://production.graphql.api.molecule.xyz/graphql
Staging: https://staging.graphql.api.molecule.xyz/graphql
Register a Kamu-backed lab (data room) for an onchain lab (OCL) that already exists onchain. The lab is identified by its canonical oclId (a 32-byte hex string, 0x-prefixed).
Admin Authorization Required: This mutation requires either a service token (JWT) from the Molecule team OR a valid Privy authentication token. The caller must be the LabNFT owner (or an authorized multisig signer) for the given
oclId.
GraphQL Mutation:
mutation CreateLab($oclId: String!) {
createLab(input: { oclId: $oclId }) {
isSuccess
message
error {
message
code
retryable
}
lab {
oclId
shortname
labAccountAddress
labNftTokenId
}
}
}Parameters:
The mutation takes a single CreateLabInput object:
| Field | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId (lowercase 0x-hex) of the onchain lab |
Prerequisites:
-
LabNFT Ownership: You must own the LabNFT for the
oclIdor be an authorized signer for it- For individual wallets: You must be the owner
- For multisig/Safe wallets: You must be one of the Safe owners
- For ERC-4337 accounts: You must be an authorized account owner
-
Authentication: One of the following:
- Service Token (recommended for automation): Obtain from Molecule team via Discord
- Privy Token (for user-initiated requests): Use your authenticated Privy session
-
LabNFT Must Be Minted: The onchain lab (LabNFT /
oclId) must already exist onchain before registering the lab
Authentication Options:
Option 1: Service Token (Recommended for Automation)
x-api-key: YOUR_API_KEY
X-Service-Token: YOUR_SERVICE_TOKENOption 2: Privy Token (User-Initiated)
x-api-key: YOUR_API_KEY
Authorization: Bearer YOUR_PRIVY_TOKEN
x-wallet-address: YOUR_WALLET_ADDRESSExample Request (Service Token):
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "mutation CreateLab($oclId: String!) { createLab(input: { oclId: $oclId }) { isSuccess message error { message code retryable } lab { oclId shortname labAccountAddress labNftTokenId } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042"
}
}'Success Response:
{
"data": {
"createLab": {
"isSuccess": true,
"message": "Lab created successfully",
"error": null,
"lab": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"shortname": "apob-lab",
"labAccountAddress": "0x1234567890123456789012345678901234567890",
"labNftTokenId": "42"
}
}
}
}Error Responses:
Not Authorized (No Token):
{
"errors": [
{
"message": "Admin authorization required. Please contact Molecule team for service token access.",
"extensions": { "code": "UNAUTHORIZED" }
}
]
}Not the LabNFT Owner:
{
"data": {
"createLab": {
"isSuccess": false,
"message": "User is not authorized for this lab",
"error": {
"message": "Onchain verification failed: wallet address is not owner or authorized signer",
"code": "OWNERSHIP_VERIFICATION_FAILED",
"retryable": false
},
"lab": null
}
}
}Lab Already Exists:
{
"data": {
"createLab": {
"isSuccess": false,
"message": "Lab already exists for this oclId",
"error": {
"message": "A lab with this oclId already exists",
"code": "CONFLICT",
"retryable": false
},
"lab": null
}
}
}How It Works:
- Authentication Check: Validates service token or Privy token
- Onchain Verification: Verifies you own or are an authorized signer for the LabNFT (
oclId) - Lab Creation: Registers the Kamu-backed lab and its data room for the
oclId - Whitelist Update: Automatically adds your wallet address to the lab whitelist
- Returns Result: Lab details if successful, error details if failed
Use Cases:
- Automate Lab Creation: Register labs programmatically after minting LabNFTs
- CI/CD Integration: Automatically set up data rooms for new research labs
- Batch Operations: Register multiple labs for a portfolio of onchain labs
- User Self-Service: Allow users to create their own lab data rooms
Getting Service Token Access:
To obtain a service token for automated lab creation:
- Join our Discord community
- Contact the Molecule team
- Provide:
- Your wallet address
- Use case description
- Intended automation workflow
- You'll receive:
- API Key (for all APIs)
- Service Token (JWT for lab creation)
- Token expiration date
Initiates the upload process and returns a presigned URL for direct file upload.
GraphQL Mutation:
mutation InitiateFileUpload(
$oclId: String!
$contentType: String!
$contentLength: Int!
) {
initiateCreateOrUpdateFile(
oclId: $oclId
contentType: $contentType
contentLength: $contentLength
) {
uploadToken
uploadUrl
uploadUrlExpiry
method
headers {
key
value
}
isSuccess
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab (lowercase 0x-hex, e.g. 0x0101…0042) |
| contentType | String | Yes | MIME type of the file (e.g., application/pdf, image/png) |
| contentLength | Int | Yes | File size in bytes |
Example Request (curl):
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "mutation InitiateFileUpload($oclId: String!, $contentType: String!, $contentLength: Int!) { initiateCreateOrUpdateFile(oclId: $oclId, contentType: $contentType, contentLength: $contentLength) { uploadToken uploadUrl uploadUrlExpiry method headers { key value } isSuccess error { message code retryable } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"contentType": "application/pdf",
"contentLength": 381846
}
}'Success Response:
{
"data": {
"initiateCreateOrUpdateFile": {
"uploadToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"uploadUrl": "https://s3.amazonaws.com/bucket/path?signature=...",
"uploadUrlExpiry": "2024-01-15T10:45:00.000Z",
"method": "PUT",
"headers": [
{
"key": "Content-Type",
"value": "application/pdf"
}
],
"isSuccess": true,
"error": null
}
}
}Upload the file directly to the presigned URL returned in Step 1.
Example Request (curl):
curl -X PUT "UPLOAD_URL_FROM_STEP_1" \
-H "Content-Type: application/pdf" \
--data-binary @your-file.pdfExample Request (JavaScript):
const uploadHeaders = {};
headers.forEach((h) => {
uploadHeaders[h.key] = h.value;
});
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
headers: uploadHeaders,
body: fileBuffer,
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}Completes the upload process and registers the file in the dataroom.
GraphQL Mutation:
mutation FinishFileUpload(
$oclId: String!
$uploadToken: String!
$path: String
$ref: String
$accessLevel: String!
$changeBy: String!
$description: String
$tags: [String!]
$categories: [String!]
$contentText: String
) {
finishCreateOrUpdateFile(
oclId: $oclId
uploadToken: $uploadToken
path: $path
ref: $ref
accessLevel: $accessLevel
changeBy: $changeBy
description: $description
tags: $tags
categories: $categories
contentText: $contentText
) {
datasetId
contentHash
version
isSuccess
message
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Same oclId used in Step 1 |
| uploadToken | String | Yes | Token received from Step 1 |
| path | String | No* | File name for NEW files (e.g., research-data.pdf) |
| ref | String | No* | Dataset ID for NEW VERSIONS of existing files |
| accessLevel | String | Yes | File visibility: PUBLIC, HOLDERS, or ADMIN |
| changeBy | String | Yes | Wallet address of user making the change |
| description | String | No | Optional file description |
| tags | [String] | No | Optional tags for categorization |
| categories | [String] | No | Optional categories for organization |
| contentText | String | No | Optional searchable text content (used for semantic search) |
*Use path for new files OR ref for versions - not both
Example Request (curl):
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "mutation FinishFileUpload($oclId: String!, $uploadToken: String!, $path: String, $accessLevel: String!, $changeBy: String!, $description: String, $tags: [String!], $categories: [String!], $contentText: String) { finishCreateOrUpdateFile(oclId: $oclId, uploadToken: $uploadToken, path: $path, accessLevel: $accessLevel, changeBy: $changeBy, description: $description, tags: $tags, categories: $categories, contentText: $contentText) { datasetId contentHash version isSuccess message error { message code retryable } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"uploadToken": "TOKEN_FROM_STEP_1",
"path": "research-results.pdf",
"accessLevel": "PUBLIC",
"changeBy": "0x1234567890123456789012345678901234567890",
"description": "Q4 2024 Research Results",
"tags": ["research", "results", "2024"],
"categories": ["data"],
"contentText": "Quarterly research findings and experimental data"
}
}'Success Response:
{
"data": {
"finishCreateOrUpdateFile": {
"datasetId": "did:kamu:...",
"contentHash": "sha256:abc123...",
"version": 1,
"isSuccess": true,
"message": "File uploaded successfully",
"error": null
}
}
}Create project announcements to share updates with your community.
GraphQL Mutation:
mutation CreateAnnouncement(
$oclId: String!
$headline: String!
$body: String!
$attachments: [String!]
) {
createAnnouncement(
oclId: $oclId
headline: $headline
body: $body
attachments: $attachments
) {
isSuccess
message
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
| headline | String | Yes | Announcement title/headline |
| body | String | Yes | Announcement body (supports Markdown) |
| attachments | [String] | No | Array of file DIDs to attach to the announcement |
Example Request:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "mutation CreateAnnouncement($oclId: String!, $headline: String!, $body: String!, $attachments: [String!]) { createAnnouncement(oclId: $oclId, headline: $headline, body: $body, attachments: $attachments) { isSuccess message error { message } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"headline": "Research Milestone Achieved",
"body": "We have completed Phase 2 trials with promising results.",
"attachments": ["did:kamu:fed01..."]
}
}'Update file metadata (description, tags, categories, access level) without creating a new version.
GraphQL Mutation:
mutation UpdateFileMetadata(
$oclId: String!
$ref: String!
$accessLevel: String!
$description: String
$tags: [String!]
$categories: [String!]
$contentText: String
) {
updateFileMetadata(
oclId: $oclId
ref: $ref
accessLevel: $accessLevel
description: $description
tags: $tags
categories: $categories
contentText: $contentText
) {
ref
isSuccess
message
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
| ref | String | Yes | File reference (DID) from finishCreateOrUpdateFile response |
| accessLevel | String | Yes | File visibility: PUBLIC, HOLDERS, or ADMIN |
| description | String | No | Updated file description |
| tags | [String] | No | Updated tags for categorization |
| categories | [String] | No | Updated categories for organization |
| contentText | String | No | Updated searchable text content |
Note: The
changeByfield (wallet address) is automatically derived from your authentication and does not need to be provided as a parameter.
Example Request:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "mutation UpdateFileMetadata($oclId: String!, $ref: String!, $accessLevel: String!, $description: String, $tags: [String!], $categories: [String!], $contentText: String) { updateFileMetadata(oclId: $oclId, ref: $ref, accessLevel: $accessLevel, description: $description, tags: $tags, categories: $categories, contentText: $contentText) { ref isSuccess message error { message } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"ref": "did:kamu:fed01...",
"accessLevel": "PUBLIC",
"description": "Updated research findings with peer review",
"tags": ["research", "peer-reviewed", "2024"],
"categories": ["data", "validated"],
"contentText": "Enhanced searchable content with key findings"
}
}'Remove a file from the dataroom permanently.
GraphQL Mutation:
mutation DeleteFile($oclId: String!, $path: String!, $changeBy: String!) {
deleteDataRoomFile(oclId: $oclId, path: $path, changeBy: $changeBy) {
oclId
filePath
isSuccess
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
| path | String | Yes | File path to delete |
| changeBy | String | Yes | Wallet address making the deletion |
Warning: This is a destructive operation. The file will be permanently deleted from the dataroom and cannot be recovered.
Example Request:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "mutation DeleteFile($oclId: String!, $path: String!, $changeBy: String!) { deleteDataRoomFile(oclId: $oclId, path: $path, changeBy: $changeBy) { oclId filePath isSuccess error { message } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"path": "old-data.pdf",
"changeBy": "0x1234567890123456789012345678901234567890"
}
}'Query operations for browsing projects, viewing files, and checking activity.
Get all labs. This is a public endpoint - no authentication required.
🔓 Public Endpoint: The
labsquery does not require authentication. You only need thex-api-keyheader - no Service Token is needed.
GraphQL Query:
query ListProjects($walletAddress: String, $page: Int, $perPage: Int) {
labs(walletAddress: $walletAddress, page: $page, perPage: $perPage) {
nodes {
oclId
shortname
name
description
labAccountAddress
labNftTokenId
latestContributionAt
trlValue
trlRationale
isVerified
}
totalCount
pageInfo {
hasNextPage
hasPreviousPage
currentPage
totalPages
}
}
}The
labslist returns lightweightLabRefobjects. Data-room contents and account details are not part ofLabRef— fetch them per lab vialabWithDataRoomAndFiles.
Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| walletAddress | String | No | Filter to labs where this wallet holds any active role (owner/contributor/viewer). Omit to return all labs. |
| role | LabMemberRole | No | Only meaningful with walletAddress: restrict to labs where the wallet holds this specific role (OWNER, CONTRIBUTOR, VIEWER). |
| page | Int | No | Page number (0-indexed, default: 0) |
| perPage | Int | No | Results per page (default: 20, max: 100) |
CMS-enriched fields (optional):
These fields are sourced from the Molecule CMS and hydrated only when requested in the selection set. They are null when the project has no corresponding CMS entry.
| Field | Type | Description |
|---|---|---|
| trlValue | String | Technology Readiness Level (TRL) assessment for the project |
| trlRationale | String | Explanation supporting the assigned TRL value |
| isVerified | Boolean | Whether the project has been verified by Molecule |
Example Request:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"query": "query ListProjects($page: Int, $perPage: Int) { labs(page: $page, perPage: $perPage) { nodes { oclId shortname name labAccountAddress } totalCount pageInfo { hasNextPage currentPage totalPages } } }",
"variables": {
"page": 0,
"perPage": 20
}
}'Retrieve complete details for a specific lab including all files. This is a public endpoint - no authentication required. Look up a lab by its oclId or, alternatively, by its human-readable shortname — provide exactly one.
🔓 Public Endpoint: The
labWithDataRoomAndFilesquery does not require authentication. You only need thex-api-keyheader - no Service Token is needed. File-level access control is handled via encryption rather than query-level authentication.
GraphQL Query:
query GetProject($oclId: String!) {
labWithDataRoomAndFiles(oclId: $oclId) {
oclId
shortname
trlValue
trlRationale
isVerified
dataRoom {
id
alias
files {
did
path
version
contentType
accessLevel
description
tags
categories
downloadUrl
}
}
}
}Example Request:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"query": "query GetProject($oclId: String!) { labWithDataRoomAndFiles(oclId: $oclId) { oclId shortname dataRoom { id files { path contentType accessLevel tags } } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042"
}
}'The optional CMS-enriched fields
trlValue,trlRationale, andisVerified(see List All Projects) are also available on this query and are hydrated only when requested.
Retrieve a specific file using the lab's oclId and the file path.
GraphQL Query:
query GetFile($oclId: String!, $path: String!) {
dataRoomFile(oclId: $oclId, path: $path) {
did
path
version
contentType
accessLevel
description
tags
categories
contentText
downloadUrl
downloadUrlExpiry
}
}Example Request:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "query GetFile($oclId: String!, $path: String!) { dataRoomFile(oclId: $oclId, path: $path) { did path contentType accessLevel downloadUrl } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"path": "research-data.pdf"
}
}'Get activity timeline for a specific project including file events and announcements. This is a public endpoint - no authentication required.
🔓 Public Endpoint: The
labActivityquery does not require authentication. You only need thex-api-keyheader - no Service Token is needed.
Filtering: By default, returns all activity types (file events and announcements). Use the optional
filterparameter (ANNOUNCEMENTorFILE) to retrieve only a specific type.
GraphQL Query:
query GetProjectActivity(
$id: String!
$page: Int!
$perPage: Int!
$filter: LabActivityFilter
) {
labActivity(oclId: $id, page: $page, perPage: $perPage, filter: $filter) {
pageInfo {
hasNextPage
hasPreviousPage
currentPage
totalPages
}
nodes {
__typename
... on LabEventFileAdded {
entry {
ref
path
tags
description
version
accessLevel
eventTime
systemTime
changeBy
categories
contentType
contentHash
contentText
}
}
... on LabEventFileUpdated {
entry {
ref
path
tags
description
version
accessLevel
eventTime
systemTime
changeBy
categories
contentType
contentHash
contentText
}
}
... on LabEventFileRemoved {
entry {
ref
path
tags
description
version
accessLevel
eventTime
systemTime
changeBy
categories
contentType
contentHash
contentText
}
}
... on LabEventAnnouncement {
announcement {
id
headline
body
attachments {
id
did
path
name
contentType
accessLevel
}
changeBy
systemTime
eventTime
}
}
}
}
}
⚠️ Breaking Change: Announcementattachmentschanged from[String!]!(array of DIDs) to[DataRoomFile!]!(array of file objects). This enables querying file metadata directly without separate API calls.
Example Request:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"query": "query GetActivity($oclId: String!, $page: Int) { labActivity(oclId: $oclId, page: $page, perPage: 20) { pageInfo { hasNextPage currentPage totalPages } nodes { __typename ... on LabEventAnnouncement { announcement { headline attachments { did path contentType } } } } } }",
"variables": {
"oclId": "0x0101000000000000000000000000000000000000000000000000000000000042",
"page": 0
}
}'Use Cases:
- Announcement detail pages requiring full file metadata
- Download links for announcement attachments
- Encrypted file access (Onchain-Verified Envelope Encryption for new files, Lit Protocol for legacy)
- Projects with many announcements (efficient pagination)
Get all activity across all projects. This is a public endpoint - no authentication required.
🔓 Public Endpoint: The
activitiesquery does not require authentication. You only need thex-api-keyheader - no Service Token is needed.
Filtering: By default, returns all activity types (file events and announcements). Use the optional
filterparameter (ANNOUNCEMENTorFILE) to retrieve only a specific type.
GraphQL Query:
query GetActivities($page: Int, $perPage: Int, $filter: LabActivityFilter) {
activities(page: $page, perPage: $perPage, filter: $filter) {
isSuccess
activities {
__typename
... on LabEventFileAdded {
entry {
ref
path
tags
description
version
accessLevel
eventTime
systemTime
changeBy
categories
contentType
contentHash
contentText
}
}
... on LabEventFileUpdated {
entry {
ref
path
tags
description
version
accessLevel
eventTime
systemTime
changeBy
categories
contentType
contentHash
contentText
}
}
... on LabEventFileRemoved {
entry {
ref
path
tags
description
version
accessLevel
eventTime
systemTime
changeBy
categories
contentType
contentHash
contentText
}
}
... on LabEventAnnouncement {
announcement {
id
headline
body
attachments {
id
did
path
name
contentType
accessLevel
}
changeBy
systemTime
eventTime
}
}
}
error
}
}Perform semantic search across all projects, files, and announcements in the Labs ecosystem.
GraphQL Query:
query SearchLabs(
$prompt: String!
$filters: SearchLabsFilters
$page: Int
$perPage: Int
) {
searchLabs(
prompt: $prompt
filters: $filters
page: $page
perPage: $perPage
) {
nodes {
__typename
... on SearchLabsFileHit {
entry {
lab {
oclId
shortname
}
path
file {
did
contentType
accessLevel
description
tags
categories
downloadUrl
}
}
}
... on SearchLabsAnnouncementHit {
announcement {
id
headline
body
systemTime
attachments {
id
did
path
name
contentType
accessLevel
}
}
lab {
oclId
shortname
}
}
}
totalCount
pageInfo {
hasNextPage
hasPreviousPage
currentPage
totalPages
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| prompt | String | Yes | Search query text |
| filters | SearchLabsFilters | No | Filter criteria |
| page | Int | No | Page number (default: 0) |
| perPage | Int | No | Results per page (default: 10) |
Available Filters:
| Filter | Type | Description |
|---|---|---|
| byOclIds | [String!] | Filter by specific lab oclIds |
| byTags | [String!] | Filter files by tags |
| byCategories | [String!] | Filter files by categories |
| byAccessLevels | [String!] | Filter files by access level (PUBLIC, HOLDERS, ADMIN) |
| byKinds | [String!] | Filter by result type |
Example - Basic Search:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "query SearchLabs($prompt: String!, $page: Int, $perPage: Int) { searchLabs(prompt: $prompt, page: $page, perPage: $perPage) { nodes { __typename ... on SearchLabsFileHit { entry { lab { oclId shortname } path file { contentType description tags } } } ... on SearchLabsAnnouncementHit { announcement { headline body } lab { shortname } } } totalCount pageInfo { hasNextPage currentPage totalPages } } }",
"variables": {
"prompt": "cancer research",
"page": 0,
"perPage": 10
}
}'Example - Filtered Search:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-H 'X-Service-Token: YOUR_SERVICE_TOKEN' \
-d '{
"query": "query SearchLabs($prompt: String!, $filters: SearchLabsFilters) { searchLabs(prompt: $prompt, filters: $filters) { nodes { __typename ... on SearchLabsFileHit { entry { path file { tags accessLevel } } } } totalCount } }",
"variables": {
"prompt": "experimental data",
"filters": {
"byAccessLevels": ["PUBLIC"],
"byTags": ["research", "validated"]
}
}
}'Understanding Results:
Search results are returned as a union type. Use the __typename field to determine result type:
- SearchLabsFileHit: File search result
- Access via:
entry.file - Contains: file metadata, tags, categories, download URL
- Access via:
- SearchLabsAnnouncementHit: Announcement search result
- Access via:
announcement - Contains: headline, body, lab reference, typed attachments (file objects)
- Access via:
JavaScript Example:
const searchResults = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.API_KEY,
"X-Service-Token": process.env.SERVICE_TOKEN,
},
body: JSON.stringify({
query: `query SearchLabs($prompt: String!) {
searchLabs(prompt: $prompt) {
nodes {
__typename
... on SearchLabsFileHit {
entry {
path
file { description tags }
}
}
... on SearchLabsAnnouncementHit {
announcement {
headline
attachments {
did
path
contentType
accessLevel
}
}
}
}
totalCount
}
}`,
variables: { prompt: "latest results" },
}),
});
const { nodes, totalCount } = (await searchResults.json()).data.searchLabs;
// Handle different result types
nodes.forEach((node) => {
if (node.__typename === "SearchLabsFileHit") {
console.log("File:", node.entry.path);
} else if (node.__typename === "SearchLabsAnnouncementHit") {
console.log("Announcement:", node.announcement.headline);
// NEW: Attachments are now full file objects
node.announcement.attachments.forEach((file) => {
console.log(" Attachment:", file.path, file.contentType);
});
}
});Here's a complete Node.js example demonstrating the full 3-step workflow:
#!/usr/bin/env node
const fs = require("fs");
const fetch = require("node-fetch");
async function uploadFileToLabs(filePath, oclId, serviceToken) {
const apiUrl = "https://production.graphql.api.molecule.xyz/graphql";
const fileBuffer = fs.readFileSync(filePath);
const filename = require("path").basename(filePath);
const fileSize = fs.statSync(filePath).size;
try {
// Step 1: Initiate upload
console.log("Step 1: Initiating file upload...");
const initiateResponse = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.API_KEY,
"X-Service-Token": serviceToken,
},
body: JSON.stringify({
query: `
mutation InitiateFileUpload($oclId: String!, $contentType: String!, $contentLength: Int!) {
initiateCreateOrUpdateFile(
oclId: $oclId
contentType: $contentType
contentLength: $contentLength
) {
uploadToken
uploadUrl
method
headers { key value }
isSuccess
error { message }
}
}
`,
variables: {
oclId,
contentType: "application/octet-stream",
contentLength: fileSize,
},
}),
});
const initiateResult = await initiateResponse.json();
if (!initiateResult.data?.initiateCreateOrUpdateFile?.isSuccess) {
throw new Error(
initiateResult.data?.initiateCreateOrUpdateFile?.error?.message ||
"Failed to initiate upload",
);
}
const { uploadToken, uploadUrl, headers } =
initiateResult.data.initiateCreateOrUpdateFile;
console.log("✅ Upload initiated");
// Step 2: Upload to presigned URL
console.log("Step 2: Uploading file to storage...");
const uploadHeaders = {};
headers?.forEach((h) => {
uploadHeaders[h.key] = h.value;
});
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
headers: uploadHeaders,
body: fileBuffer,
});
if (!uploadResponse.ok) {
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
}
console.log("✅ File uploaded to storage");
// Step 3: Finish upload
console.log("Step 3: Finalizing upload...");
const finishResponse = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": process.env.API_KEY,
"X-Service-Token": serviceToken,
},
body: JSON.stringify({
query: `
mutation FinishFileUpload(
$oclId: String!
$uploadToken: String!
$path: String!
$accessLevel: String!
$changeBy: String!
) {
finishCreateOrUpdateFile(
oclId: $oclId
uploadToken: $uploadToken
path: $path
accessLevel: $accessLevel
changeBy: $changeBy
) {
datasetId
isSuccess
message
error { message }
}
}
`,
variables: {
oclId,
uploadToken,
path: filename,
accessLevel: "PUBLIC",
changeBy: process.env.WALLET_ADDRESS,
},
}),
});
const finishResult = await finishResponse.json();
if (!finishResult.data?.finishCreateOrUpdateFile?.isSuccess) {
throw new Error(
finishResult.data?.finishCreateOrUpdateFile?.error?.message ||
"Failed to finish upload",
);
}
console.log("🎉 File upload completed successfully!");
console.log(
"Dataset ID:",
finishResult.data.finishCreateOrUpdateFile.datasetId,
);
return {
success: true,
datasetId: finishResult.data.finishCreateOrUpdateFile.datasetId,
};
} catch (error) {
console.error("❌ Upload failed:", error.message);
throw error;
}
}
// Usage
if (require.main === module) {
const filePath = process.argv[2];
const oclId = process.argv[3];
const serviceToken = process.env.SERVICE_TOKEN;
if (!filePath || !oclId || !serviceToken) {
console.error(
'Usage: SERVICE_TOKEN="token" WALLET_ADDRESS="0x..." node upload.js <file> <ocl-id>',
);
process.exit(1);
}
uploadFileToLabs(filePath, oclId, serviceToken);
}
module.exports = { uploadFileToLabs };Usage:
API_KEY="your-api-key" SERVICE_TOKEN="your-service-token" WALLET_ADDRESS="0x..." node upload.js data.pdf 0x0101000000000000000000000000000000000000000000000000000000000042Service tokens must be requested from the Molecule team (see Authentication section above).
Alternatively, a service can obtain a token self-service by proving control of its wallet — useful for autonomous agents, bots, and CI/CD pipelines that don't have a browser-based Privy session. This is a two-step flow: fetch the deterministic sign-in message, sign it with the service wallet, then exchange the signature for a token.
Step 1 — Get the sign-in message (getServiceSignInMessage):
query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
getServiceSignInMessage(
walletAddress: $walletAddress
serviceName: $serviceName
) {
message
}
}| Parameter | Type | Required | Description |
|---|---|---|---|
| walletAddress | String | Yes | Wallet address of the service (e.g. an agent's EOA) |
| serviceName | String | Yes | Name of the service requesting a token |
Public query — no authentication required.
Step 2 — Exchange the signature for a token (generateServiceToken):
Sign the returned message with the service wallet, then submit the signature:
mutation GenerateServiceToken(
$serviceName: String!
$walletAddress: String!
$messageSignature: String!
$expiresIn: String
) {
generateServiceToken(
serviceName: $serviceName
walletAddress: $walletAddress
messageSignature: $messageSignature
expiresIn: $expiresIn
) {
token
tokenId
serviceName
expiresAt
createdAt
isSuccess
message
}
}| Parameter | Type | Required | Description |
|---|---|---|---|
| serviceName | String | Yes | Name of the service the token is issued for |
| walletAddress | String | No* | Service wallet address (required together with messageSignature) |
| messageSignature | String | No* | Hex-encoded signature of the sign-in message (required with walletAddress) |
| expiresIn | String | No | Token lifetime (e.g. "30d", "720h") |
* walletAddress and messageSignature must be provided together for signature-based issuance. The returned token is the JWT to pass as X-Service-Token on subsequent requests.
You can extend your service token's expiration using the extendServiceToken mutation:
mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) {
extendServiceToken(tokenId: $tokenId, expiresIn: $expiresIn) {
token
tokenId
expiresAt
isSuccess
message
}
}Parameters:
| Parameter | Type | Description |
|---|---|---|
| tokenId | String | Token ID provided when token was generated |
| expiresIn | String | New duration (e.g., "30d", "720h", "90d") |
Example:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'X-Service-Token: YOUR_CURRENT_TOKEN' \
-d '{
"query": "mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) { extendServiceToken(tokenId: $tokenId, expiresIn: $expiresIn) { token tokenId expiresAt isSuccess message } }",
"variables": {
"tokenId": "your-token-id",
"expiresIn": "90d"
}
}'Important: Extension returns a new JWT token - update your stored token accordingly.
Revoke a service token immediately (e.g., if compromised):
mutation RevokeServiceToken($tokenId: String!) {
revokeServiceToken(tokenId: $tokenId) {
tokenId
isSuccess
message
revokedAt
}
}Example:
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
-H 'Content-Type: application/json' \
-H 'X-Service-Token: YOUR_CURRENT_TOKEN' \
-d '{
"query": "mutation RevokeServiceToken($tokenId: String!) { revokeServiceToken(tokenId: $tokenId) { tokenId isSuccess message revokedAt } }",
"variables": {
"tokenId": "your-token-id"
}
}'All API responses follow a consistent error format:
{
"data": {
"initiateCreateOrUpdateFile": {
"isSuccess": false,
"error": {
"message": "Error description",
"code": "ERROR_CODE",
"retryable": true
}
}
}
}| Status Code | Error | Description |
|---|---|---|
| 401 | Unauthorized | Missing or invalid service token |
| 403 | Forbidden | Service token does not have access to the specified lab |
| 400 | Bad Request | Invalid parameters (e.g., missing oclId, invalid contentType) |
| 404 | Not Found | Lab or dataroom not found |
| 413 | Payload Too Large | File exceeds size limits |
| 500 | Internal Server Error | Server error - check if retryable and try again |
"Missing service token" error:
- Ensure
X-Service-Tokenheader is included in requests - Verify token is not empty or malformed
"Service does not have access to lab" error:
- Verify your wallet address (linked to service token) has admin access to the lab/dataroom
- Check that the oclId format is correct: a 32-byte hex string with
0xprefix
"Token expired" error:
- Request a new token from the Molecule team, or
- Use
extendServiceTokenmutation to extend expiration
Upload to presigned URL fails:
- Ensure binary file upload (use
--data-binaryin curl) - Verify headers match those returned in Step 1
- Check that presigned URL hasn't expired (expires after ~15 minutes)
"File not found" error (updateFileMetadata, deleteDataRoomFile):
- Verify the file
ref(DID) orpathis correct - Check that the file exists in the specified dataroom
- Ensure you have access to the dataroom
"Invalid search filters" error (searchLabs):
- Verify filter values match expected types (arrays of strings)
- Check that access level values are: PUBLIC, HOLDERS, or ADMIN
- Ensure oclId format is correct if using byOclIds filter
- Default Limit: 5GB per lab/project
- Custom Limits: Can be increased upon request - contact the Molecule team
- Note: the Labs web app additionally caps individual uploads at 100 MB per file; API uploads are not subject to that app-side cap
- All file types are supported
- Common types: PDF, PNG, JPEG, CSV, JSON, ZIP, etc.
| Level | Description |
|---|---|
| PUBLIC | Visible to anyone with access to the project |
| HOLDERS | Visible only to IP Token (IPT) holders |
| ADMIN | Visible only to project administrators |
All three values are accepted by the API. Note that the Labs web app currently writes only PUBLIC (regular files) or ADMIN (confidential files) — HOLDERS is set via the API only.
Enhance file discoverability with optional metadata:
- description: Human-readable description of the file
- tags: Array of tags for categorization (e.g.,
["research", "q4-2024"]) - categories: Array of categories for organization (e.g.,
["data", "results"]) - contentText: Searchable text content for full-text search
For files requiring client-side encryption, obtain a data encryption key via the generateDataEncryptionKey mutation, encrypt locally, upload as normal, and include an encryptionMetadata object on finishCreateOrUpdateFile. The full end-to-end model — key wrapping, onchain access conditions, and condition-gated decryption — is documented on the Data Privacy & Access page.
generateDataEncryptionKey (no arguments) returns plaintextDEK, encryptedDek, and encryptionSystem. The client uses plaintextDEK to AES-256-GCM encrypt the file locally (Web Crypto SubtleCrypto), then wipes it from memory. The upload itself uses the standard initiateCreateOrUpdateFile → PUT → finishCreateOrUpdateFile flow, with the encrypted bytes uploaded to the presigned URL.
$encryptionMetadata: EncryptionMetadataInput{
"encryptionMetadata": {
"encryptionSystem": "<echo value returned by generateDataEncryptionKey>",
"encryptedDek": "BASE64_WRAPPED_DEK",
"iv": "BASE64_AES_GCM_IV",
"contentHash": "sha256-...",
"accessControlConditions": "[{...}]",
"encryptedBy": "0x1234567890123456789012345678901234567890",
"encryptedAt": "2026-01-15T10:30:00.000Z"
}
}encryptionSystem is backend-set — clients must echo the value returned by generateDataEncryptionKey rather than hardcode it. This keeps the roadmap rollover to BLS threshold key custody transparent to existing integrations.
accessControlConditions is a JSON-stringified array of EvmContractCondition predicates joined by BooleanCondition separators (and / or). The backend evaluates each predicate against live chain state at decrypt time via viem readContract, short-circuits booleans, and fails closed on RPC error. To gate decryption on LabNFT owner OR active Contributor OR active Viewer, OR AccessResolver.isAuthorizedSignerForTba(:userAddress, tba) against AccessResolver.hasRole(oclId, :userAddress, ROLE_VIEWER) — the role-hierarchy collapses Contributor + Viewer into one check on the canonical chain (Base).
The placeholder :userAddress in functionParams is substituted with the authenticated caller's wallet at evaluate time. The full EvmContractCondition JSON shape, the worked OR-composite example, and condition-evaluator semantics are documented on the Data Privacy & Access page.
Role grants are onchain transactions on the AccessResolver contract, not Labs API mutations. Lab owners (and active Contributors, for the Viewer slot) call grantRole(oclId, account, role, expiry, isAgent) / revokeRole(oclId, account) directly via viem / ethers / Safe. The Labs API only consumes role state at decrypt time through the accessControlConditions evaluator. See Roles & Permissions for the capability matrix, grant lifecycle (expiry, isAgent), and the AccessResolver reference for the onchain interface.
Legacy. Lit Protocol is retained read-only for files encrypted before the migration to Onchain-Verified Envelope Encryption. New uploads should use the metadata shape above. Files with the shape below continue to decrypt through the Lit SDK.
{
"encryptionMetadata": {
"dataToEncryptHash": "0xabc123...",
"accessControlConditions": "[{...}]",
"encryptedBy": "0x1234567890123456789012345678901234567890",
"encryptedAt": "2024-01-15T10:30:00.000Z",
"chain": "ethereum",
"litSdkVersion": "3.0.0",
"litNetwork": "datil-test",
"templateName": "standard-access-control",
"contractVersion": "1.0.0"
}
}When to Use Encryption:
- Sensitive research data requiring access control
- Compliance requirements for data protection
- Conditional access based on token ownership or lab role
- Never commit tokens to version control (add to
.gitignore) - Use environment variables to store tokens
- Rotate tokens regularly (quarterly recommended)
- Use secrets management systems in production (AWS Secrets Manager, HashiCorp Vault, etc.)
- Revoke immediately if a token is compromised
- Monitor your 5GB storage limit per project
- Organize files with meaningful names and metadata
- Use categories and tags for easy file discovery
- Clean up old or unnecessary files regularly
- Use descriptive tags:
["experiment-1", "2024-q4", "preliminary"] - Organize with categories:
["raw-data", "analysis", "results"] - Add descriptions: Help collaborators understand file contents
- Include searchable text (
contentText): Enables full-text search viasearchLabs - Update metadata as needed: Use
updateFileMetadatato refine tags and descriptions without re-uploading files
- Use
ADMINfor sensitive internal documents - Use
HOLDERSfor IPT holder-exclusive content - Use
PUBLICfor community-facing data - Review access levels regularly as your project evolves
- Use contentText: Populate
contentTextfield when uploading files to enable full-text search - Tag consistently: Use consistent tag names across files for better filtering
- Filter strategically: Combine filters (tags + access levels) to narrow search results
- Test search queries: Use
searchLabsto verify your files are discoverable
Return the active members of a lab (owner, contributors, viewers), sourced from the indexed ocl_user table. Expired grants are excluded.
Public query — only an API Key is required. The same data is also exposed on the public
Lab/LabRef.membersfield.
query ListLabMembers($oclId: String!) {
listLabMembers(oclId: $oclId) {
isSuccess
message
members {
walletAddress
role
source
expiry
isAgent
grantedAt
}
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
Member fields:
| Field | Type | Description |
|---|---|---|
| walletAddress | String | Lowercased wallet address of the member |
| role | LabMemberRole | Effective role: OWNER, CONTRIBUTOR, or VIEWER |
| source | LabMemberSource | Row that defines the membership: ONCHAIN_EVENT, MULTISIG_RESOLUTION, ACCESS_CONTRACT, or ACCESS_RESOLVER_EVENT |
| expiry | String | Unix-seconds expiry as a decimal string; null means the grant is permanent |
| isAgent | Boolean | True if the member is an agent identity (surfaced for UI; not used for authorization) |
| grantedAt | String | ISO-8601 timestamp the row was first persisted |
Return the valid file categories and their tags from the CMS. Use these when tagging or categorizing files. Public query — no authentication required.
query FileCategoriesAndTags {
fileCategoriesAndTags {
isSuccess
data {
name
tags
}
error {
message
code
retryable
}
}
}Each entry in data is a FileCategory with a name and its list of allowed tags.
Partial update of the LabNFT display metadata (name, description, image, externalUrl). Omitted fields are left unchanged; an explicit null clears a field.
Authorization: Restricted to the OCL admin (LabNFT owner + multisig signers).
mutation UpdateLabNftMetadata(
$oclId: String!
$input: UpdateLabNftMetadataInput!
) {
updateLabNftMetadata(oclId: $oclId, input: $input) {
isSuccess
oclId
message
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
| input | UpdateLabNftMetadataInput | Yes | Patch object (all fields optional) |
UpdateLabNftMetadataInput fields (all optional): name, description, image, externalUrl.
Generate a single-use presigned PUT URL to which the OCL admin uploads a LabNFT display image. contentType must be one of image/jpeg, image/png, image/webp, image/gif, or image/svg+xml. The public URL is patched onto the lab asynchronously by the image processor once the object lands in S3.
Authorization: Restricted to the OCL admin (LabNFT owner + multisig signers).
mutation GenerateLabImageUploadUrl($oclId: String!, $contentType: String!) {
generateLabImageUploadUrl(oclId: $oclId, contentType: $contentType) {
uploadUrl
key
expiresAt
isSuccess
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
| contentType | String | Yes | Image MIME type (image/jpeg, image/png, image/webp, image/gif, image/svg+xml) |
The legal-agreement flow is three operations: fetch the populated template + contentHash, sign it as an EIP-712 typed-data payload, then submit the signature. The backend regenerates and verifies the document server-side and stores the signed artifact in the lab's data room.
typeis aLegalAgreementTypeenum. Current value:ASSIGNMENT_AGREEMENT.
Return the populated agreement the given wallet is expected to sign, plus the contentHash for the EIP-712 envelope. Read-only.
query LegalAgreementTemplate(
$oclId: String!
$type: LegalAgreementType!
$walletAddress: String!
$signerName: String
$entity: String
$title: String
) {
legalAgreementTemplate(
oclId: $oclId
type: $type
walletAddress: $walletAddress
signerName: $signerName
entity: $entity
title: $title
) {
isSuccess
agreement
contentHash
templateVersion
agreementType
issuedAt
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
| type | LegalAgreementType | Yes | Agreement type (ASSIGNMENT_AGREEMENT) |
| walletAddress | String | Yes | Intended signer. On the user-auth path this must equal the authenticated wallet |
| signerName | String | No | Signer identity (natural person). Echoed VERBATIM into signLegalAgreement; covered by contentHash |
| entity | String | No | Signing entity, if the signer represents an organization |
| title | String | No | Signer title |
agreementis for display only — the client must NOT re-serialize or re-hash it; sign overcontentHashas given, and echoissuedAt,signerName,entity, andtitleback unchanged at sign time or the regenerated hash won't match.
Whether (and at which template versions) the agreement has been signed for the lab. Public — same surface as labs. Also available inline as Lab / LabRef.legalAgreementStatus.
query LegalAgreementStatus($oclId: String!, $type: LegalAgreementType!) {
legalAgreementStatus(oclId: $oclId, type: $type) {
isSuccess
signed
isCurrentVersionSigned
currentTemplateVersion
signedVersions {
templateVersion
path
signer
signedAt
contentHash
issuedAt
signature
}
error {
message
code
retryable
}
}
}Check isSuccess before reading the other fields. signed is true if any version has been signed; isCurrentVersionSigned reflects the current template version (the FE routes the signing flow on this). The signedVersions enrichment is fetched lazily, only when its sub-fields are selected.
Record acceptance of a legal agreement. The backend regenerates the document from (type, oclId, walletAddress, issuedAt), verifies the EIP-712 signature and LabNFT ownership, embeds the signature into a self-verifying envelope, and uploads it to the lab's data room. Rejects if the current template version is already signed.
mutation SignLegalAgreement($input: SignLegalAgreementInput!) {
signLegalAgreement(input: $input) {
isSuccess
oclId
path
contentHash
templateVersion
datasetId
version
message
error {
message
code
retryable
}
}
}SignLegalAgreementInput fields:
| Field | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
| type | LegalAgreementType | Yes | Agreement type (ASSIGNMENT_AGREEMENT) |
| walletAddress | String | Yes | Signer's wallet. Must equal the EIP-712 signer, the lab's current LabNFT owner, and (user path) the authenticated wallet |
| signature | String | Yes | EIP-712 signature (0x…) over the LegalAgreementAcceptance typed data |
| issuedAt | AWSTimestamp | Yes | Echoed VERBATIM from legalAgreementTemplate — used to regenerate the document |
| signerName | String | No | Echoed VERBATIM from the template call (covered by contentHash) |
| entity | String | No | Echoed verbatim; see signerName |
| title | String | No | Echoed verbatim; see signerName |
Public read-only snapshot of DID-linking state for an OCL. DID-linking runs automatically in the background after createLab; this query is for diagnostic and support visibility. No authentication required.
query GetDidLinkStatus($oclId: String!) {
getDidLinkStatus(oclId: $oclId) {
isSuccess
message
didLinkStatus {
oclId
status
userOpHash
txHash
accountDid
dataRoomDid
linkedDidCount
attempts
updatedAt
}
error {
message
code
retryable
}
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | Yes | Canonical 32-byte oclId of the lab |
status is a DidLinkingStatus: PENDING, SUBMITTED, LINKED, or FAILED (null before the first linking attempt). linkedDidCount reflects the number of active onchain DID links observed by the event indexer.
Return the onchain event feed for an OCL or a wallet. Exactly one of oclId / wallet must be supplied. Paginate with a cursor of the form "<block_number>:<log_index>" — pass the last row's id to fetch the next page.
query OnChainActivity(
$oclId: String
$wallet: String
$limit: Int
$cursor: String
) {
onChainActivity(
oclId: $oclId
wallet: $wallet
limit: $limit
cursor: $cursor
) {
id
chainId
contractAddress
contractName
eventName
blockNumber
blockTimestamp
txHash
logIndex
args
}
}Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| oclId | String | No* | Canonical 32-byte oclId of the lab |
| wallet | String | No* | Wallet address to filter events by |
| limit | Int | No | Max rows to return (default: 50) |
| cursor | String | No | Pagination cursor "<block_number>:<log_index>" (last row's id) |
* Provide exactly one of oclId or wallet. contractName is one of accessresolver, ocl, ipnft, ipt, or bio-agent. args is a JSON object of the decoded event arguments (BigInts as decimal strings, addresses lowercased).
Generate a standalone data encryption key (DEK) for client-side encryption outside the file-upload flow. Returns both the plaintext DEK (used to encrypt data locally, then wiped) and the KMS-encrypted DEK (stored alongside the ciphertext). Requires authentication (Privy user or service token). See Advanced: Encrypted File Upload for the file-upload encryption path.
mutation GenerateDataEncryptionKey {
generateDataEncryptionKey {
isSuccess
plaintextDEK
encryptedDek
encryptionSystem
error {
message
code
retryable
}
}
}| Field | Type | Description |
|---|---|---|
| plaintextDEK | String | Base64-encoded plaintext DEK (only present on success) |
| encryptedDek | String | Base64-encoded KMS-encrypted DEK (only present on success) |
| encryptionSystem | String | Encryption system used (always "kms") |
The legacy *V2 operations and the pre-OCL naming have been removed. The current API is oclId-based. If you are migrating from an older integration, use the current names below.
| Legacy (removed) | Current | Notes |
|---|---|---|
projects / projectsV2 |
labs |
Same paginated shape; adds an optional role filter |
projectWithDataRoomAndFiles / …V2 |
labWithDataRoomAndFiles |
Look up by oclId (or shortname) instead of ipnftUid |
dataRoomFileV2 |
dataRoomFile |
Identified by oclId + path |
projectActivity / projectActivityV2 |
labActivity |
— |
activitiesV2 |
activities |
— |
projectAnnouncementsV2 / projectAnnouncementV2 |
labActivity / activities |
Removed — use the filter: ANNOUNCEMENT argument |
| Legacy (removed) | Current | Notes |
|---|---|---|
createProject |
createLab |
Now takes input: { oclId } instead of ipnftSymbol / ipnftTokenId |
initiateCreateOrUpdateFileV2 |
initiateCreateOrUpdateFile |
— |
finishCreateOrUpdateFileV2 |
finishCreateOrUpdateFile |
— |
createAnnouncementV2 |
createAnnouncement |
Takes oclId; the legacy moleculeAccessLevel param was removed |
updateFileMetadataV2 |
updateFileMetadata |
— |
deleteDataRoomFileV2 |
deleteDataRoomFile |
— |
Top-level identifiers on Lab / LabRef were renamed away from the legacy IP-NFT naming:
| Legacy field | Current field | Notes |
|---|---|---|
ipnftUid |
oclId |
Now a 32-byte hex string (0x…), not <address>_<tokenId> |
ipnftSymbol |
shortname |
Human-readable slug derived from the lab name |
ipnftAddress |
labAccountAddress |
ERC-6551 token-bound account address |
ipnftTokenId |
labNftTokenId |
LabNFT tokenId (decimal string) |
The linked legacy IP-NFT (for labs migrated from one) is still available as the nested
ipnftobject onLab/LabRef;LabRefadditionally exposes a scalaripnftIdfield.
If you encounter any issues or have questions about the Programmatic File Upload API:
- Check this documentation and troubleshooting section
- Review the complete example for implementation guidance
- Join our Discord community for support
- Contact the Molecule Labs development team directly
Last updated: July 2026