Back to blog
How to Manage AI Files with ERC-721
AI tools like OpenClaw are transforming how we automate workflows. OpenClaw works with everything from Slack, iMessage, and Telegram to GitHub, Gmail, and Notion, with a lightweight setup.

Scary and kind of thrilling.
But once you start using AI tools for real work, like sending customer emails or booking travel, they need reliable access to rich context. Traditionally, that context lives in databases, but like LlamaIndex recently pointed out, a shift is happening where agents are increasingly treating files as the core interface for context, history, and skills. If files are central to AI, controlling who can access files becomes critical. ERC-721 + Pinata’s Private IPFS lets you attach private files to an NFT and use that NFT as access control for an agent like OpenClaw.
In this blog, we’ll break down how this works in a way that’s friendly for non-crypto-native or non-infra-native (hi, same, welcome), and wrap up with an end-to-end tutorial with deployable code.
Quick definitions
ERC-721
ERC-721 is the “open standard that describes how to build non-fungible or unique tokens on the Ethereum blockchain”. It’s essentially the shared rules that make an NFT unique, ownable, and transferable. This matters because if an NFT is ownable + transferable, it can represent more than art or pictures of cool stuff. An NFT can represent membership, credentials, or access rights.
Private IPFS
Public IPFS is what most people think of when they hear "IPFS”. If you have a CID (content identifier), you can retrieve the content. This works well until the content is sensitive, like PII. Exactly the type of content you might need to share with agents to GSD (get stuff done).
Pinata offers Private IPFS. This enables you to offer public content on Public IPFS and protect private content on Private IPFS, where it’s only retrievable by authorized requesters via access controls.
What this means for AI
LlamaIndex describes this trend: agents interact with unstructured context through files and those files become the interface for how agents read, store, and retrieve info. Instead of building custom systems for memory, context, and skills, we’re enabling agents to run on files. File access is everything.
OpenClaw is a personal automation agent that can take actions across chat apps and tools, and it’s exactly the kind of system that can benefit from file-native context. If you’re using an agent like OpenClaw, you want it to pull the right documents at the right time without needed blanket access to everything. Most importantly, you don’t want sensitive content publicly available.
How do we give an agent secure access to private files?
Solution
Private IPFS + ERC-721 is the missing piece. An ERC-721 NFT can act as ownership-based access. If an agent holds the NFT, it can retrieve the private files attached to it or selectively create presigned URLs for temporary access to that content. It can also transfer access by transferring the NFT. If access transfer is not desired, consider using soulbound NFTs.
File access tied to NFT ownership = agent that can prove ownership (or act on behalf of the owner)
Tutorial (ERC-721 + Pinata’s Private IPFS + agent)
How to use an ERC-721 NFT as an access key for private AI files stored on Pinata’s Private IPFS using an agent
This will create:
- An ERC-721 NFT contract
- NFT metadata stored publicly on Pinata IPFS (see Metadata is Valuable. Here’s How to Monetize It for why you might want private metadata. This would require your server needing to generate a private signed URL for the metadata first)
- A private AI file stored on Pinata’s Private IPFS
- A backend API endpoint that:
- verifies NFT ownership (ownerOf)
- reads the NFT metadata (tokenURI)
- finds the private file CID
- generates a signed URL for temporary access
No permissions database needed.
Deploy an ERC-721 NFT contract
Minimal Contract: AccessNFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract AccessNFT is ERC721URIStorage, Ownable {
uint256 public nextTokenId = 1;
constructor() ERC721("AI File Access", "AIFA") Ownable(msg.sender) {}
function mint(address to, string calldata tokenUri)
external
onlyOwner
returns (uint256)
{
uint256 tokenId = nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, tokenUri);
return tokenId;
}
}Each NFT stores a tokenURI pointing to public metadata JSON. This is the metadata you want to be publicly available that provides information about the private file without revealing its contents.
Pinata’s private NFT flow depends on fetching this metadata before generating a signed URL
Upload a private AI file to Pinata’s Private IPFS
Uses the Pinata IPFS SDK
import { PinataSDK } from "pinata";
const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT,
});
async function uploadPrivateFile() {
const file = new Blob(["secret agent context"], { type: "text/plain" });
const privateUpload = await pinata.upload.private.file(file);
console.log("Private File CID:", result.cid);
}
uploadPrivateFile();Save the CID for your private AI file
Create NFT metadata that references the private CID
Pinata’s private NFT flow stores the private CID inside metadata under a custom field like file
{
"name": "Agent Context Pack",
"description": "Private AI files gated by ERC-721 ownership",
"file": "YOUR_PRIVATE_FILE_CID"
}
Upload metadata publicly to IPFS
async function uploadMetadata() {
const metadata = new Blob(
[
JSON.stringify(
{
name: "Agent Context Pack",
description: "Private AI files gated by ERC-721 ownership",
file: "YOUR_PRIVATE_FILE_CID"
},
null,
2
),
],
{ type: "application/json" }
);
const result = await pinata.upload.file(metadata);
console.log("Metadata CID:", result.cid);
}
uploadMetadata();
Private file CID is stored privately and metadata CID is stored publicly
Your NFT’s tokenURI should point to:
ipfs://METADATA_CID
Build the generic agent access endpoint
An agent that owns a wallet that owns the NFT and can create a signature can call an endpoint to request access. For the purposes of this tutorial, we’re creating a simple Express.js endpoint, but you can choose to implement an API in whatever framework you choose.
Endpoint A: return a signed URL for the private AI file
A signed URL is a temporary permissioned link. Pinata uses signed URLs to deliver private content securely.
import "dotenv/config";
import express from "express";
import cors from "cors";
import { PinataSDK } from "pinata";
import { createPublicClient, http, recoverTypedDataAddress } from "viem";
import contractAbi from "./AccessNFTv0.json" with { type: "json" };
const app = express();
app.use(cors());
app.use(express.json());
const pinata = new PinataSDK({
pinataJwt: process.env.PINATA_JWT,
pinataGateway: process.env.PINATA_GATEWAY,
});
const publicClient = createPublicClient({
transport: http(process.env.RPC_URL),
});
const chainId = Number(process.env.CHAIN_ID || 1);
const TIMESTAMP_WINDOW_MS = 2 * 60 * 1000;
const types = {
Access: [
{ name: "action", type: "string" },
{ name: "tokenId", type: "uint256" },
{ name: "timestamp", type: "uint256" },
],
};
function getDomain(chainId) {
return { name: "Pinata Private IPFS", version: "1", chainId };
}
function toGatewayUrl(tokenUri) {
return tokenUri.replace("ipfs://", `https://${process.env.PINATA_GATEWAY}/ipfs/`);
}
async function fetchMetadata(tokenUri) {
const res = await fetch(toGatewayUrl(tokenUri));
return res.json();
}
async function verifySignature(signature, message, expectedType, chainId) {
const now = Date.now();
const messageTimestamp = Number(message.timestamp) * 1000;
if (!Number.isFinite(messageTimestamp)) {
throw new Error("Invalid or missing timestamp");
}
if (Math.abs(now - messageTimestamp) > TIMESTAMP_WINDOW_MS) {
throw new Error("Signature expired");
}
if (message.action !== expectedType.toLowerCase()) {
throw new Error("Invalid action");
}
return recoverTypedDataAddress({
domain: getDomain(chainId),
types,
primaryType: expectedType,
message,
signature,
});
}
app.post("/v0/nft-files/signed-url", async (req, res) => {
try {
const { tokenId, signature, message } = req.body;
if (tokenId === undefined || !signature || !message) {
return res.status(400).json({ error: "Missing tokenId, signature, or message" });
}
if (BigInt(message.tokenId) !== BigInt(tokenId)) {
return res.status(400).json({ error: "TokenId mismatch" });
}
const signerAddress = await verifySignature(signature, message, "Access", chainId);
const owner = await publicClient.readContract({
address: process.env.V0_CONTRACT_ADDRESS,
abi: contractAbi,
functionName: "ownerOf",
args: [BigInt(tokenId)],
});
if (owner.toLowerCase() !== signerAddress.toLowerCase()) {
return res.status(401).json({ error: "Not authorized" });
}
const tokenUri = await publicClient.readContract({
address: process.env.V0_CONTRACT_ADDRESS,
abi: contractAbi,
functionName: "tokenURI",
args: [BigInt(tokenId)],
});
const metadata = await fetchMetadata(tokenUri);
const privateCid = metadata.file;
const signedUrl = await pinata.gateways.private.createAccessLink({
cid: privateCid,
expires: 180,
});
return res.json({
tokenId,
privateCid,
signedUrl,
expiresInSeconds: 180,
});
} catch (error) {
return res.status(401).json({ error: error.message });
}
});
app.listen(8788, () =>
console.log("V0 Legacy API running on <http://localhost:8788>")
);
Any agent can request file access
Request with a EIP712 signature:
curl -X POST <http://localhost:8788/v0/nft-files/signed-url> \\
-H "Content-Type: application/json" \\
-d '{
"tokenId": 1,
"message": {
"action": "access",
"tokenId": "1",
"timestamp": 1706900000
},
"signature": "0xEIP712_SIGNATURE"
}'
Response:
{
"tokenId": 1,
"privateCid": "bafy...",
"signedUrl": "https://....",
"expiresInSeconds": 180
}
Agent can now fetch the private file using the signed URL. If you don’t want agents to touch the signed URLs directly, you can also use /fetch endpoint.
To recap, using this architecture:
- Pinata’s Private IPFS keeps the files private
- NFTs represent portable permissions
- Signed URLs deliver files securely
- If an agent holds the NFT, it can retrieve the private files attached to it
Now that you have an NFT with private files, you can use the NFT as access controls for agents.