Back to blog

How to Manage AI Files with ERC-721

How to Manage AI Files with ERC-721

Elise Jones

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:

  1. An ERC-721 NFT contract
  2. 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)
  3. A private AI file stored on Pinata’s Private IPFS
  4. 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.

Subscribe to paid plan image

Share this post:

Stay up to date

Join our newsletter for the latest stories & product updates from the Pinata community.