
Files Sdk
- 19 installs
- 1.5k repo stars
- Updated August 4, 2026
- haydenbleasel/files-sdk
Adds file storage to a TypeScript/JavaScript app with one unified API across S3, R2, GCS, Azure, Vercel Blob, local filesystem, and 40+ providers.
About
A storage-SDK skill for the files-sdk package, covering uploads, downloads, presigned URLs, multipart/resumable transfers, and provider migration. A developer uses it to integrate object/blob storage behind a single API and swap adapters without changing code.
- One Files class configured with a per-import adapter subpath
- Throws FilesError on unsupported capabilities instead of silently degrading
Files Sdk by the numbers
- 19 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,461 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/haydenbleasel/files-sdk --skill files-sdkAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 1.5k |
| Last updated | August 4, 2026 |
| Repository | haydenbleasel/files-sdk ↗ |
What it does
Adds file storage to a TypeScript/JavaScript app with one unified API across S3, R2, GCS, Azure, Vercel Blob, local filesystem, and 40+ providers.
Files
files-sdk
A unified storage SDK for object and blob backends. One small API. Web-standard I/O. Escape hatch to the native client when needed.
When the user asks for help integrating it, follow this skill. It is the source of truth — prefer it over training-data memory of the package.
Bundled docs: Whenfiles-sdkis installed, the full documentation ships inside the package atnode_modules/files-sdk/docs. Read those MDX files for the complete, version-matched reference: per-adapter setup underdocs/adapters/, AI tools underdocs/ai/, the CLI underdocs/cli/, per-feature pages underdocs/features/, plusoverview,api/*,providers, andtroubleshooting. Prefer them over <https://files-sdk.dev> when the package is present locally — they match the installed version exactly.
Mental model
- One core class
Files, configured once with an adapter at construction time. The adapter is fixed for the life of the instance. - 40+ adapters, each a separate subpath export so only what you import is bundled (
files-sdk/s3,files-sdk/r2,files-sdk/gcs,files-sdk/azure,files-sdk/vercel-blob,files-sdk/fs, …). - The unified API is the common subset of what every adapter can do. Provider-specific features (S3 versioning, lifecycle, storage classes, etc.) live behind
files.raw, which returns the underlying native client. - Bodies are web-standard:
Blob,File,ReadableStream<Uint8Array>,Uint8Array,ArrayBuffer,ArrayBufferView, orstring. No provider types leak. - Every method takes the same
OperationOptions(signal,timeout,retries), and most of those can also be set once on the constructor as instance defaults. The constructor additionally takesprefix,readonly, andhooks. - Where an adapter can't do something the unified surface offers (a range download, a folder listing, a resumable session), it throws a `FilesError` rather than silently degrading — so a missed capability is loud, not a quiet correctness bug. Capability flags (
supportsRange,supportsDelimiter) let you branch at runtime.
Install
npm install files-sdkQuick start
import { Files } from "files-sdk";
import { s3 } from "files-sdk/s3";
const files = new Files({
adapter: s3({ bucket: "uploads" }),
});
await files.upload("avatars/abc.png", file, { contentType: "image/png" });
const got = await files.download("avatars/abc.png");
const exists = await files.exists("avatars/abc.png");Swap the adapter import and the rest of the code stays the same.
Core API
All methods live on the Files instance; the single-key forms are also available on a key-scoped FileHandle from files.file(key). The upload/download/head/exists/delete methods are overloaded — pass one key for a single result, or an array for the bulk form (see Bulk operations).
| Method | Returns | Notes |
|---|---|---|
upload(key, body, opts?) | UploadResult | opts: contentType, cacheControl, metadata, onProgress, multipart, control. Array form → { uploaded, errors? }. |
download(key, opts?) | StoredFile | opts.as is "blob" or "stream"; opts.range for a byte slice. Array form → { downloaded, errors? }. |
head(key, opts?) | StoredFile | Metadata only. The returned object still has text()/blob()/arrayBuffer()/stream() but they lazy-GET. Array form → { files, errors? }. |
exists(key, opts?) | boolean | false only on NotFound. Auth/transport errors still throw. Array form → { existing, missing, errors? }. |
delete(key, opts?) | void | Array form → { deleted, errors? } (uses native batch delete on S3/Supabase/UploadThing). |
copy(from, to, opts?) | void | Within one adapter. |
move(from, to, opts?) | void | Rename. Native rename where available (fs, Cloudinary), else copy+delete. Throws on immutable stores (Convex). |
list(opts?) | { items, prefixes?, cursor? } | opts: prefix, cursor, limit, delimiter. delimiter returns folder prefixes — see Folder listing. |
listAll(opts?) | AsyncGenerator<StoredFile> | Walks every page for you, following the cursor. for await (const f of files.listAll({ prefix })). |
url(key, opts?) | string | See URL behavior — varies by adapter. |
signedUploadUrl(key, opts) | SignedUpload | See Signed upload URLs — pass maxSize. |
file(key) | FileHandle | Same single-key methods, key pre-bound. Also copyTo/copyFrom/moveTo/moveFrom. |
raw / adapter (getters) | native client / Adapter | Escape hatch — see Escape hatch. |
readonly() (method) | Files | A read-only view reusing the same adapter/prefix/hooks — see Instance options. |
Plus a top-level transfer(source, dest, opts?) for cross-provider migration — see Bulk, move & transfer.
StoredFile shape
name, key, size, type, lastModified?, etag?, metadata?, plus arrayBuffer(), text(), blob(), stream().
File handles
For repeated work on the same key:
const avatar = files.file("avatars/abc.png");
await avatar.upload(file, { contentType: "image/png" });
if (await avatar.exists()) {
const meta = await avatar.head();
const url = await avatar.url({ expiresIn: 300 });
}
await avatar.moveTo("avatars/archived/abc.png");
await avatar.delete();Instance options
Pass these to new Files({ adapter, ... }). The three OperationOptions (signal, timeout, retries) are also accepted per-call, where a per-call value wins.
- `prefix` — every key is resolved relative to it: prepended on the way in, stripped from results on the way out (including in
list, hooks, and bulk forms). Your app code works in its own namespace.new Files({ adapter, prefix: "users" })→upload("123/a.png")writesusers/123/a.pngand the result'skeyis"123/a.png". - `readonly: true` — blocks every write surface (
upload,delete,copy,move,signedUploadUrl, and thefile(key)write helpers) withFilesError { code: "ReadOnly" }. Reads still work.files.readonly()derives such a view from an existing instance (same adapter/prefix/timeout/retries/hooks, no second client). Does not lock downfiles.raw. - `hooks` — fire-and-forget observability:
onAction(fires once per settled call, success or error, withtype/key/keys/from/to/status/result/durationMs),onError(rejections),onRetry(each scheduled retry). Caller-facing payloads only — never the internal prefixed path. A throwing hook can't fail the operation. - `signal` — an
AbortSignal; aborting fails in-flight single-key calls fast withFilesError { aborted: true }(stillcode: "Provider"). Constructor + per-call signals compose (either aborts). - `timeout` — per-attempt deadline in ms (not per call). Aborts and is not retried.
0/negative disables. No default. - `retries` — a number (
{ max }) or{ max, backoff }. Retries only transientProviderfailures;NotFound/Unauthorized/Conflict, aborts, timeouts, andReadableStreamuploads are never retried. Default backoff is exponential (100ms·2ⁿ, capped 30s).
Bulk forms don't take `signal` or `retries` — they manage work throughconcurrency/stopOnErrorand surface per-key failures inerrors[]instead. See references/resilience-and-hooks.md.
Bulk operations
upload, download, head, and exists take a single key or an array; delete takes one key or many. The array form fans out with bounded concurrency (8 by default) and returns a structured result that keeps successes and failures separate, in input order — one bad key never sinks the batch, and it does not throw on partial failure.
const { uploaded, errors } = await files.upload([
{ key: "a.txt", body: "alpha" },
{ key: "b.txt", body: "beta", contentType: "text/plain" },
]);
const { existing, missing } = await files.exists(["a.txt", "b.txt", "c.txt"]);
const { deleted } = await files.delete(["a.txt", "b.txt"], { concurrency: 16 });Result shapes: upload → { uploaded, errors? }, download → { downloaded, errors? }, head → { files, errors? }, exists → { existing, missing, errors? }, delete → { deleted, errors? }. errors is { key, error }[], omitted entirely when everything succeeded. Pass stopOnError: true to bail at the first failure (runs sequentially). Bulk calls are not retried and fire one aggregated onAction.
Large & resilient uploads
Three per-call upload/download options for big objects — all detailed in references/large-uploads.md:
- `multipart` on
upload— split a large body into parallel parts (true, or{ partSize, concurrency }). The robust path past the single-request limit and forReadableStreambodies of unknown length (which auto-engage multipart on S3-family adapters even without the flag). Maps to each provider's native chunking; unsupported adapters that only take buffered bodies ignore it. - `control` on
upload(resumable) — pass anUploadControl(exported fromfiles-sdk) topause()/resume()/abort(), and persistcontrol.toJSON()to resume in a later process after a crash. Requires a known-length body (no bareReadableStream). Supported on S3-family, GCS, Firebase, Azure, OneDrive, Dropbox, and more; unsupported adapters throw. Distinct frommultipart(this drives the provider's resumable session and exposes the upload id). - `range` on
download— fetch a contiguous byte slice ({ start, end? }, 0-based,endinclusive — HTTPRangesemantics, notslice()). The primitive behind video seeking and resuming. Throws on adapters with no range primitive (checkadapter.supportsRange). - `onProgress` on
upload—({ loaded, total? }) => void. S3-family reports true byte-level progress (via the optional@aws-sdk/lib-storagepeer dep).
Bulk, move & transfer
- `move(from, to)` — rename within an adapter (native rename where the provider has one, else copy+delete; moving onto itself is a no-op).
FileHandlehasmoveTo/moveFrom. - `listAll(opts?)` — async iterable over every page; each page is a real
listcall so retries/timeouts/prefix all apply. - `transfer(source, dest, opts?)` — top-level export. Streams every object from one
Filesinstance to another across backends (the one thing the unified surface uniquely enables, sincecopy/moveare single-adapter). Built onlistAll+ streamingdownload+exists+upload. Body, content type, and user metadata travel;etag/lastModifiedare destination-assigned andCache-Controlis not carried. Returns{ transferred, skipped?, errors? }(no throw on partial failure). Options:prefix,transformKey,overwrite,concurrency(default 8),limit,stopOnError,signal,onProgress.
import { Files, transfer } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { r2 } from "files-sdk/r2";
const from = new Files({ adapter: s3({ bucket: "old" }) });
const to = new Files({
adapter: r2({ bucket: "new", accountId, accessKeyId, secretAccessKey }),
});
const { transferred, errors } = await transfer(from, to, {
prefix: "uploads/",
});See references/bulk-and-transfer.md.
Folder listing
list({ delimiter: "/" }) collapses keys at the boundary into S3-style common prefixes — the building block for a file-browser UI. With prefix: "photos/", items are the direct files and ListResult.prefixes holds the subfolders (["photos/2023/", "photos/2024/"]). Object stores and folder-based providers support it (folder-based ones only accept "/"); flat stores (UploadThing, Appwrite, PocketBase, Convex, bun-s3) throw — check adapter.supportsDelimiter. A cursor is only valid for the exact prefix and delimiter it was produced with.
URL behavior
url(key, opts?) returns the most direct URL the adapter can produce. Behavior is not uniform:
- Signing adapters (S3, R2 HTTP, MinIO, DigitalOcean Spaces, Storj, Hetzner, Akamai, Backblaze B2, Wasabi, Tigris): presigned
GetObjectURL expiring afteropts.expiresInseconds (default ~3600). If the adapter was constructed withpublicBaseUrl, the URL is built against that origin instead and does not expire. - R2 binding: uses
publicBaseUrlif set; falls back to HTTP signing if HTTP credentials were also passed (hybrid); otherwise throws. - Vercel Blob (public): permanent CDN URL.
expiresInis ignored. - Vercel Blob (private): throws — no URL primitive. Use
download().
Two UrlOptions worth knowing
expiresIn— seconds. Honored by signing adapters; ignored by Vercel Blob public; N/A whereurl()throws.responseContentDisposition— strongly recommend `"attachment"` (or `'attachment; filename="..."'`) for user-uploaded buckets. Without it, a user-uploaded.htmlor scripted SVG executes inline at the bucket origin (stored XSS). Passing this option forces the signing path on signing adapters (even whenpublicBaseUrlis set) because a permanent CDN URL has no signature to bind the override to. Throws on Vercel Blob (no primitive) and R2 binding without HTTP creds.
Key encoding
The SDK does not URL-encode keys when building public URLs (or Vercel Blob's fast path). The caller is responsible. If keys come from untrusted input, validate or encodeURIComponent-escape segments before passing.
Signed upload URLs
signedUploadUrl(key, opts) where opts: { expiresIn, contentType?, maxSize?, minSize? }.
- Always pass `maxSize`. Without it, the adapter returns a presigned
PUTURL with no server-side size limit — anyone holding the URL can upload an arbitrarily large file untilexpiresInelapses. WithmaxSize, supporting adapters return a presignedPOSTform (S3/R2) enforcing the size via acontent-length-rangepolicy. Adapters that can't enforce it fail closed. minSizedefaults to1(rejects empty uploads, which are usually a broken client). Pass0to allow zero-byte uploads.contentTypeis bound into the signature where the provider supports it; adapters that can't enforce it throw rather than returning an advisory header.- Return shape is one of:
{ method: "PUT", url, headers? }{ method: "POST", url, fields }— POST asmultipart/form-datawithfieldsand the file last.
See references/client-uploads.md.
Errors
Every adapter error is wrapped in FilesError (re-exported from files-sdk). It has:
.codeof typeFilesErrorCode:"NotFound" | "Unauthorized" | "Conflict" | "ReadOnly" | "Provider"..aborted—truewhen the failure came from a cancellation or timeout (stillcode: "Provider"); this flag, not the code, is how you tell an abort from a real provider failure..cause— the underlying provider error (may carry request IDs/headers; don't blindlyJSON.stringifyit across a trust boundary).
Catch FilesError at the boundary; branch on .code. Only Provider failures are retried. See references/errors-and-recipes.md.
Escape hatch
import type { s3 } from "files-sdk/s3";
const native = files.raw; // typed as the native client for the configured adapterUse this for provider features that aren't in the unified API (versioning, lifecycle, storage classes, etc.). files.adapter exposes the Adapter (e.g. files.adapter.bucket). Note: raw bypasses a readonly instance by design.
Adapter catalog
40+ adapters. S3-family and S3-compatible stores wrap the s3() adapter with provider-friendly defaults (MinIO, DigitalOcean Spaces, Wasabi, Backblaze B2, Tigris, Storj, Hetzner, Scaleway, OVH, Vultr, IBM COS, Oracle, Tencent, Alibaba, Yandex, …). Direct-binding adapters (R2 worker binding, fs, Vercel Blob, Netlify Blobs, GCS, Azure, Supabase, Dropbox, Google Drive, OneDrive, Box, SharePoint, Cloudinary, UploadThing, Appwrite, Convex, Firebase Storage, PocketBase, FTP, SFTP, …) have their own implementation. There's also an in-memory adapter at `files-sdk/memory` — full Adapter contract backed by a Map, zero deps, isomorphic — for testing code that uses Files without touching real storage (url() returns an opaque memory:// URL; not for production).
Always check the live list and per-adapter options at <https://files-sdk.dev> (or the bundled docs/adapters/) rather than guessing. The exports map in packages/files-sdk/package.json is authoritative for what subpaths exist.
CLI & MCP server
files-sdk ships a `files` CLI (the files bin) at full parity with the SDK. Install globally or run via npx -p files-sdk files …. Pick a provider with --provider <name> (or FILES_SDK_PROVIDER); credentials come from the adapter's standard env vars. Output is JSON by default; bodies stream over stdin/stdout.
files --provider s3 --bucket uploads upload reports/q1.pdf --file ./q1.pdf
files --provider s3 --bucket uploads list --prefix reports/ --all | jq '.items[].key'
files --provider s3 --bucket old transfer --to '{"provider":"r2","bucket":"new",...}' --prefix uploads/Commands: upload download head exists list copy move delete url sign-upload transfer. Global flags mirror the constructor: --key-prefix (instance prefix, distinct from list --prefix), --timeout, --retries. head/exists/delete take multiple keys + --concurrency/--stop-on-error; download --range, upload --multipart/--part-size, list --all, upload --dir/download --out-dir.
The built-in MCP server (files … mcp) is read-only by default — exposes download, head, exists, list, url. Pass `--allow-writes` to also expose upload, delete, copy, move, sign-upload, transfer. Provider + credentials are bound at startup; the agent only passes operation arguments, never secrets. Binary payloads roundtrip as base64.
See references/cli-and-mcp.md. (This MCP server is the CLI-level binding — distinct from the in-process AI-tool bindings below.)
AI tools
Three subpaths expose a configured Files instance as in-process tools for AI agents. All share the same eight operations (listFiles, getFileMetadata, downloadFile, getFileUrl, uploadFile, deleteFile, copyFile, signUploadUrl) and the same approval-gating defaults (the four writes are gated; reads are not). downloadFile takes a maxBytes guard so a model can't pull an unbounded object into context.
| Subpath | For | Factory |
|---|---|---|
files-sdk/ai-sdk | Vercel AI SDK (generateText, streamText, ToolLoopAgent) | createFileTools |
files-sdk/openai | OpenAI Responses API and Agents SDK | createResponsesFileTools / createAgentsFileTools |
files-sdk/claude | Anthropic Claude Agent SDK | createClaudeFileTools |
import { Files } from "files-sdk";
import { createFileTools } from "files-sdk/ai-sdk";
import { s3 } from "files-sdk/s3";
import { generateText } from "ai";
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
await generateText({
model,
tools: createFileTools({ files }),
prompt: "Find every CSV under reports/ and summarize the latest one.",
});Key options on createFileTools (mirrored across the three):
readOnly: true— strips write tools entirely (uploadFile,deleteFile,copyFile,signUploadUrl). The model cannot mutate the bucket. (For a non-AI lock, see the SDK-levelreadonlyin Instance options.)requireApproval— defaults totrue(all writes require approval). Passfalse, or a per-tool record like{ deleteFile: true, uploadFile: false }.overrides— per-tool patches fordescription,title,needsApproval. Cannot overrideexecute,inputSchema, oroutputSchema.
See references/ai-tools.md.
Decision guide
- "How do I add file uploads to my app?" → Pick the adapter that matches their hosting/provider, show
new Files({ adapter: x({...}) })+upload/url. - Swap providers → Change the subpath import and the adapter factory call; the rest of the code is unchanged.
- Presigned client-side uploads →
signedUploadUrlwithmaxSize(always). Walk them through thePUTvsPOSTreturn shape. - Public download URL →
files.url(key); recommendresponseContentDisposition: "attachment"for user content. If their adapter throws onurl()(Vercel Blob private, R2 binding w/o config), usedownload()or configurepublicBaseUrl/HTTP creds. - Large file / unreliable connection →
multipartfor big bodies and unknown-length streams; resumablecontrol(UploadControl) to pause/resume or survive a crash;download({ range })for seeking/resuming. - Many keys at once → the bulk array form (
upload([...]),delete([...]), …) withconcurrency/stopOnError; inspectresult.errors. - Migrate a bucket to another provider → top-level
transfer(from, to, { prefix }). - Rename a key →
move. Walk a whole bucket →listAll. File-browser folders →list({ delimiter: "/" }). - Multi-tenant / namespaced keys →
new Files({ prefix }). - Lock storage to reads → SDK-level
new Files({ readonly: true })/files.readonly(). - Audit log / metrics / activity feed →
hooks(onAction/onError/onRetry). - Shell scripts / CI / a quick poke at a bucket → the
filesCLI. Give an MCP client (Claude Code, etc.) bucket access →files … mcp(read-only; add--allow-writesdeliberately). - Give an in-app LLM bucket access → the matching AI-tools subpath. Default to leaving
requireApprovalon for writes; suggestreadOnly: trueif it only needs to read. - Test code that uses `Files` → swap in
files-sdk/memory. - Feature not in the unified API →
files.raw+ the provider's native client.
References
Load the relevant reference file only when the user's task matches it — don't preload them all.
When the package is installed locally, node_modules/files-sdk/docs holds the full, version-matched documentation (see the note at the top) — reach for it for per-adapter detail the bundled references below don't cover.
- references/adapter-setup.md — construction snippets and non-obvious knobs for the common adapters (
s3,r2HTTP vs binding vs hybrid,vercel-blobpublic vs private,gcs,azure,minio,fs). - references/client-uploads.md — presigned-upload flow end-to-end: server route returning
signedUploadUrlwithmaxSize, client handling for PUT and POST, the field-order gotcha on POST, server-side confirmation. - references/large-uploads.md — multipart, resumable (
UploadControl, cross-process resume), range downloads, andonProgress: when each applies, per-adapter support, and the gotchas (known-length bodies, throw-on-unsupported, auto-multipart for streams). - references/bulk-and-transfer.md — bulk array forms and their result shapes,
listAll,move, cross-providertransfer, and folder listing withdelimiter. - references/resilience-and-hooks.md —
retries,timeout, cancellation (signal),prefixscoping,readonlyviews, and theonAction/onError/onRetryhooks. - references/cli-and-mcp.md — the
filesCLI commands, global flags, JSON/stream output, and wiring the built-in MCP server (read-only vs--allow-writes) into an MCP client. - references/ai-tools.md — full examples for
files-sdk/ai-sdk,files-sdk/openai(Responses + Agents), andfiles-sdk/claude. CoversreadOnly, granular approval, per-tool overrides, themaxBytesdownload guard, and how to choose across the three. - references/errors-and-recipes.md —
FilesError.codevalues (incl.ReadOnly) and theabortedflag, theexists()/head()traps, key-encoding rules, and migration rewrites from@aws-sdk/client-s3,@vercel/blob, and@google-cloud/storage.
Verification
Before answering with specifics:
- Confirm the adapter the user has chosen actually exists by checking
packages/files-sdk/package.jsonexports orpackages/files-sdk/src/<adapter>/. - For non-obvious behavior (URL signing,
existssemantics,signedUploadUrlPOST vs PUT, which adapters supportrange/delimiter/resumablecontrol), re-read the JSDoc on the relevant interface/method inpackages/files-sdk/src/index.tsrather than trusting memory — the capability matrices there are the source of truth.
Adapter setup
Construction snippets and the non-obvious knobs for the most common adapters. The catalog at <https://files-sdk.dev> is the canonical list — this page covers the ones users ask about most often.
S3 — files-sdk/s3
import { s3 } from "files-sdk/s3";
const adapter = s3({
bucket: "uploads",
region: "us-east-1", // optional; AWS SDK falls back to AWS_REGION
// credentials: { accessKeyId, secretAccessKey, sessionToken? }, // optional; ADC otherwise
// endpoint, forcePathStyle, // for self-hosted/S3-compatible
// publicBaseUrl: "https://cdn.example.com", // skip signing on url()
// defaultUrlExpiresIn: 3600,
});Gotchas:
- No
credentials? The AWS SDK's default credential chain (env, shared config, EC2/ECS/EKS metadata) runs. That's usually what you want in production. publicBaseUrlflipsurl()to return${publicBaseUrl}/${key}and skips signing — set this when you've put CloudFront in front of the bucket.- Passing
responseContentDispositionalways forces signing, even withpublicBaseUrlset, because permanent CDN URLs have no signature to bind the override to.
Cloudflare R2 — files-sdk/r2
Two modes, picked by which options you pass.
HTTP (works anywhere)
import { r2 } from "files-sdk/r2";
const adapter = r2({
bucket: "uploads",
accountId: process.env.R2_ACCOUNT_ID,
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
// publicBaseUrl: "https://uploads.example.com",
});Binding (inside a Worker)
const adapter = r2({
binding: env.UPLOADS, // R2Bucket binding from wrangler.toml
publicBaseUrl: "https://uploads.example.com", // required for url() unless hybrid mode
});Hybrid (binding + HTTP creds)
const adapter = r2({
binding: env.UPLOADS,
accountId: env.R2_ACCOUNT_ID,
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
});Reads/writes still go through the binding (no egress fees, no extra round trip). url() and signedUploadUrl() fall back to the S3-compatible HTTP signer instead of throwing.
Gotchas:
- Binding-only with no
publicBaseUrland no HTTP creds →url()throws. There's no signing primitive available to a binding. - The HTTP adapter is loaded via dynamic import so a binding-only Worker bundle doesn't pull in
@aws-sdk/client-s3(~500 KB+).
Vercel Blob — files-sdk/vercel-blob
import { vercelBlob } from "files-sdk/vercel-blob";
const adapter = vercelBlob({
// Credentials are optional — the adapter resolves them in the same order
// the upstream SDK does:
// 1. explicit `token` (RW or client token) — always wins
// 2. OIDC pair (`oidcToken` + `storeId`, option or env)
// 3. `BLOB_READ_WRITE_TOKEN` env
// token: process.env.BLOB_READ_WRITE_TOKEN,
// oidcToken: loadOidcToken(),
// storeId: loadStoreId(),
access: "public", // or "private" — fixed at construction
addRandomSuffix: false, // default false (predictable keys, S3-style)
allowOverwrite: true, // default true so predictable keys actually work
});A few things to know:
- OIDC is preferred on Vercel. When the Blob store is connected to a project, Vercel auto-injects
VERCEL_OIDC_TOKEN(short-lived, auto-rotated) andBLOB_STORE_ID. The adapter uses both automatically — noBLOB_READ_WRITE_TOKENrequired. Off Vercel, or if OIDC isn't configured, the RW token still works as before. - Pass `oidcToken` / `storeId` explicitly when your framework doesn't load
.env.localintoprocess.env(Vite, etc.). Otherwise the adapter silently falls back toBLOB_READ_WRITE_TOKEN(or throws if no RW token is set either). - Explicit `token` always wins over OIDC env vars, mirroring the SDK. Set it only when you actually want to override.
- `access` is fixed at construction. A single
Filesinstance is unambiguously public or private. Need both? Instantiate two adapters. - `access: "private"` makes `url()` throw. Private blobs have no permanent public URL. Use
download()instead.signedUploadUrldoes still work. - `allowOverwrite: true` is the default so
addRandomSuffix: falseworks at all — Vercel rejects same-pathname uploads otherwise. If you want create-only semantics, setallowOverwrite: falseand handle the resultingConflict.
Google Cloud Storage — files-sdk/gcs
import { gcs } from "files-sdk/gcs";
const adapter = gcs({
bucket: "uploads",
// projectId: "...", // falls back to GOOGLE_CLOUD_PROJECT / GCLOUD_PROJECT
// keyFilename: "./service-account.json", // OR
// credentials: { client_email, private_key }, // inline for Vercel/Netlify
// publicBaseUrl: "https://storage.googleapis.com/uploads",
});Notes:
- With none of
keyFilename/credentials/ env, falls back to Application Default Credentials (gcloud auth, GCE metadata, etc.). url()produces V4 signed read URLs by default; GCS capsexpiresInat 7 days.
Azure Blob Storage — files-sdk/azure
import { azure } from "files-sdk/azure";
import { DefaultAzureCredential } from "@azure/identity";
const adapter = azure({
container: "uploads", // (Azure calls it "container", surfaced as bucket)
// Highest precedence:
// connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
// Or:
accountName: process.env.AZURE_STORAGE_ACCOUNT_NAME,
accountKey: process.env.AZURE_STORAGE_ACCOUNT_KEY,
// Or for Azure AD / Managed Identity:
// accountName: process.env.AZURE_STORAGE_ACCOUNT_NAME,
// credential: new DefaultAzureCredential(),
// sasToken: "?sv=...&sig=...", // alternative to accountKey
// endpoint: "http://127.0.0.1:10000/devstoreaccount1", // Azurite / sovereign clouds
// publicBaseUrl: "https://uploads.azureedge.net",
});Notes:
- A SAS-token-only adapter (no
accountKey) cannot mint new SAS —url()andsignedUploadUrl()throwProvider. Reads/writes/list still work as long as the SAS has those permissions. - A
credentialadapter uses Azure AD / Managed Identity for SDK calls and mints User Delegation SAS URLs forurl()andsignedUploadUrl(). The principal needs blob data permissions plus permission to callgenerateUserDelegationKey. connectionStringis the highest-precedence credential source.
MinIO — files-sdk/minio
import { minio } from "files-sdk/minio";
const adapter = minio({
bucket: "uploads",
endpoint: "http://localhost:9000",
accessKeyId: process.env.MINIO_ACCESS_KEY_ID,
secretAccessKey: process.env.MINIO_SECRET_ACCESS_KEY,
});Thin wrapper over s3() with MinIO-friendly defaults: forcePathStyle: true, region default, error messages relabeled "MinIO error". endpoint is required. Other S3-compatible stores (DigitalOcean Spaces, Wasabi, Backblaze B2, Tigris, Storj, Hetzner, etc.) follow the same wrapper pattern with provider-specific defaults.
Local filesystem — files-sdk/fs
import { fs } from "files-sdk/fs";
const adapter = fs({
root: "./tmp/uploads",
// urlBaseUrl: "http://localhost:3000/uploads", // when a dev server fronts the same root
});Notes:
- Paths that resolve outside
root(e.g.../etc/passwd) throwProvider. - Without
urlBaseUrl,url()returns afile://URL — fine for CLIs/tests, not for browsers. signedUploadUrl()returns a URL with?expires=...for parity with the cloud adapters; the fs adapter itself does not enforce the expiry — your dev upload handler is expected to validate it.
The shape every adapter shares
Every adapter exports a factory that returns an Adapter satisfying the Adapter interface in packages/files-sdk/src/index.ts. As long as it satisfies that interface, the Files API works identically. When in doubt about a less-common adapter, read its index.ts — they're all small.
AI tool bindings
Three subpaths expose a configured Files instance as ready-made in-process tools for AI agents. They share the same eight operations (listFiles, getFileMetadata, downloadFile, getFileUrl, uploadFile, deleteFile, copyFile, signUploadUrl) and the same approval-gating defaults — only the _shape of the binding_ differs per SDK.
The four writes (uploadFile, deleteFile, copyFile, signUploadUrl) are approval-gated by default. Reads are not. downloadFile accepts a maxBytes guard (and overrides are capped to a safe ceiling) so a model can't pull an unbounded object into context.
Not to be confused with the CLI's built-in MCP server (files … mcp), which is a separate, process-external binding — read-only by default, opt into writes with--allow-writes. See cli-and-mcp.md. The subpaths below are for embedding tools directly in your own agent code.
Vercel AI SDK — files-sdk/ai-sdk
Tools are a record shaped for tools: { ... } on generateText/streamText/ToolLoopAgent.
import { Files } from "files-sdk";
import { createFileTools } from "files-sdk/ai-sdk";
import { s3 } from "files-sdk/s3";
import { generateText } from "ai";
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
await generateText({
model,
tools: createFileTools({ files }),
prompt: "Find every CSV under reports/ and summarize the latest one.",
});Read-only agent
createFileTools({ files, readOnly: true });
// Result type is ReadOnlyFileTools — writes are not just gated, they don't exist.Granular approval
createFileTools({
files,
requireApproval: {
deleteFile: true,
uploadFile: false,
copyFile: false,
signUploadUrl: true,
},
});Per-tool overrides
createFileTools({
files,
overrides: {
deleteFile: { needsApproval: false },
listFiles: { description: "List user uploads in the current tenant" },
},
});execute, inputSchema, and outputSchema cannot be overridden. If you need to change behavior, wrap the tool yourself or call the Files methods directly.
OpenAI — files-sdk/openai
Two surfaces: Responses API and Agents SDK. Pick whichever your codebase already uses.
Responses API
import { Files } from "files-sdk";
import { createResponsesFileTools } from "files-sdk/openai";
import { s3 } from "files-sdk/s3";
import OpenAI from "openai";
const openai = new OpenAI();
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
const tools = createResponsesFileTools({ files });
const response = await openai.responses.create({
model: "gpt-5",
tools: tools.definitions,
input: [{ role: "user", content: "List files under reports/." }],
});
// Run any function_calls the model emitted.
const followUp: unknown[] = [];
for (const item of response.output) {
if (item.type === "function_call") {
if (tools.needsApproval(item.name)) {
// Human-in-the-loop: pause, ask, then continue.
continue;
}
const output = await tools.execute(item);
followUp.push(item, output);
}
}Notes:
tools.execute(call)parses + validatesarguments, runs the operation, returns afunction_call_outputitem ready to push into the next turn's input.- JSON parse and Zod validation failures are returned as the tool output so the model can self-correct.
FilesErrorfrom the SDK is rethrown — the caller decides how to surface it. tools.executedoes not enforce approval. Checktools.needsApproval(item.name)before executing if you want the gate.
Agents SDK
import { Files } from "files-sdk";
import { createAgentsFileTools } from "files-sdk/openai";
import { s3 } from "files-sdk/s3";
import { Agent, run } from "@openai/agents";
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
const tools = createAgentsFileTools({ files });
const agent = new Agent({
name: "Bucket assistant",
tools: Object.values(tools),
});
await run(agent, "List files under reports/.");The agents-shape returns a record keyed by tool name; spread or Object.values() to plug into the tools array.
Anthropic Claude Agent SDK — files-sdk/claude
Bridges to the Claude Agent SDK's in-process MCP server + allowedTools + canUseTool triad.
import { query } from "@anthropic-ai/claude-agent-sdk";
import { Files } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { createClaudeFileTools } from "files-sdk/claude";
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
const tools = createClaudeFileTools({ files });
for await (const message of query({
prompt: "List my files.",
options: {
mcpServers: tools.mcpServers,
allowedTools: tools.allowedTools,
canUseTool: tools.canUseTool,
},
})) {
// handle messages
}What the bundle exposes:
mcpServers— pass intoquery({ options: { mcpServers } }).allowedTools— strings of the formmcp__<serverName>__<toolName>. Pass intoquery({ options: { allowedTools } }).canUseTool— ready-made approval callback. Allows reads, allows writes whoseneedsApprovalresolves tofalse, denies the rest with"requires approval".needsApproval(toolName)— accepts both bare names ("uploadFile") and prefixed ("mcp__files__uploadFile").serverandserverName— the raw MCP server instance and its name, for callers composing into a largermcpServersmap.
Override the server name when composing multiple MCP servers:
createClaudeFileTools({ files, serverName: "user-uploads" });
// allowedTools entries become `mcp__user-uploads__listFiles`, etc.Choosing across the three
- Vercel AI SDK — simplest binding. The framework handles tool dispatch; you just pass
createFileTools({ files }). - OpenAI Responses — most explicit. You loop the
function_callitems yourself, which is also where you decide whether to pause for approval. Good when you want full control of the agent loop. - OpenAI Agents — similar to AI SDK in spirit (framework loops for you), but inside the OpenAI Agents runtime.
- Claude Agent SDK — driven by the MCP +
canUseToolcontract. Use this when the agent already lives inside Claude Code or another Claude Agent SDK harness.
All four requireApproval shapes (true, false, { ... }, readOnly) work identically across the three subpaths.
Bulk operations, move, listAll & transfer
Acting on many objects, renaming, walking a whole bucket, and migrating across providers.
Bulk (array) forms
upload, download, head, and exists take a single key or an array; delete takes one key or many. The array form fans out with bounded concurrency (8 by default) and does not throw on partial failure — successes and failures come back separated, in input order.
const { uploaded, errors } = await files.upload([
{ key: "a.txt", body: "alpha" },
{ key: "b.txt", body: "beta", contentType: "text/plain", multipart: true },
]);
const { existing, missing } = await files.exists(["a.txt", "b.txt", "c.txt"]);
const { files: metas } = await files.head(["a.txt", "b.txt"]);
const { downloaded } = await files.download(["a.txt", "b.txt"]);
const { deleted } = await files.delete(["a.txt", "b.txt"]);| Method | Array form returns |
|---|---|
upload | { uploaded, errors? } |
download | { downloaded, errors? } |
head | { files, errors? } |
exists | { existing, missing, errors? } |
delete | { deleted, errors? } |
The success arrays are in supplied order. Each errors entry is { key, error } with a normalized FilesError; invalid keys (empty, null bytes) are reported there too, never thrown. errors is omitted entirely when every item succeeded.
Options & semantics
- `concurrency` (default 8) — how many per-key ops run in parallel.
- `stopOnError: true` — bail at the first failure, returning results gathered so far plus that error. Runs sequentially (ignores
concurrency). - No `signal` or `retries` — bulk calls aren't retried (
onRetrynever fires) and don't take a per-call signal; re-drive failed keys fromerrors[]instead. Cancellation/retries are single-key concerns. - Native batch delete:
delete([...])uses a provider's native bulk primitive where it has one (S3DeleteObjectschunked at 1000, Supabase, UploadThing) and ignoresconcurrency; others fall back to bounded fan-out. The other four methods always fan out (no provider batch primitive). - Hooks: one aggregated
onActionper call (carrieskeys+ the aggregated result; per-item failures live inresult.errors, notonError). - `prefix` is honored throughout — resolved on the way in, stripped on the way out.
const result = await files.upload(items, { concurrency: 16 });
if (result.errors) {
for (const { key, error } of result.errors)
logger.warn("upload failed", key, error.code);
}move
await files.move("uploads/tmp-abc.png", "avatars/user-123.png");
// FileHandle equivalents:
await files.file("avatars/user-123.png").moveFrom("uploads/tmp-abc.png");Uses the adapter's native rename where one exists (fs renames in place atomically; Cloudinary uses server-side rename, keeping the same asset_id with no re-upload) and otherwise falls back to copy + delete — the same two-step every object store takes (none offer an atomic move). Moving a key onto itself is a no-op, so the fallback can't delete a file out of existence. Throws on Convex (immutable storage ids, no rename), where copy also throws. Fires the lifecycle hooks with a "move" action type (from/to).
listAll
Walk every page as an async iterable — the SDK follows the cursor for you:
for await (const file of files.listAll({ prefix: "avatars/" })) {
console.log(file.key, file.size);
}prefix scopes the walk, limit sets the per-page size. Each page is a real list call, so retries, timeouts, and prefix scoping apply. (This is the engine transfer walks the source with.)
Folder listing (delimiter)
list({ delimiter }) collapses keys at the boundary into S3-style common prefixes — the building block for a file-browser UI.
const { items, prefixes } = await files.list({
prefix: "photos/",
delimiter: "/",
});
// items → direct files: [ photos/cover.jpg, ... ]
// prefixes → subfolders: [ "photos/2023/", "photos/2024/" ] (full keys, trailing delimiter)ListResult.prefixes is omitted when no delimiter is set or none are found; when the instance has a prefix, prefixes are scoped/stripped like item keys. Supported by object stores and folder-based providers (the latter only accept "/"); throws a FilesError on flat stores (UploadThing, Appwrite, PocketBase, Convex, bun-s3) — check adapter.supportsDelimiter. A cursor is valid only for the exact prefix and delimiter it was produced with — hold both constant across a paginated sequence.
transfer — cross-provider migration
copy/move live inside one adapter; a migration spans two. transfer(source, dest, options?) walks every object the source exposes and streams each one straight to the dest. Both arguments are full Files instances (not raw adapters), so each leg honors its own prefix, retries, timeouts, and hooks. Built entirely on public primitives (listAll + streaming download on the source, exists + upload on the dest) — no adapter implements anything new.
import { Files, transfer } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { r2 } from "files-sdk/r2";
const from = new Files({ adapter: s3({ bucket: "old" }) });
const to = new Files({
adapter: r2({ bucket: "new", accountId, accessKeyId, secretAccessKey }),
});
const { transferred, skipped, errors } = await transfer(from, to, {
prefix: "uploads/", // only walk keys under this logical prefix
transformKey: (key) => `archive/${key}`, // remap each key for the destination
overwrite: false, // skip keys already at the dest (one extra exists() each)
concurrency: 16, // objects streaming at once (default 8)
stopOnError: false, // true → sequential, bail at first failure
onProgress: ({ done, key, status }) => console.log(done, key, status),
});| Field | Contents |
|---|---|
transferred | Source keys copied to the destination. |
skipped | Keys skipped because they already existed. Omitted when none. |
errors | Per-key { key, error } failures. Omitted when every key wins. |
Each object is streamed download-to-upload — the destination never buffers a whole large file. Body, content type, and user metadata travel; `etag`/`lastModified` are destination-assigned and `Cache-Control` is not carried. Metadata a destination adapter rejects (Bunny, Appwrite, PocketBase) surfaces as a per-key error rather than failing the run. Like the bulk forms, transfer doesn't throw on partial failure. transformKey maps the _logical_ key (each instance applies its own prefix independently). There's no total in progress — the source is walked lazily. Also exposed as the CLI transfer command and an MCP transfer tool (with --allow-writes).
CLI & MCP server
files-sdk ships a files binary at full parity with the SDK, plus a built-in MCP server. Same adapters, same FilesError codes, same StoredFile shape — JSON-by-default output and stdin/stdout streaming.
Install & select a provider
The bin comes with the package; install globally for a files on PATH, or one-shot via npx/bunx. Adapter SDKs are optional peer deps loaded lazily on first use — install the one(s) for the provider you'll use alongside files-sdk.
npm install -g files-sdk @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/s3-request-presigner
files --provider s3 --bucket uploads list
# No install:
npx -p files-sdk files --provider fs --root ./uploads listPick the provider with --provider <name> on every call (or set FILES_SDK_PROVIDER once). Credentials come from the adapter's standard env vars (AWS_ACCESS_KEY_ID, BLOB_READ_WRITE_TOKEN, GOOGLE_APPLICATION_CREDENTIALS, …) — the same environment that works with the SDK. Common fields have short flags (--bucket, --region, --endpoint, --root, --container, --token); for the long tail, --config-json '{...}' passes the raw adapter-options blob through.
Commands
Each maps to a Files method:
| Command | Method | Notes |
|---|---|---|
upload | upload | --file ./x or --stdin; --content-type (else inferred) |
download | download | --out ./x to disk, --stdout to pipe; --range start-end |
head | head | metadata as JSON; takes multiple keys |
exists | exists | no output — exit 0 = exists, 1 = missing; takes multiple keys |
list | list | --prefix, --limit, --all (follow cursor to the end) |
copy | copy | |
move | move | |
delete | delete | takes multiple keys |
url | url | --expires-in <sec> |
sign-upload | signedUploadUrl | --expires-in, --max-size, --content-type |
transfer | transfer | --to '<json>' destination config; --prefix, --no-overwrite |
files --provider s3 --bucket uploads upload reports/q1.pdf --file ./q1.pdf --content-type application/pdf
cat q1.pdf | files --provider s3 --bucket uploads upload reports/q1.pdf --stdin
files --provider s3 --bucket uploads download reports/q1.pdf --stdout > q1.pdf
files --provider s3 --bucket uploads list --prefix logs/ --all | jq '.items[].key'
files --provider s3 --bucket uploads url reports/q1.pdf --expires-in 600
files --provider s3 --bucket uploads sign-upload uploads/avatar.png --expires-in 600 --max-size 5242880 --content-type image/pngGlobal flags
Mirror the constructor + OperationOptions, applied to every command:
- `--key-prefix <p>` — the _instance_ prefix; scopes every operation under a base path and returns keys relative to it. Distinct from `list --prefix`, which is a one-off filter for that one call. They compose (
--dir/--out-dirand bulk commands all honor--key-prefix). - `--timeout <ms>` — per-attempt timeout. `--retries <n>` — retry count for provider failures.
files --provider s3 --bucket uploads --key-prefix tenants/acme list # lists under tenants/acme/
files --provider s3 --bucket uploads --timeout 10000 --retries 3 head reports/q1.pdfBulk, ranges, multipart, directories
# Many keys at once → structured result, no throw on partial failure
files --provider s3 --bucket uploads head a.txt b.txt c.txt
files --provider s3 --bucket uploads delete a.txt b.txt --concurrency 16
files --provider s3 --bucket uploads exists a.txt b.txt --stop-on-error
# Byte range (0-based, inclusive); throws on adapters with no range primitive
files --provider s3 --bucket uploads download big.mp4 --out head.mp4 --range 0-1048575
# Multipart (--part-size / --multipart-concurrency imply --multipart)
files --provider s3 --bucket uploads upload big.iso --file ./big.iso --multipart --part-size 16777216
# Whole local tree up (keyed by relative path, content type inferred per file)
files --provider s3 --bucket site --key-prefix assets upload --dir ./build
# Many keys down into a directory, recreating their key paths underneath
files --provider s3 --bucket uploads download docs/a.pdf docs/b.pdf --out-dir ./pulled
# Cross-provider migration
files --provider s3 --bucket old --verbose transfer \
--to '{"provider":"r2","bucket":"new","accountId":"...","accessKeyId":"...","secretAccessKey":"..."}' \
--prefix uploads/ --no-overwrite --concurrency 16MCP server
files … mcp boots an MCP server on stdio. Read-only by default — exposes download, head, exists, list, url. Pass `--allow-writes` to also expose upload, delete, copy, move, sign-upload, transfer. Provider + credentials are bound at startup (and the global --key-prefix/--timeout/--retries bind to the server's Files instance), so the agent only passes operation arguments, never secrets. Tools mirror the CLI surface: download takes a byte range, head/exists take arrays + concurrency/stopOnError, list takes all; with writes, upload takes multipart, delete takes arrays, transfer takes a to config. Binary payloads roundtrip as base64 (download bytes, and upload with a base64 body).
files --provider s3 --bucket uploads mcp # read-only
files --provider s3 --bucket uploads mcp --allow-writes # opt into mutations// ~/.claude.json or .claude/mcp.json
{
"mcpServers": {
"files-sdk": {
"command": "files",
"args": ["--provider", "s3", "--bucket", "uploads", "mcp"],
"env": { "AWS_ACCESS_KEY_ID": "...", "AWS_SECRET_ACCESS_KEY": "..." },
},
},
}This process-external MCP server is distinct from the in-process AI-tool bindings (files-sdk/ai-sdk/openai/claude) in ai-tools.md, which embed tools directly in your own agent code.
Client-side uploads with signedUploadUrl
The pattern: server mints a short-lived presigned credential, browser uploads directly to the storage provider. The bucket never sits behind your app server.
The cardinal rule: pass maxSize
Without maxSize, the adapter returns a presigned `PUT` URL with no server-side size limit. Anyone with the URL can stream an unbounded file until expiresIn elapses. With maxSize, the adapter returns a presigned `POST` form (S3/R2 family) whose content-length-range policy is enforced by the storage provider itself.
// Bad — no size enforcement
await files.signedUploadUrl(key, { expiresIn: 60 });
// Good — POST policy with size bounds
await files.signedUploadUrl(key, {
expiresIn: 60,
contentType: "image/png",
maxSize: 5 * 1024 * 1024, // 5 MB
});minSize defaults to 1 (rejects empty uploads). Pass 0 if zero-byte uploads are legitimate for your use case.
Return shape (discriminated union)
type SignedUpload =
| { method: "PUT"; url: string; headers?: Record<string, string> }
| { method: "POST"; url: string; fields: Record<string, string> };The client must handle both. Discriminate on method.
Server: Next.js route handler
// app/api/uploads/sign/route.ts
import { Files } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { NextResponse } from "next/server";
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
export async function POST(req: Request) {
const { filename, contentType } = await req.json();
// Always derive the key server-side. Never accept a fully-trusted key
// from the client — that lets the caller overwrite arbitrary objects.
const key = `user-uploads/${crypto.randomUUID()}/${filename}`;
const signed = await files.signedUploadUrl(key, {
expiresIn: 60,
contentType,
maxSize: 10 * 1024 * 1024, // 10 MB
});
return NextResponse.json({ key, signed });
}Client: handle both PUT and POST
async function uploadFromBrowser(file: File) {
const res = await fetch("/api/uploads/sign", {
method: "POST",
body: JSON.stringify({ filename: file.name, contentType: file.type }),
});
const { key, signed } = await res.json();
if (signed.method === "PUT") {
const put = await fetch(signed.url, {
method: "PUT",
headers: signed.headers,
body: file,
});
if (!put.ok) throw new Error(`PUT failed: ${put.status}`);
} else {
// POST: send as multipart/form-data with fields first, file LAST.
const form = new FormData();
for (const [k, v] of Object.entries(signed.fields)) form.append(k, v);
form.append("file", file); // must be the last field
const post = await fetch(signed.url, { method: "POST", body: form });
if (!post.ok) throw new Error(`POST failed: ${post.status}`);
}
return key;
}Important detail for the POST path: the file field must be appended after all the policy fields. S3/R2 read fields in order and apply the policy to whatever comes after — putting file first means the policy never gets evaluated against it.
Confirming the upload server-side
The client knows the upload returned 2xx, but a hostile client can lie. If the upload matters (billing, content moderation, search indexing), have the client call back and confirm; the server then runs files.head(key) to verify the object exists and has the expected contentType/size.
const meta = await files.head(key);
if (meta.size > 10 * 1024 * 1024) {
await files.delete(key);
throw new Error("Oversized upload slipped through");
}When to use the PUT path on purpose
Skip maxSize (accept the PUT path) only when:
- The upload happens on a trusted backend, not in a user's browser.
- You're in a dev script and just want the shortest path to "object lands in bucket."
- The adapter doesn't support presigned POST at all (some non-S3 adapters fall back to PUT regardless). Treat this as a hard provider limitation, not a security stance.
Errors and migration recipes
FilesError
Every adapter error is wrapped before it reaches your code.
import { FilesError } from "files-sdk";
import type { FilesErrorCode } from "files-sdk";Shape:
class FilesError extends Error {
readonly code: FilesErrorCode; // "NotFound" | "Unauthorized" | "Conflict" | "ReadOnly" | "Provider"
readonly aborted: boolean; // true for a cancellation or timeout
readonly cause?: unknown; // the original provider error
}code is the field worth branching on:
| Code | Meaning |
|---|---|
NotFound | The object or key (or bucket/container) does not exist. |
Unauthorized | Credentials are missing, wrong, or lack the required permission. |
Conflict | Precondition failed — e.g. If-Match mismatch, create-only collision. |
ReadOnly | A write was attempted on a new Files({ readonly: true }) / files.readonly(). |
Provider | Catch-all for anything else (transport, throttling, malformed input, timeouts). |
Codes map from the provider's own error/HTTP status (404 → NotFound, 401/403 → Unauthorized, 409/412 → Conflict); ReadOnly is the one SDK-native code. Only Provider is retried — the rest are deterministic and returned immediately.
The aborted flag
A cancellation (via a signal you abort) or a timeout rejects with code: "Provider" and aborted: true. That flag — not the code — is how you tell an intentional abort apart from a genuine provider failure, and aborts are never retried.
try {
await files.download("big.zip", { signal: controller.signal });
} catch (err) {
if (err instanceof FilesError && err.aborted) {
return; // expected — the caller (or a timeout) aborted it
}
throw err;
}Use it like this:
try {
await files.head(key);
} catch (err) {
if (err instanceof FilesError && err.code === "NotFound") {
return null;
}
throw err; // never silently swallow Unauthorized / Provider
}Logging the cause
The original provider error sits on .cause for debugging. It can carry request IDs, response headers, and partial request metadata — especially from @aws-sdk. If you serialize FilesError into logs that cross a trust boundary, strip or whitelist cause rather than JSON.stringify-ing the whole thing.
The exists() trap
exists(key) returns false only when the provider reports NotFound. Auth failures, transport errors, and bad credentials still throw.
// Wrong — treats Unauthorized as "file is missing"
const present = await files.exists(key).catch(() => false);
// Right — let non-NotFound errors propagate
const present = await files.exists(key);If you actually want "best effort, log and move on," catch FilesError and inspect .code. Do not blanket-catch.
The head() accessor footgun
head(key) returns a StoredFile. The metadata fields (size, contentType, etag, metadata) are populated immediately, but text() / arrayBuffer() / blob() / stream() lazily issue a full GET on first use. If all you want is metadata, don't touch the body accessors — they are not free.
URL key encoding
The SDK does not URL-encode keys when building public URLs (or Vercel Blob's fast path). The caller is responsible. If keys are derived from untrusted input:
const safe = pathSegments.map(encodeURIComponent).join("/");
const url = await files.url(safe);Migration: @aws-sdk/client-s3 → files-sdk/s3
Before:
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3";
const s3client = new S3Client({ region: "us-east-1" });
await s3client.send(
new PutObjectCommand({
Bucket: "uploads",
Key: "avatars/abc.png",
Body: file,
ContentType: "image/png",
})
);
const got = await s3client.send(
new GetObjectCommand({ Bucket: "uploads", Key: "avatars/abc.png" })
);
const bytes = await got.Body!.transformToByteArray();After:
import { Files } from "files-sdk";
import { s3 } from "files-sdk/s3";
const files = new Files({ adapter: s3({ bucket: "uploads" }) });
await files.upload("avatars/abc.png", file, { contentType: "image/png" });
const got = await files.download("avatars/abc.png");
const bytes = new Uint8Array(await got.arrayBuffer());Provider-specific things like versioning, multipart, or storage-class controls don't have a unified API — reach for the native client via files.raw, which is typed as S3Client:
import { PutObjectCommand } from "@aws-sdk/client-s3";
await files.raw.send(
new PutObjectCommand({
Bucket: files.adapter.bucket,
Key: "archives/2026/q1.zip",
Body: file,
StorageClass: "GLACIER_IR",
})
);Migration: @vercel/blob → files-sdk/vercel-blob
Before:
import { put, head, list, del } from "@vercel/blob";
const { url } = await put("avatars/abc.png", file, {
access: "public",
addRandomSuffix: false,
});
const meta = await head(url);
await del(url);After:
import { Files } from "files-sdk";
import { vercelBlob } from "files-sdk/vercel-blob";
const files = new Files({
adapter: vercelBlob({ access: "public", addRandomSuffix: false }),
});
await files.upload("avatars/abc.png", file, { contentType: "image/png" });
const url = await files.url("avatars/abc.png");
const meta = await files.head("avatars/abc.png");
await files.delete("avatars/abc.png");The big difference: @vercel/blob is URL-keyed (head(url), del(url)); files-sdk is key-keyed (head(key), delete(key)). The key is the pathname you uploaded.
For private blobs, swap access: "public" → access: "private" and remember that files.url(key) will throw — use files.download(key) instead.
Migration: @google-cloud/storage → files-sdk/gcs
Before:
import { Storage } from "@google-cloud/storage";
const storage = new Storage();
const bucket = storage.bucket("uploads");
await bucket.file("avatars/abc.png").save(buf, { contentType: "image/png" });
const [bytes] = await bucket.file("avatars/abc.png").download();After:
import { Files } from "files-sdk";
import { gcs } from "files-sdk/gcs";
const files = new Files({ adapter: gcs({ bucket: "uploads" }) });
await files.upload("avatars/abc.png", buf, { contentType: "image/png" });
const got = await files.download("avatars/abc.png");
const bytes = new Uint8Array(await got.arrayBuffer());ADC discovery still works the same way (env vars, gcloud auth, GCE metadata) — the adapter just delegates.
Large & resilient transfers
Four per-call options on upload/download for big objects. All are single-key options — none are available in the bulk array form (except multipart, which is a per-item field there).
multipart — parallel parts
Splits a body into parts, uploads them in parallel, stitches them server-side. The robust path past the single-request limit (5 GB on S3) and for ReadableStream bodies of unknown length.
// Defaults: 5 MiB parts, 4 in flight.
await files.upload("backups/db.tar", stream, { multipart: true });
// Or tune it:
await files.upload("backups/db.tar", stream, {
multipart: { partSize: 16 * 1024 * 1024, concurrency: 8 },
});- S3 + S3-compatible (incl. R2 HTTP): runs through the optional
@aws-sdk/lib-storagepeer dep, falling back to a singlePutObjectfor small bodies. Unknown-length `ReadableStream` bodies auto-engage multipart even without the flag. - OneDrive: bodies over 250 MB (and any
multipartrequest) use a chunked upload session. - GCS / Firebase: switch to a resumable upload;
partSizemaps to chunk size. - Azure Blob: maps
partSize/concurrencyto parallel block-upload tuning. - Dropbox: streams
ReadableStreambodies through its upload session chunk-by-chunk (never buffers the whole file);partSizerounds to a 4 MiB multiple. - Everything else either streams natively or only takes a buffered body, so it ignores the flag.
Adapters that chunk natively round partSize to their own granularity (OneDrive → 320 KiB multiple, GCS/Firebase → 256 KiB); S3 enforces a 5 MiB minimum per part except the last, and caps an object at 10,000 parts (so very large objects need a big enough partSize). Memory footprint is up to partSize × concurrency. Multipart is still one `upload` call for retries/timeouts/cancellation — a failure retries the whole call, not a part. To retry individual parts and pause/resume, use control below.
control — resumable uploads
Pass an UploadControl (exported from files-sdk) to drive a pause-able, resumable, cross-process upload.
import { Files, UploadControl } from "files-sdk";
const control = new UploadControl();
const result = files.upload("backups/db.tar", file, {
control,
multipart: { partSize: 16 * 1024 * 1024 },
onProgress: ({ loaded, total }) =>
console.log(total ? `${Math.round((loaded / total) * 100)}%` : `${loaded}`),
});
control.pause(); // stop dispatching new parts; in-flight parts settle; `result` stays pending
control.resume(); // pick up where it left off
await result;It's an AbortSignal-style handle: a plain object you construct and drive from outside.
- `pause()` stops dispatching new parts (in-flight ones finish). Session preserved — the moment to
toJSON()and persist. - `resume()` continues a paused upload.
- `abort()` cancels and discards the provider-side session (cleans up the partial upload a provider might bill/retain). Terminal — the token can't be resumed. (Aborting via the
signaloption instead _keeps_ the session for later resume.) control.status("idle"|"uploading"|"paused"|"completed"|"aborted"|"error") andcontrol.loaded/control.totaltrack progress for a UI.
Resume across processes
control.toJSON() is a small JSON-serializable token. Persist it (disk, localStorage, a DB row), then rebuild with UploadControl.from(token) and call upload again with the same body — the SDK discovers what already landed and uploads only the rest.
// First run — pause and persist.
const control = new UploadControl();
files.upload("backups/db.tar", file, { control }).catch(() => {});
// …once a session exists, control.toJSON() is populated…
localStorage.setItem("upload", JSON.stringify(control.toJSON()));
// Later — new tab / process / after a crash.
const token = JSON.parse(localStorage.getItem("upload")!);
await files.upload("backups/db.tar", file, {
control: UploadControl.from(token),
});Requirements & support
- Known-length body only (
File,Blob,ArrayBuffer, typed array,string). A bareReadableStreamis rejected — a consumed stream can't be replayed. Keep theFilehandle around (as a browser upload widget does). - Cross-process resume: S3 + S3-compatible (token carries the
UploadId; resume viaListParts, abort viaAbortMultipartUpload), GCS, Firebase, Google Drive, Azure, OneDrive, Dropbox, Vercel Blob, localfs(.fls-parttemp file), FTP, SFTP, Supabase (TUS), Appwrite, Cloudinary. - In-process only (
toJSON()can't resume in a new process): Box, bun-s3, memory. - Throws
FilesError"not supported" whencontrolis passed: Netlify Blobs, UploadThing, PocketBase, Bunny, Convex, and the rest. partSize/concurrencycome frommultipartand tune the same trade-off; each part is retried individually under the call's retry policy.
The Supabase (TUS), Appwrite, and Cloudinary resumable drivers are built to each provider's documented protocol and covered by mocked tests, but haven't been exercised against a live account — verify end-to-end before relying on them in production.
range — byte-range downloads
Fetch a contiguous slice instead of the whole object — the primitive behind video seeking and resuming an interrupted download.
// Bytes 0–1023 inclusive → 1024 bytes (HTTP Range semantics, NOT slice()).
const head = await files.download("video.mp4", {
range: { start: 0, end: 1023 },
});
// Omit `end` to read from an offset to EOF — e.g. resume a partial download.
const rest = await files.download("video.mp4", { range: { start: 1024 } });Both bounds are 0-based and end is inclusive. The returned StoredFile.size reflects the range length, not the full object. Supported by adapters with a native range primitive (S3 + S3-compatible, bun-s3, GCS, Firebase, Azure, fs, memory); throws a FilesError on the rest rather than silently downloading the whole object and slicing it — check adapter.supportsRange to branch at runtime.
onProgress — upload progress
await files.upload("big.iso", file, {
onProgress: ({ loaded, total }) =>
bar.set(total ? loaded / total : undefined),
});total is present for buffered bodies, omitted for unknown-length streams. Granularity:
- A
ReadableStreambody reports byte-by-byte as the adapter consumes it. - A buffered body reports
{ loaded: 0, total }then{ loaded: total, total }— _unless_ the adapter reports true progress itself. - S3 + S3-compatible report true byte-level progress for every body type, including multipart, via
@aws-sdk/lib-storage(the optional peer dep must be installed to useonProgressthere).
Only fires while in flight and on success; a failed upload emits no final event, and on retry progress restarts. The bulk upload([...]) form's onProgress additionally carries the item key.
Resilience, scoping & observability
Constructor and per-call knobs that aren't about a single object's bytes: retries, timeouts, cancellation, prefix scoping, read-only views, and hooks. The three OperationOptions (signal, timeout, retries) are accepted both on new Files(...) (as instance defaults) and per call (where a per-call value wins).
Retries
retries retries transient failures automatically — a number (shorthand for { max }) or { max, backoff }.
const files = new Files({
adapter: s3({ bucket: "uploads" }),
retries: { max: 3, backoff: ({ attempt }) => attempt * 500 },
});
await files.upload("avatars/abc.png", file, { retries: 0 }); // opt one call out- Only `Provider` failures retry — network blips, throttling, 5xx.
NotFound/Unauthorized/Conflictare deterministic and returned immediately. Aborts and timeouts are never retried. `ReadableStream` uploads are never retried (a consumed stream can't be replayed) — buffered bodies retry normally. - Default backoff is exponential —
100 * 2 ** (attempt - 1)ms (100, 200, 400, …), capped at 30s, no jitter.attemptis1for the first retry. A caller-suppliedbackoffis used verbatim — no cap — so add your own ceiling/jitter. - Bulk forms don't retry — they surface per-key failures in
errors[]so you re-drive only what failed.
Timeouts
timeout caps how long a single attempt may run (ms). On fire, the call aborts and rejects with a Provider FilesError (aborted: true). Not retried — it ends the call. No default; 0/negative disables.
const files = new Files({
adapter: s3({ bucket: "uploads" }),
timeout: 10_000,
});
await files.head("avatars/abc.png", { timeout: 2_000 }); // tighter for one read
await files.download("big.zip", { timeout: 0 }); // disabled for a large objectBecause it's per attempt and timeouts aren't retryable, a call that retries up to n times on _other_ (provider) errors can run as long as timeout × n plus backoff. Bound the whole call with a signal you abort yourself. When an adapter applies its own download timeout (Vercel Blob, UploadThing), the signals merge — yours can tighten the deadline, never loosen it.
Cancellation (signal)
Pass a signal to bind a call to an AbortController. On abort, the in-flight call rejects immediately with FilesError { aborted: true } (still code: "Provider") — for _every_ adapter, whether or not the provider SDK supports cancellation.
const controller = new AbortController();
const upload = files.upload("avatars/abc.png", file, {
signal: controller.signal,
});
controller.abort(); // rejects immediately
// Detecting it:
import { FilesError } from "files-sdk";
try {
await files.download("big.zip", { signal: controller.signal });
} catch (err) {
if (err instanceof FilesError && err.aborted) return; // expected
throw err;
}A constructor signal applies to every single-key op (handy for tearing down all in-flight work when a request/job ends); a constructor + per-call signal compose (either aborts). Failing fast at the `Files` layer is guaranteed; cancelling the underlying provider request is not — adapters whose SDK exposes cancellation forward the signal (S3 family, Vercel Blob, UploadThing reads), others reject at the Files layer while the provider request may run to completion in the background. Bulk forms take no per-call signal. Distinguish on the aborted flag, not the code.
Prefix scoping
new Files({ prefix }) resolves every key relative to it — prepended in, stripped from results out — so app code works in its own namespace.
const users = new Files({
adapter: s3({ bucket: "uploads" }),
prefix: "users",
});
await users.upload("123/avatar.png", file); // writes users/123/avatar.png
const stored = await users.head("123/avatar.png");
stored.key; // "123/avatar.png" — prefix stripped
const { items } = await users.list(); // scoped to users/, keys relativeLeading/trailing slashes are normalized ("/users/", "users/", "users" all equivalent); a leading slash on a key is ignored, so prefix+key always join with exactly one separator. list matches on a path boundary — prefix: "users" lists users/ but never the sibling users-archive/; the per-call list({ prefix }) filter and cursor pagination compose with it. The prefix never leaks: results (key/name) come back relative, hook payloads report the keys you passed, and the bulk forms strip it identically.
Read-only views
Lock a client to reads — at construction or derived from an existing one:
const ro = new Files({ adapter: s3({ bucket: "uploads" }), readonly: true });
const base = new Files({
adapter: s3({ bucket: "uploads" }),
prefix: "users",
timeout: 10_000,
});
const view = base.readonly(); // reuses adapter/prefix/timeout/retries/hooks — no second clientStill allowed: download, head, exists, list, listAll, url, file(key) (for reads). Blocked with FilesError { code: "ReadOnly" }: upload, delete, copy, move, signedUploadUrl, and the file(key) write helpers (upload/delete/copyTo/copyFrom/moveTo/moveFrom/signedUploadUrl). It does not lock down files.raw — code writing through the escape hatch bypasses the guard by design. (Distinct from the AI-tools readOnly option, which _removes_ the write tools from an agent's toolset.)
Hooks
new Files({ hooks }) registers fire-and-forget observability callbacks. Each mirrors the lightweight onProgress style — caller-facing payloads, no internal adapter detail — and a throwing hook can never fail the operation it observes.
const files = new Files({
adapter: s3({ bucket: "uploads" }),
hooks: {
onAction({ type, status, key, keys, from, to, durationMs }) {
metrics.timing(`files.${type}.duration`, durationMs, { status });
},
onError({ type, key, error }) {
logger.error("files failed", type, key, error.code);
},
onRetry({ type, attempt, maxRetries, delayMs, error }) {
logger.warn(
`retry ${attempt}/${maxRetries} in ${delayMs}ms`,
type,
error.code
);
},
},
});- `onAction` — fires once when a public call settles, success _or_ error (
statussays which), with wall-clockdurationMs. Single-key calls setkey;copy/movesetfrom/to; the array form setskeysand fires one event for the whole batch (per-item failures live inresult.errors, not inonError). On success,resultis the resolved value (UploadResult/StoredFile/ListResult/…). OneonActioncovers latency/throughput across the whole surface — no per-call wrapping. - `onError` — fires when a public call _rejects_, just before the matching
onAction({ status: "error" }). Partial failures in a bulkerrors[]are not rejections and don't fire it. - `onRetry` — fires for each scheduled retry of a single-key call (
attempt,maxRetries,delayMs,error). Not on the first attempt, for non-retryable errors, for stream uploads, or for bulk calls.
Keys in every payload are the ones the caller passed — the prefix is never leaked.