Give an AI SDK agent access to S3 or R2 with approval-gated storage tools
An AI SDK 7 agent lists, reads, and proposes changes to one user's files in S3 or R2, and every write waits until the user approves it in the chat.
createFileTools from files-sdk/ai-sdk turns a Files instance into eight AI SDK tools. The tools have no scope of their own, so build that instance per request from the session, with the user’s prefix, and the agent can reach that user’s keys and nothing else. The four write tools need approval by default: the agent pauses, the chat shows the exact input, and nothing touches the bucket until the user says yes. A declined write never runs.
The catch is that in a useChat app the client sends the conversation back on every turn, approvals included. Without experimental_toolApprovalSecret, a client can approve an input other than the one it was shown, and that input runs. Sign approvals closes the gap.
Before you start
- An R2 bucket and API token (see the R2 adapter), or an S3 bucket.
- A Next.js App Router app with an auth library that resolves the signed-in user on the server.
- A Vercel AI Gateway API key and a model that supports tool calling. An AI SDK provider instance works in place of the Gateway model ID.
- Written against files-sdk 3.0, AI SDK 7.0 (
ai7.0.99), Next.js 16.4, React 19.3, and Zod 4.6.
npm install files-sdk ai @ai-sdk/react zodpnpm add files-sdk ai @ai-sdk/react zodyarn add files-sdk ai @ai-sdk/react zodbun add files-sdk ai @ai-sdk/react zodnub add files-sdk ai @ai-sdk/react zodaube add files-sdk ai @ai-sdk/react zodR2_ACCOUNT_ID=your-account-id
R2_ACCESS_KEY_ID=your-access-key-id
R2_SECRET_ACCESS_KEY=your-secret-access-key
AI_GATEWAY_API_KEY=your-gateway-key
# A Gateway model ID ("creator/model") that supports tool calling
AI_MODEL=creator/model
# Signs tool approvals. Generate with: openssl rand -base64 32
TOOL_APPROVAL_SECRET=a-long-random-string
What the agent gets
| Tool | Calls | Needs approval |
|---|---|---|
listFiles |
files.list({ prefix, cursor, limit }) |
No |
getFileMetadata |
files.head(key) |
No |
downloadFile |
files.head(key), then files.download(key) |
No |
getFileUrl |
files.url(key, { expiresIn, responseContentDisposition }) |
No |
uploadFile |
files.upload(key, content, options) |
Yes |
deleteFile |
files.delete(key) |
Yes |
copyFile |
files.copy(from, to) |
Yes |
signUploadUrl |
files.signedUploadUrl(key, options) |
Yes |
downloadFile returns UTF-8 text, or base64 with binary: true. Its maxBytes defaults to 1 MiB and its input schema stops at 10 MiB; the tool checks head() before transferring and enforces the cap again on the bytes it reads. uploadFile replaces whatever is at the key. signUploadUrl is gated even though no bytes move, because the URL grants upload permission until it expires. There’s no move, search, or exists tool. The Vercel AI SDK reference has every option.
Scope the tools to the signed-in user
import { createFiles } from "files-sdk";
import { r2 } from "files-sdk/r2";
import { signedUrlPolicy } from "files-sdk/signed-url-policy";
const adapter = r2({ bucket: "uploads", client: "fetch" });
/** Everything the agent can reach: one user's keys, and nothing else. */
export function filesForUser(userId: string) {
return createFiles({
adapter,
prefix: `users/${userId}`,
// getFileUrl passes the model's expiresIn through; cap it here.
plugins: [signedUrlPolicy({ maxExpiresIn: 300 })],
});
}
The tools take no bucket or prefix option. Everything comes from the Files you hand them, so with prefix: "users/42":
- Every key the model sends resolves under
users/42/. A key with.or..segments throwskey must not contain . or .. path segmentsbefore any request. - The
prefixthe model passes tolistFilesis appended to the instance prefix, so it can narrow a listing but not widen it, and results come back withusers/42/stripped. See Prefixes. signedUrlPolicyclamps theexpiresInthe model picks forgetFileUrlto 300 seconds and forcesContent-Disposition: attachment. Without it, the model’s number goes straight tofiles.url().
Run against the memory adapter, listFiles with prefix: "../bob/" returned no items, getFileMetadata on ../bob/reports/secret.csv threw the error above, and a getFileUrl call asking for 604800 seconds reached the adapter as 300 seconds with attachment.
On S3, swap r2(...) for s3({ bucket: "uploads" }) from files-sdk/s3, which needs @aws-sdk/client-s3 (S3 adapter). Use your auth library’s stable user ID for the prefix, never a value from the request. Isolate each user’s files in a multi-tenant application covers team and tenant layouts.
Build the agent
import { isStepCount, ToolLoopAgent, type InferAgentUIMessage } from "ai";
import type { Files } from "files-sdk";
import { createFileTools } from "files-sdk/ai-sdk";
export function createStorageAgent(files: Files) {
const model = process.env.AI_MODEL; // an AI Gateway ID: "creator/model"
const secret = process.env.TOOL_APPROVAL_SECRET;
if (!model || !secret) {
throw new Error("Set AI_MODEL and TOOL_APPROVAL_SECRET");
}
return new ToolLoopAgent({
model,
instructions: [
"You work with the signed-in user's files.",
"When a tool execution is not approved, do not retry it.",
].join("\n"),
tools: createFileTools({ files }),
stopWhen: isStepCount(10),
// Binds each approval to the exact tool call and input the user saw.
experimental_toolApprovalSecret: secret,
});
}
export type StorageAgentMessage = InferAgentUIMessage<
ReturnType<typeof createStorageAgent>
>;
The agent is built per request because its tools close over that request’s Files. Constructing one makes no network calls.
ToolLoopAgent runs up to 20 steps by default; listing, reading, and writing fit in far fewer, so isStepCount(10) caps the loop lower. The instruction not to retry declined tools comes from the AI SDK’s approval docs, which recommend it to stop repeated requests for the same action.
Serve it from a route
import { createAgentUIStreamResponse } from "ai";
import { createStorageAgent } from "@/lib/agent";
import { getSession } from "@/lib/auth";
import { filesForUser } from "@/lib/files";
export const maxDuration = 60;
export async function POST(req: Request) {
const session = await getSession(req.headers);
if (!session) {
return Response.json({ error: "Sign in" }, { status: 401 });
}
const { messages } = await req.json();
// Scope comes from the session, never from the request body.
const agent = createStorageAgent(filesForUser(session.user.id));
return createAgentUIStreamResponse({ agent, uiMessages: messages });
}
getSession stands in for your auth library (Auth.js, Clerk, Better Auth, or your own cookie check) and is assumed to take request headers and return { user: { id: string } } or null. createAgentUIStreamResponse validates the incoming messages against the agent’s tools, converts them to model messages, and streams the result.
Answer approvals in the chat
"use client";
import { useChat } from "@ai-sdk/react";
import {
DefaultChatTransport,
getToolName,
isToolUIPart,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from "ai";
import { useState, type FormEvent } from "react";
import type { StorageAgentMessage } from "@/lib/agent";
export function AgentChat() {
const [input, setInput] = useState("");
const { messages, sendMessage, addToolApprovalResponse, status } =
useChat<StorageAgentMessage>({
transport: new DefaultChatTransport({ api: "/api/agent" }),
// Resubmit once every approval in the last message has an answer.
sendAutomaticallyWhen:
lastAssistantMessageIsCompleteWithApprovalResponses,
});
function onSubmit(event: FormEvent) {
event.preventDefault();
sendMessage({ text: input });
setInput("");
}
return (
<div>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) => {
if (part.type === "text") {
return <p key={index}>{part.text}</p>;
}
if (!isToolUIPart(part)) {
return null;
}
const name = getToolName(part);
switch (part.state) {
case "approval-requested":
return (
<div key={part.toolCallId}>
<p>Allow {name}?</p>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: true,
})
}
type="button"
>
Approve
</button>
<button
onClick={() =>
addToolApprovalResponse({
id: part.approval.id,
approved: false,
})
}
type="button"
>
Deny
</button>
</div>
);
case "output-denied":
return <p key={part.toolCallId}>Declined: {name}</p>;
case "output-error":
return (
<p key={part.toolCallId}>
{name} failed: {part.errorText}
</p>
);
case "output-available":
return <p key={part.toolCallId}>Done: {name}</p>;
default:
return <p key={part.toolCallId}>Running: {name}</p>;
}
})}
</div>
))}
<form onSubmit={onSubmit}>
<input onChange={(e) => setInput(e.target.value)} value={input} />
<button disabled={status !== "ready"} type="submit">
Send
</button>
</form>
</div>
);
}
Show the full input in the approval prompt. For uploadFile that’s the key and the content the model wants to write, and it’s what the user is agreeing to.
A write moves through these message parts, as the AI SDK 7 approval docs define them:
- The model calls a write tool. Read tools run without asking. For
uploadFile, the AI SDK doesn’t callexecute; it records an approval request and the loop stops. The UI receives atool-uploadFilepart withstate: "approval-requested", the parsedinput, andapproval.id(plusapproval.signaturewhen a secret is set). - The user answers.
addToolApprovalResponse({ id, approved })moves the part toapproval-responded. Once every request in the last message has an answer,lastAssistantMessageIsCompleteWithApprovalResponsesreturnstrueanduseChatresubmits. - The server acts on the answer.
convertToModelMessagesturns that part into an assistanttool-callandtool-approval-request, followed by a tool message carrying thetool-approval-response. If it was approved, the AI SDK checks the input against the tool’s schema, runsexecute, and the part becomesoutput-available. If it was declined,executenever runs: the model gets a tool result of typeexecution-denied, the part becomesoutput-denied, and the model continues in text.
This route and agent were run locally against the memory adapter, with a scripted MockLanguageModelV4 from ai/test standing in for the model. It calls listFiles, then downloadFile, then uploadFile; it’s a test double, not a model transcript. After a denial, the uploadFile result the model received was {"type":"execution-denied"} and the bucket held the same single object as before. After an approval, the new key was in the bucket.
Sign approvals
In the default useChat setup the client sends the whole conversation each turn, and the server rebuilds tool calls and approvals from it. The AI SDK re-checks the input against the tool’s schema, but on its own it can’t tell whether the input that comes back is the input the user saw. experimental_toolApprovalSecret HMAC-signs each approval request over the tool name, the tool call ID, and the input, and rejects an approval whose signature doesn’t match (security considerations).
The local run tested this by approving the uploadFile call and then changing its key before resubmitting. With no secret set, the changed key was written. With the secret set, the server threw AI_InvalidToolApprovalSignatureError (invalid signature), the client received an error chunk, and nothing was written.
Every instance that serves the route needs the same secret, because one instance can issue the approval and another verify it. Keep it server-side. The option is prefixed experimental_, so check the AI SDK release notes when you upgrade.
Keep the approval defaults in force
The write tools set needsApproval: true. AI SDK 7 deprecates that tool property in favor of a toolApproval setting on the agent or call, but still honors it as a fallback. How the two combine in ai 7.0.99:
toolApproval you pass |
What the file tools do |
|---|---|
| None | needsApproval applies, so writes wait for approval. |
| An object naming some tools | Named tools follow your setting. The rest fall back to needsApproval. |
| A function | It decides for every tool, and needsApproval is ignored. Returning undefined runs the tool without approval. |
To relax one write on purpose, use requireApproval. Write tools you leave out stay gated:
const files = filesForUser(session.user.id);
const tools = createFileTools({
files,
requireApproval: { uploadFile: false },
});
An ungated uploadFile can overwrite any key in scope. versioning() keeps the previous version when that happens.
Read-only agents
For an agent that only browses and summarizes, remove writes in two places:
const files = filesForUser(session.user.id).readonly();
const tools = createFileTools({ files, readOnly: true });
readOnly: true drops the four write tools, so the model never sees them. readonly() makes every write on the instance throw Cannot call upload() on a read-only Files instance. (code ReadOnly), which still holds if someone later adds a write tool by hand. Read-only lists what stays allowed.
OpenAI and Claude agent SDKs
The same eight tools ship for two other agent SDKs, with the same defaults. Build them from the same per-request filesForUser().
files-sdk/openai:createResponsesFileTools({ files })returns{ definitions, execute, needsApproval }for the Responses API.execute(call, { approved: true })runs a gated write; withoutapproved, the output is anapprovalRequirederror and nothing runs.createAgentsFileTools({ files })returns a record fornew Agent({ tools: Object.values(tools) }), and gated writes surface as Agents SDK interruptions. See OpenAI.files-sdk/claude:createClaudeFileTools({ files })returns{ mcpServers, allowedTools, canUseTool, needsApproval, server, serverName }for the Claude Agent SDK. Tools are namedmcp__files__<tool>. Gated writes are left out ofallowedTools, so each call reachescanUseTool; the bundled one denies them, along with any tool not on its server. See Claude.
Limits
- File contents leave your server. Whatever
downloadFilereturns goes to the model provider, and in this setup back to the browser, which resends it every turn. Persisting the conversation on the server, as the attachments guide does, stops the browser round trip. - Large and binary files are a poor fit.
downloadFilestops at 10 MiB, and base64 content uses up context quickly. Let the agent hand the user agetFileUrllink instead. - Instructions aren’t enforcement. The prefix and the approval gate are what limit the agent. A system prompt asking it to stay in a folder isn’t.
- R2 can’t cap
signUploadUrl. If the model passesmaxSize, the R2 adapter throwsr2: `maxSize` is not supported., and an approved URL without it accepts any single-part size.
Troubleshooting
AI_InvalidToolApprovalSignatureError with invalid signature. The tool name, call ID, or input changed after the approval request was signed, or two instances use different TOOL_APPROVAL_SECRET values. With missing signature, the approval request was issued without a secret, for example by an instance that didn’t have it set.
Writes run without an approval prompt. Something overrides needsApproval: a toolApproval function returning undefined, a toolApproval entry for that tool, or requireApproval: false.
The agent reports File "…" is … bytes which exceeds the maxBytes limit of 1048576. That’s downloadFile’s default cap, returned to the model as a tool error. The model can retry with a larger maxBytes, up to 10 MiB.
The chat shows An error occurred. createAgentUIStreamResponse hides server error details from the client by default. The real error is in your server logs.