About files-sdk
files-sdk is a TypeScript storage SDK skill for integrating object and blob storage through a shared API. It guides agents to use the package’s bundled, version-matched documentation and adapter-specific capabilities.
Key features
- 01Unified API across storage adapters
- 02Web-standard request and response bodies
- 03Bulk file operations
- 04Retries, timeouts, and hooks
- 05Adapter capabilities and native access
Use cases
- 01Integrating object or blob storage
- 02Handling batches of file operations
- 03Checking adapter-specific support
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ haydenbleasel/ files-sdk --skill files-sdkAfter installing, type/files-sdkin Claude Code to confirm it loaded.
Bundled files
References and scripts installed along with SKILL.md.
references/adapter-setup.mdai-tools.mdbulk-and-transfer.mdcli-and-mcp.mdclient-uploads.mderrors-and-recipes.mdevents.mdlarge-uploads.mdresilience-and-hooks.mdDetails
From the project READMEfiles-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: When
files-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/, concept pages (bulk, multipart, resumable, retries, prefixes, capabilities, conditional operations, …) underdocs/(concepts)/, the method reference underdocs/api/, plugins underdocs/plugins/, storage events underdocs/events/, the browser/gateway layer underdocs/ui/, AI tools underdocs/ai/, the CLI underdocs/cli/, plusindex,installation,usage,providers,faq,troubleshooting, andmigrating-to-v3. 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. - 49 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,hooks,plugins(see Plugins), andreceipts(opt-in provenance records ononAction). - Where an adapter can't do something the unified surface offers (a range download, a folder listing, a resumable session), it throws a
FilesErrorrather than silently degrading — so a missed capability is loud, not a quiet correctness bug.files.capabilitieslets you branch at runtime.
Install
npm install files-sdk
Quick 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?) | FileInfo | opts: contentType, cacheControl, metadata, onProgress, multipart, control, condition. Array form → { results, errors? }. |
download(key, opts?) | StoredFile | opts.as is "blob" or "stream"; opts.range for a byte slice. Array form → { results, errors? }. |
head(key, opts?) | FileInfo | Metadata only (key, size, contentType, etag, lastModified, metadata), no body — use download for bytes. Array form → { results, 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 → { results, errors? } (the deleted keys) (uses native batch delete on S3-family, Azure, Supabase, UploadThing). |
copy(from, to, opts?) | void | Within one adapter. |
move(from, to, opts?) | void | Rename. Native rename where available (fs, FTP, SFTP, WebDAV, Cloudinary, memory), else copy+delete. Throws on immutable stores (Convex). |
list(opts?) | { items, prefixes?, cursor? } | opts: prefix, cursor, limit (a positive integer, else Invalid), delimiter. delimiter returns folder prefixes — see Folder listing. |
listAll(opts?) | AsyncGenerator<FileInfo> | Walks every page for you, following the cursor. for await (const f of files.listAll({ prefix })). |
search(pattern, opts?) | AsyncGenerator<FileInfo> | Key match over every page: a glob by default (photos/**/*.jpg), a RegExp, or match: "regex" | "substring" | "exact". opts: prefix, maxResults, caseInsensitive, limit. |
url(key, opts?) | string | See URL behavior — varies by adapter. |
signedUploadUrl(key, opts) | SignedUpload | See Signed upload URLs — pass maxSize. |
abortUpload(key, token, opts?) | void | Discard a persisted resumable session (control.toJSON()) you won't resume — what UploadControl.abort() does mid-upload, which a restored control can't. See references/large-uploads.md. |
file(key) | FileHandle | Same single-key methods, key pre-bound. Also copyTo/copyFrom/moveTo/moveFrom. |
capabilities (getter) | AdapterCapabilities | What the adapter can do: rangeRead, delimiter ("any", "slash", or false), metadata, cacheControl, resumable, serverSideCopy, uploadProgress, signedUrl (with expiry / disposition), signedUpload (with maxSize / contentType), publicUrl, events, conditional. Plugins can narrow it (see Plugins). Branch on it up front instead of catching a throw. |
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 top-level transfer(source, dest, opts?) for a one-shot cross-provider migration and sync(source, dest, opts?) to mirror only new or changed objects — see Bulk, move & transfer.
FileInfo and StoredFile
head,list,listAll,search, anduploadreturn aFileInfo:key,size,contentType,etag?,lastModified?,metadata?, and no body.downloadreturns aStoredFile: aFileInfoplus theFile-likename/typealiases andarrayBuffer(),text(),blob(),stream().
Conditional operations
condition applies an ETag predicate inside the provider request — never a head() + write race:
const created = await files.upload("state.json", body, {
condition: { type: "create" }, // only if absent
});
await files.upload("state.json", next, {
condition: { type: "replace", etag: created.etag }, // compare-and-set
});
download and delete take condition: { etag }; copy takes condition: { source: { etag }, destination: { type: "create" } }. A failed predicate rejects with FilesError; an adapter without the native primitive rejects before any I/O (check files.capabilities.conditional). Not available on bulk, multipart, or resumable uploads.
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 inlist, 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,abortUpload, 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.files.isReadOnlyreports whether an instance is read-only.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— anAbortSignal; 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, the SDK's ownInvalid/Unsupported/ReadOnly, aborts, timeouts, andReadableStreamuploads are never retried. Default backoff is exponential (100ms·2ⁿ, capped 30s).
Bulk forms don't take
signalorretries— 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 { results, 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 { results: deleted } = await files.delete(["a.txt", "b.txt"], {
concurrency: 16,
});
Result shapes: every array form but exists returns { results, errors? } (upload / head → FileInfo[], download → StoredFile[], delete → the deleted keys); exists → { existing, missing, 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:
multipartonupload— split a large body into parallel parts (true, or{ partSize, concurrency }, both positive integers or the upload throwsInvalid). 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, except thefetchS3 engine, which throws.controlonupload(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).rangeondownload— 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 (checkfiles.capabilities.rangeRead).onProgressonupload—({ 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 reallistcall so retries/timeouts/prefix all apply.transfer(source, dest, opts?)— top-level export. Streams every object from oneFilesinstance 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 (metadata is dropped when the destination has no metadata support, rather than failing each key);etag/lastModifiedare destination-assigned andCache-Controlis not carried. Returns{ transferred, skipped?, errors? }(no throw on partial failure).onProgressreports every settled key withstatus"transferred"|"skipped"|"failed", sodonereachestotal; a throwingonProgressis ignored. Options:prefix,transformKey,overwrite,concurrency(default 8),limit,stopOnError,signal,onProgress.sync(source, dest, opts?)— top-level export for repeatable mirrors: uploads only new or changed objects (compare: "etag"by default, or"size", or a function) and, withprune: true, deletes destination keys the source no longer has (destructive).dryRun: truereturns the plan without writing. Metadata andonProgressbehave as intransfer(statuses"uploaded"|"skipped"|"deleted"|"failed"). Returns{ uploaded, skipped, deleted?, errors? }.
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, Bunny Storage) throw — check files.capabilities.delimiter. 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, RustFS, DigitalOcean Spaces, Storj, Hetzner, Akamai, Backblaze B2, Wasabi, Tigris): presigned
GetObjectURL expiring afteropts.expiresInseconds (default ~3600). WithpublicBaseUrl, a plainurl(key)is permanent;expiresInorresponseContentDispositionstill signs. - 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; an
expiresInthrowsUnsupported. - Vercel Blob (private): presigned
GETscoped to that key, expiring afterexpiresIn(default 3600, or the adapter'sdefaultUrlExpiresIn; Vercel caps it at 7 days). - FTP / SFTP / WebDAV: need
publicBaseUrl(an HTTP server fronting the same tree); otherwiseurl()throws.fsreturns afile://URL unlessurlBaseUrlis set.
Two UrlOptions worth knowing
expiresIn— a positive integer number of seconds (0, negative, fractional, orNaNthrowsInvalid), and always means "a link that expires": on a signing adapter it returns a signed URL even whenpublicBaseUrlis set (a plainurl(key)still returns the CDN link); on an adapter that only has permanent links (Vercel Blob public, UploadThing public-read, Convex, Appwrite, fs) it throwsUnsupported.files.capabilities.publicUrl/signedUrl.supportedtell you which you have.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
Pass raw keys. URLs built from publicBaseUrl / urlBaseUrl (and Vercel Blob's public fast path) percent-encode each key segment for you, and signing adapters encode through the provider's signer — so pre-encoding a key double-encodes it (%20 → %2520) or, on a signing adapter, names a different object. If keys come from untrusted input, validate them (reject .. segments, control characters, unexpected prefixes) rather than escaping them.
Signed upload URLs
signedUploadUrl(key, opts) where opts: { expiresIn, contentType?, maxSize?, minSize? }.
- Always pass
maxSize. Without it, the adapter returns a presignedPUTURL with no server-side size limit — anyone holding the URL can upload an arbitrarily large file untilexpiresInelapses. WithmaxSize, supporting adapters return a presignedPOSTform (S3 and the S3-compatible adapters on the AWS SDK engine, GCS, Firebase) enforcing the size via acontent-length-rangepolicy; Vercel Blob enforces it on its presignedPUT. Adapters that can't enforce it (R2, Azure, Supabase, thefetchS3 engine, …) fail closed. minSizedefaults to1(rejects empty uploads, which are usually a broken client). Pass0to allow zero-byte uploads. Providers with no minimum-size constraint (Cloudinary, UploadThing, Vercel Blob, Azure, Supabase) throw on an explicit positiveminSize— omit it or pass0.contentTypeis bound into the signature where the provider supports it; adapters that can't enforce it (bun-s3, Azure, Supabase, Cloudinary, OneDrive, SharePoint) throw rather than returning an advisory header.expiresInis capped where the provider caps it: the S3 family (including bun-s3 and the fetch engine) throws past SigV4's 7 days rather than returning a URL that is dead on arrival, and GCS/Firebase V4 signing rejects past 7 days too.files.capabilities.signedUrl.maxExpiresInreports the cap (604800on those, and on UploadThing's private mode), and the gateway clamps requested expiries to it.- 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" | "Invalid" | "Unsupported" | "Provider".Invalidmeans the call is wrong (bad key, range, option, config, or avalidation()rejection);Unsupportedmeans this adapter/mode/plugin can't do it (checkfiles.capabilities);Providermeans the backend or transport failed..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)..permanent—truewhen the same call can only fail the same way: always forInvalid,Unsupported, andReadOnly, and for deterministicProviderfailures (a host that ignoresRange, corrupt stored data). Never retried, and thefailoverplugin doesn't fail over on them.
Catch FilesError at the boundary; branch on .code. Only non-permanent 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 adapter
Use 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
49 adapters. S3-family and S3-compatible stores wrap the s3() adapter with provider-friendly defaults (MinIO, RustFS, DigitalOcean Spaces, Wasabi, Backblaze B2, Tigris, Railway Buckets, 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, WebDAV, Bunny Storage, bun-s3, …) 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 node_modules/files-sdk/package.json is authoritative for what subpaths exist, and files-sdk/providers exports the same catalog as data (PROVIDERS, getProvider(slug), each adapter's env vars and required config).
S3-compatible wrappers need the @aws-sdk/* peers. On Cloudflare Workers (or anywhere you want no AWS SDK), use files-sdk/s3-fetch (s3Fetch(), a SigV4 fetch engine) or client: "fetch" on r2() / minio() / rustfs() — r2 picks it automatically inside Workers. The fetch engine has no multipart/resumable uploads.
Plugins
plugins: [...] wraps every call on the instance (plugins[0] is outermost). Each plugin is its own subpath:
| Subpath | Factory | What it does |
|---|---|---|
files-sdk/validation | validation() | Fail-closed write guard: size, MIME type, key rules. |
files-sdk/content-type | contentType() | Sniffs magic bytes to set or verify Content-Type. |
files-sdk/encryption | encryption() | Client-side envelope AES-256-GCM (generateEncryptionKey()). |
files-sdk/compression | compression() | gzip/deflate bodies at rest. |
files-sdk/dedup | dedup() | Content-addressed storage; identical bodies stored once. |
files-sdk/versioning | versioning() | Snapshot on overwrite/delete; files.versions() / restoreVersion(). |
files-sdk/soft-delete | softDelete() | Recycle bin; trashed() / restoreTrashed() / purge(). |
files-sdk/cache | cache() | LRU/KV cache for head() / url() / small downloads. A custom store requires namespace naming the bucket (cache({ store, namespace: "uploads-prod" }), else Invalid); entries are scoped by namespace + instance prefix. One cache() per instance. Invalidates on writes and on provider storage events. |
files-sdk/tiering | tiering() | Route hot/cold objects across two backends. |
files-sdk/failover | failover() | Retry provider failures against replica backends. |
files-sdk/signed-url-policy | signedUrlPolicy() | Force attachment disposition on url() and cap signed-URL lifetimes. |
files-sdk/audit | audit() | Awaited who/what/when log of mutations. |
files-sdk/usage | usage() | Meter ops and bytes; files.usage(). |
files-sdk/tracing | tracing() | OpenTelemetry span per operation. |
files-sdk/zip | zip() | Stream objects as a ZIP archive and extract archives into keys. |
files-sdk/events | events() | Normalize bucket notifications into one FileEvent; files.events.on() / dispatch() / webhook(). See Storage events. |
import { createFiles } from "files-sdk";
import { s3 } from "files-sdk/s3";
import { validation } from "files-sdk/validation";
import { versioning } from "files-sdk/versioning";
const files = createFiles({
adapter: s3({ bucket: "uploads" }),
plugins: [validation({ maxSize: 10 * 1024 * 1024 }), versioning()],
});
await files.versions("report.pdf"); // typed because createFiles was used
Use createFiles (not new Files) when a plugin adds methods, so they show up on the type. Order matters. contentType() goes before validation(), so the check sees the sniffed type rather than the claimed one. validation() goes before versioning(), softDelete(), and dedup(): their internal keys (snapshots, trash, blobs) only pass through plugins placed after them, so a validation() further in would reject them. encryption() goes after anything that must see plaintext ([compression(), encryption(key)]), but before failover() and tiering() ([encryption(key), tiering(...)]): they drive their secondary / cold backend outside the rest of the onion, so placed ahead of encryption() whatever they route there is stored unencrypted. Read each plugin's page under the bundled docs/plugins/ (or https://files-sdk.dev/docs/plugins) before composing others.
Body-transforming plugins fail closed rather than hand out something that bypasses them: encryption() and compression() throw on url(), signedUploadUrl(), range reads, and resumable control uploads (and report signedUrl, signedUpload, rangeRead, and resumable as unsupported); dedup() throws on url(), signedUploadUrl(), and conditional operations. These refusals, like validation() and contentType() rejections, are permanent, so failover() never re-sends them to a replica that runs without the plugin. A plugin can declare what it refuses through the FilesPlugin.capabilities?(caps) hook, which narrows files.capabilities, so callers and the gateway see it up front (for example, the gateway proxies downloads and uploads instead of using signed URLs, and an option a plugin turns off is refused before any plugin runs; versioning(), softDelete(), tiering(), and failover() switch off the conditional flags for the compare-and-set operations they refuse). On adapters whose list() carries no object metadata (S3 and the S3-compatibles), list() items under encryption(), compression(), or dedup() report the stored object's size (for dedup(), the empty pointer's), though download() still returns the decoded content.
Browser uploads & the gateway (useFiles)
To give a browser the whole Files API without shipping credentials, mount a gateway on your server and call it from a client binding:
- Server:
createFilesRouter({ files, authorize, allowedOrigins })fromfiles-sdk/api, mounted with a framework adapter'screateRouteHandler—files-sdk/next,hono,express,fastify,koa,nitro,astro,sveltekit,tanstack-start(andFilesModulefromfiles-sdk/nestjs). The gateway is deny-by-default: with noauthorizehook and nooperationsallow-list, every verb butcapabilitiesreturns 403.authorizethrows to deny or returns a constraint, typically a per-userkeyPrefixsuch as"users/<id>/".defaultExpiresInis only the default URL lifetime, not a ceiling: cap client-requested expiries withmaxExpiresInfromauthorize.- Keyless uploads:
presignrefuses a declaredsizeovermaxUploadSize(422,reason: "size"), and the token binds the declared size (capped bymaxUploadSize) plus the minting origin, path, and query; the proxyPUTis a bearer-token upload (the token alone authorizes writing the one minted key until it expires, noauthorizecall, no clientheaders), so a per-requestfilesfactory must pick the instance from the URL, never a cookie or header;completerefuses a token whose key is outside the caller'skeyPrefixand deletes an object larger than the token allows. A client content type that isn't exactly one media type (image/png, text/html) is a 422 (reason: "type"). - On adapters whose
url()refusesresponseContentDisposition(Vercel Blob, Box, Dropbox,fs, …),downloadMode: "auto"proxies downloads soattachmentstill applies, and anauthorize{ disposition: "inline" }mints URLs without a disposition. Without that inline policy theurlop fails with a422Unsupportederror, since the gateway can't guaranteeattachment. - Proxied downloads on a non-range adapter answer
Range: bytes=0-(how<video>/<audio>open) with the full object (200), and send a quotedETagplusLast-Modified,Cache-Control: private, no-store,Content-Disposition: attachment; filename="<key's last segment>"by default, and (except raster images/audio/video/PDF, judged on a well-formed type) aContent-Security-Policy: sandboxso stored HTML/SVG can't run script inline.HEADgets the same headers with no body. A 416 has a JSON body (Validation, reasonrange; the client throwsInvalid). Inautomode aurl()that refuses withUnsupportedfalls back to the proxy. - Keys and
list/searchprefixes insideversioning()'s /softDelete()'s /dedup()'s storage (.versions,.trash,.dedup) get 403 unless under akeyPrefix; use the plugin verbs. A plugin verb the gateway'sFileslacks isUnsupported(reasoncapability).filterKeysalso covers versions / restoreVersion / restoreTrashed / complete.authorizegets uploadparams(presign:{ files: [{ name, size?, type }], expiresIn? }; keyed PUT:{ contentType?, size? }). Reserved prefixes match case-insensitively and with\as a separator (.TRASH/x,.trash\xare refused too). - Anything thrown that isn't a
FilesError/RouterError(fromauthorize,onUploadComplete, the completions store) answers a generic 500 and goes to the router'sonError(error, req)(defaultconsole.error). An adapter'sNotFound/Unauthorized/Conflict/Providermessage is replaced by a fixed one (not found, …; it can carry fs paths or hostnames), andProvider/Unauthorizedones go toonError;Invalid/Unsupported/ReadOnlyand aFilesErroryour hooks throw keep their message, rewritten to the client's key. - The gateway bounds client-driven load:
maxBatchSize(array lengths, default 1000, 413),maxConcurrency(default 16),maxJsonBodySize(default 1 MiB, 413), asearchwalks inmaxListLimit-sized pages whatever itslimit, onesearchreads at mostmaxSearchScankeys (default 10,000) before answeringtruncated: true, andsearchpatterns limited bymaxSearchPatternLength(256) /maxSearchWildcards(4) (422); amatch: "regex"pattern that doesn't compile or could backtrack catastrophically is also a 422. - Record uploads with
onUploadComplete({ file, storageKey, files, req, context, uploadId, via }), not a second "confirm" endpoint. It runs once per verified upload: incompletefor keyless uploads (afterhead+ themaxUploadSizecheck) and after a keyedupload(key, body)stores its body; never on the proxy PUT. Its resolved value comes back to the client as the upload result'sdata.authorizecan returncontext(the user, the tenant) that the hook receives typed. Throw to reject: the object is deleted (onRejected: "keep"to quarantine instead) and the client gets the error;UploadRejectedErrorfromfiles-sdk/apigives a 422 with your message. Tokens are stateless, so a replayedcompletefires again with the sameuploadId: make the hook idempotent on it, or pass acompletions{ get, set }store (Map-backed in one process, Redis/KV across instances) to make completions single-use (the proxy PUT then answers 409 aftercompleteaccepted the upload).completeaccepts a token up tocompleteGracePeriod(default 3600 s) past its expiry; a presigned direct-to-storage target stays writable until it expires regardless. A client that never callscompletenever fires the hook; reconcile withfiles-sdk/events.
- Client:
useFiles({ endpoint: "/api/files" })fromfiles-sdk/react,files-sdk/vue, orfiles-sdk/svelte(every verb, upload progress, plus reactiveuseList/useFile/useSearch);createFilesClientfromfiles-sdk/clientfor non-framework callers. Type the hook'sdatawithuseFiles<InferUploadData<typeof router>>()(a type-only import of the router).uploadsaccumulates oneFileUploadStateper file across calls (single and bulk) untilreset()clears the finished ones; every state endssuccess,error(witherrorset), oraborted. Errors from every verb, includinglistAll/searchiteration, are mirrored intoerror. Every rejection is aFilesError: a network failure isProvider(original incause), a cancelled call hasaborted: true, and a non-envelope response (auth middleware, a storage host) is classified by status. Bulkupload([...])/download([...])withstopOnErrorrun sequentially and resolve{ results, errors }like the SDK. A query hook'sdatabelongs to its current input (cleared on a key/endpoint change or when disabled; kept acrossrefetch()), and the hooks honor thesignaloption.search()'s generator returns{ truncated }(SearchSummary) when the gateway's bounded walk stopped early.
See the bundled docs/ui/ (or https://files-sdk.dev/docs/ui) for per-framework setup, authorization, and the shadcn component registry.
Storage events
files-sdk/events turns every provider's bucket notifications (S3 via SQS/Lambda/EventBridge/SNS, MinIO webhooks, R2 Queues, GCS Pub/Sub, Storj via Pub/Sub, Azure Event Grid, B2, Tigris, Supabase, Cloudinary, Appwrite, Box) into one FileEvent (type: "created" | "deleted", caller-facing key, size?, etag?, versionId?, contentType?, time, source, provider, id, raw) and routes it to files.events.on(type, glob?, handler). Queue consumers call files.events.dispatch(message); push providers mount files.events.webhook({ verify }) with any gateway binding (verify is required: { token }, { google: { audience, email } } (both required), { secret }, { sns: { topicArn } } (topic required, signed Timestamp checked), or false). Other buckets' events are dropped by default (bucket defaults to the adapter's; false accepts all). Gateway uploads and (with events({ sdk: true })) the instance's own writes reach the same handlers. Delivery is at-least-once (Supabase's webhooks: once, no retries) and unordered: make handlers idempotent on event.id, or pass a dedupe store. The memory adapter emits events natively (await files.events.settled() in tests). See references/events.md.
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 search copy move delete url sign-upload capabilities transfer sync events mcp (files events parse [file] --format <s3|r2|gcs|…> prints the events a notification delivery normalizes to). 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, search, url, capabilities. Pass --allow-writes to also expose upload, delete, copy, move, sign-upload; transfer and sync additionally need an operator-fixed destination via mcp --allow-writes --to '<json>' (the agent never picks the destination). 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.
Effect
files-sdk/effect bridges a Files instance into Effect v4 (optional peer effect@^4). Files is a Context.Service; Files.layer(instanceOrOptions) provides it (options the constructor rejects fail the layer with an Invalid reason). The service mirrors the Files methods and options: operations return Effect<A, FilesError>, listAll/search return Stream<FileInfo, FilesError>, and download resolves to a DownloadedFile whose text/arrayBuffer/blob are Effects and stream is a Stream<Uint8Array>.
import { Effect } from "effect";
import { Files } from "files-sdk/effect";
import { s3 } from "files-sdk/s3";
const program = Effect.gen(function* () {
const files = yield* Files;
const file = yield* files.download("settings.json");
return yield* file.text;
}).pipe(
Effect.catchReason("FilesError", "NotFound", () => Effect.succeed("{}"))
);
await Effect.runPromise(
program.pipe(Effect.provide(Files.layer({ adapter: s3({ bucket: "app" }) })))
);
- Errors: one
FilesError(tag"FilesError") whosereasonis a tagged error per SDK code (NotFound,Unauthorized,Conflict,ReadOnly,Invalid,Unsupported,Provider) carryingmessage,permanent,timedOut,aborted,applied,appliedEtag, andcause(the SDK'sFilesError). UseEffect.catchReason/catchReasons, orEffect.unwrapReason("FilesError")thencatchTag. To match the SDK's retry policy, useEffect.retry({ while: (e) => e.reason._tag === "Provider" && !e.reason.permanent && !e.reason.aborted }), with the instance'sretriesunset. - Cancellation: the fiber's
AbortSignalis passed assignal;Effect.timeout/interruption stops waiting for the SDK call and forwards cancellation to the adapter, but not all provider SDKs can stop the underlying request. The bulk (array) forms take no per-call signal and run to completion. Interrupting a buffered body accessor (text,arrayBuffer,blob) cancels the body read unless another read still waits on it; later reads of a cancelled body fail asaborted, so download again. - Storage events (needs the
events()plugin, elseUnsupported):files.events.on(type, glob?, handler)runs an Effect handler for the life of theScope, and deliveries wait for it (at-least-once).files.events.stream()is aStream, but it acknowledges on buffering, so buffered events are lost if the process stops; useonfor durable work.parse/dispatchfor queue consumers. - Plugin methods (
versions(),usage(), …) aren't on the service:files.tryPromise((client, signal) => client.versions(key)). For typed plugin methods or several buckets, declare your ownContext.Service<Self, FilesService<typeof client>>and providemake(client).
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()(R2 binding w/o config, FTP/SFTP/WebDAV withoutpublicBaseUrl, Netlify Blobs, Google Drive/OneDrive/SharePoint withoutpublicByDefault), 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 }). Keep a backup mirror current →sync(from, to, { prune }). - Find files by name/pattern →
files.search("reports/**/*.pdf"). - Create-only or compare-and-set writes →
upload(..., { condition }). - Validate, encrypt, version, cache, or trace storage → the matching plugin.
- Upload from the browser → presigned
signedUploadUrlfor a single upload form; the gateway +useFilesfor a full browser file manager. - 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. - Effect (v4) codebase →
files-sdk/effect: provideFiles.layer(...),yield* Files, recover withEffect.catchReason("FilesError", "NotFound", …). - Test code that uses
Files→ swap infiles-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,syncmirrors, 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 (incl.search,sync,capabilities), global flags, JSON/stream output, and wiring the built-in MCP server (read-only vs--allow-writes,--tofor transfer/sync) 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 the
exportsinnode_modules/files-sdk/package.json(orPROVIDER_NAMESfromfiles-sdk/providers). - For non-obvious behavior (URL signing,
existssemantics,signedUploadUrlPOST vs PUT, which adapters supportrange/delimiter/resumablecontrol), re-read the JSDoc innode_modules/files-sdk/dist/index.d.ts(anddist/<adapter>/index.d.ts) or the bundleddocs/rather than trusting memory — or checkfiles.capabilitiesat runtime.