Views run in a sandboxed iframe, cut off from the conversation and the user’s disk. Each host opens its own door for files, and the two work differently: ChatGPT keeps a file store your tools and views read and write; Claude only lets a view save a file to the user’s device. What you can build depends on the host.
ChatGPT manages files for you: it stores each one and hands your code a reference to it, never the bytes. References survive across tool calls, so files move through the app in both directions, into a tool and back out.
A tool can take a file the user added to the conversation. The model only passes files already there, so the user adds one first, from their device or library; the handler receives a reference and fetches the contents.
server.ts
import { FileRef, McpServer } from "skybridge/server";import { z } from "zod";const server = new McpServer({ name: "expenses", version: "0.0.1" }) .registerTool( { name: "scan-receipt", description: "Extract line items from a receipt.", inputSchema: { receipt: FileRef }, outputSchema: { summary: FileRef }, _meta: { "openai/fileParams": ["receipt"] }, }, async ({ receipt }) => { const bytes = await fetch(receipt.download_url).then((r) => r.blob()); return { structuredContent: { summary: await summarize(bytes) } }; }, );
Each file input is a FileRef listed in _meta["openai/fileParams"] (top-level fields only), which tells ChatGPT to route the attachment to it. The host fills download_url, so scan-receipt reads the bytes straight from receipt.download_url.
A tool can also hand a file back. The host surfaces it in the conversation, so the user can download it and the model can see it.
server.ts
import { FileRef, McpServer } from "skybridge/server";import { z } from "zod";const server = new McpServer({ name: "expenses", version: "0.0.1" }) .registerTool( { name: "scan-receipt", description: "Extract line items from a receipt.", inputSchema: { receipt: FileRef }, outputSchema: { summary: FileRef }, _meta: { "openai/fileParams": ["receipt"] }, }, async ({ receipt }) => { const bytes = await fetch(receipt.download_url).then((r) => r.blob()); return { structuredContent: { summary: await summarize(bytes) } }; }, );
scan-receipt declares summary as a FileRef in its outputSchema and returns it in structuredContent; summarize produces the ref: a file_id and a download_url the host uses to fetch and cache the file.
The viewcan run the whole exchange itself: pick a file, hand it to a tool, and resolve what comes back, no model in the loop. Here a receipt scanner lets the user pick a receipt and get a summary in return, built up in three steps:
upload takes a file from the device and selectFiles opens the user’s library. Both return a FileMetadata, a fileId with no bytes attached, which we hold in state.
scan-receipt returns its own FileRef on data.structuredContent. Resolve it from summary.file_id when the user asks: a download_url is a temporary cache URL that expires; the file_id is the durable handle. Keep the file_id (in state, or in the model’s context) and regenerate a URL on demand.
views/receipt-scanner.tsx
import { useState } from "react";import { type FileMetadata, useFiles } from "skybridge/web";import { useCallTool } from "../helpers.js"; // generated, type-safe from server schemaexport default function ReceiptScanner() { const { upload, selectFiles, getDownloadUrl } = useFiles(); const { data, callTool } = useCallTool("scan-receipt"); const [file, setFile] = useState<FileMetadata>(); /* fromDevice, fromLibrary and scan as above */ const downloadSummary = async () => { const summary = data?.structuredContent?.summary; if (!summary) { return; } const { downloadUrl } = await getDownloadUrl({ fileId: summary.file_id }); window.open(downloadUrl, "_blank"); }; return ( <div> {/* pickers and Scan button as above */} {data?.structuredContent && ( <button onClick={downloadSummary}>Download summary</button> )} </div> );}
useFiles is ChatGPT only. It won’t work in other hosts.
Claude has no file store. The one operation is egress: the view hands the host content, and the host writes it to the user’s disk after a confirmation prompt.
A view can save a file to the user’s device, whether content it built itself or a file returned by an earlier tool call. The host shows a confirmation, then writes it to disk.
ReceiptSummary builds a CSV from its line items, then hands download a single resource: text carries the content, mimeType types it, and the uri’s last segment sets the suggested filename, here receipt.csv. A resource can instead carry a base64 blob, for bytes the view rendered or a file an earlier tool returned. The call fires from the Export button, not on mount: the host rejects downloads the user didn’t trigger.
Claude doesn’t support resource_link as a content type.