Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 (ai 7.0.99), Next.js 16.4, React 19.3, and Zod 4.6.
npm install files-sdk ai @ai-sdk/react zod
pnpm add files-sdk ai @ai-sdk/react zod
yarn add files-sdk ai @ai-sdk/react zod
bun add files-sdk ai @ai-sdk/react zod
nub add files-sdk ai @ai-sdk/react zod
aube add files-sdk ai @ai-sdk/react zod
R2_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 throws key must not contain . or .. path segments before any request.
  • The prefix the model passes to listFiles is appended to the instance prefix, so it can narrow a listing but not widen it, and results come back with users/42/ stripped. See Prefixes.
  • signedUrlPolicy clamps the expiresIn the model picks for getFileUrl to 300 seconds and forces Content-Disposition: attachment. Without it, the model’s number goes straight to files.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:

  1. The model calls a write tool. Read tools run without asking. For uploadFile, the AI SDK doesn’t call execute; it records an approval request and the loop stops. The UI receives a tool-uploadFile part with state: "approval-requested", the parsed input, and approval.id (plus approval.signature when a secret is set).
  2. The user answers. addToolApprovalResponse({ id, approved }) moves the part to approval-responded. Once every request in the last message has an answer, lastAssistantMessageIsCompleteWithApprovalResponses returns true and useChat resubmits.
  3. The server acts on the answer. convertToModelMessages turns that part into an assistant tool-call and tool-approval-request, followed by a tool message carrying the tool-approval-response. If it was approved, the AI SDK checks the input against the tool’s schema, runs execute, and the part becomes output-available. If it was declined, execute never runs: the model gets a tool result of type execution-denied, the part becomes output-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; without approved, the output is an approvalRequired error and nothing runs. createAgentsFileTools({ files }) returns a record for new 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 named mcp__files__<tool>. Gated writes are left out of allowedTools, so each call reaches canUseTool; the bundled one denies them, along with any tool not on its server. See Claude.

Limits

  • File contents leave your server. Whatever downloadFile returns 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. downloadFile stops at 10 MiB, and base64 content uses up context quickly. Let the agent hand the user a getFileUrl link 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 passes maxSize, the R2 adapter throws r2: `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.

Last updated on

Was this page helpful?