# Custom attachment uploads URL: /docs/integrations/attachments/custom-adapter Upload chat attachments to object storage with a presigned-URL AttachmentAdapter. The bundled `SimpleImageAttachmentAdapter` and `SimpleTextAttachmentAdapter` inline files as data URLs. That works for small images and text files but breaks down for large files, persistent threads, and serverless body-size limits. This page shows the production pattern: an `AttachmentAdapter` that uploads to object storage via a presigned URL and sends only the URL to the model. For the adapter contract itself, see [adapters](/docs/runtimes/concepts/adapters#attachment-adapter). This page is the storage variant. ## How it works \[#how-it-works] ``` composer add ──► POST /api/upload (presign) ──► PUT to object storage │ ▼ PendingAttachment with the public URL │ composer send ◄────────────────────────────────────┘ │ └─► send() emits a content part with the URL │ ▼ AI SDK passes URL to the model ``` Three ideas to internalize before reading the code: 1. **`add` uploads, returns `requires-action`.** The composer holds the file with the user's other input. 2. **`send` finalizes.** When the user submits, you mark the attachment `complete` and emit a `content` part with a stable URL. 3. **`remove` deletes.** Optional. Runs only if the user removes the attachment from the composer before sending; messages already sent are immutable. ## Setup \[#setup] ### Build the presign endpoint \[#build-the-presign-endpoint] The browser cannot mint upload credentials safely; the server creates a short-lived presigned URL. The example below uses S3, but R2, GCS, and Vercel Blob have nearly identical shapes. ```sh title=".env.local" AWS_REGION=us-east-1 S3_BUCKET=my-chat-uploads AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... ``` ```ts title="app/api/upload/route.ts" import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { auth } from "@/auth"; import { generateId } from "ai"; const s3 = new S3Client({ region: process.env.AWS_REGION }); export async function POST(req: Request) { const session = await auth(); if (!session?.user) return new Response(null, { status: 401 }); const { name, contentType } = (await req.json()) as { name: string; contentType: string; }; const key = `chat-uploads/${generateId()}-${name}`; const url = await getSignedUrl( s3, new PutObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: key, ContentType: contentType, }), { expiresIn: 60 }, ); const publicUrl = `https://${process.env.S3_BUCKET}.s3.amazonaws.com/${key}`; return Response.json({ uploadUrl: url, publicUrl, key }); } ``` Authenticate the request here. A presigned URL with a 60-second expiry is still a write capability; only authenticated users should mint them. For `remove()` to work end to end, expose a delete route too: ```ts title="app/api/upload/[key]/route.ts" import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; import { auth } from "@/auth"; const s3 = new S3Client({ region: process.env.AWS_REGION }); export async function DELETE( _req: Request, { params }: { params: Promise<{ key: string }> }, ) { const session = await auth(); if (!session?.user) return new Response(null, { status: 401 }); const { key } = await params; await s3.send( new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET!, Key: decodeURIComponent(key), }), ); return new Response(null, { status: 204 }); } ``` ### Implement the adapter \[#implement-the-adapter] ```tsx title="app/runtime/attachment-adapter.ts" import type { AttachmentAdapter, PendingAttachment, CompleteAttachment, } from "@assistant-ui/react"; type Pending = PendingAttachment & { key: string; url: string }; export const attachmentAdapter: AttachmentAdapter = { accept: "image/*,application/pdf", async add({ file }) { const presign = await fetch("/api/upload", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: file.name, contentType: file.type }), }).then((r) => r.json()); const put = await fetch(presign.uploadUrl, { method: "PUT", headers: { "content-type": file.type }, body: file, }); if (!put.ok) throw new Error(`upload failed: ${put.status}`); const pending: Pending = { id: presign.key, type: file.type.startsWith("image/") ? "image" : "document", name: file.name, contentType: file.type, file, url: presign.publicUrl, key: presign.key, status: { type: "requires-action", reason: "composer-send" }, }; return pending; }, async send(attachment): Promise { const { url, type, name, contentType } = attachment as Pending; const content = type === "image" ? [{ type: "image" as const, image: url }] : [ { type: "file" as const, filename: name, mimeType: contentType ?? "application/octet-stream", data: url, }, ]; return { ...attachment, status: { type: "complete" }, content }; }, async remove(attachment) { await fetch(`/api/upload/${(attachment as Pending).key}`, { method: "DELETE", }); }, }; ``` The shape of `content` matches the AI SDK part types. Use `image` for images and `file` for everything else; AI SDK forwards them to multimodal models that accept URL-based content. ### Wire it into the runtime \[#wire-it-into-the-runtime] ```tsx title="app/runtime/MyProvider.tsx" "use client"; import { AssistantRuntimeProvider } from "@assistant-ui/react"; import { useChatRuntime } from "@assistant-ui/react-ai-sdk"; import { attachmentAdapter } from "./attachment-adapter"; export function MyProvider({ children }: { children: React.ReactNode }) { const runtime = useChatRuntime({ adapters: { attachments: attachmentAdapter }, }); return ( {children} ); } ``` The composer paperclip button appears automatically. The `accept` string filters the file picker. ### Run and verify \[#run-and-verify] Pick a file. Check: * Network tab shows `POST /api/upload` returning a presigned URL, then a `PUT` to the storage host. * The composer shows a thumbnail / chip while the file is in the `requires-action` state. * Submitting the message sends the URL (not the file bytes) in the request body to `/api/chat`. * Object storage has the file under `chat-uploads/...`. ## Variants \[#variants] The `add()` body is the only thing that changes per provider. Tabs below show the upload step; everything else (presign endpoint, `send`, `remove`, runtime wiring) is identical. The example above. R2 is API-compatible with S3, so swap the endpoint: ```ts const s3 = new S3Client({ region: "auto", endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`, credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID!, secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!, }, }); ``` Vercel Blob uses client uploads with a server-issued token, not raw presigned PUTs. ```ts title="app/api/upload/route.ts" import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; export async function POST(req: Request) { const body = (await req.json()) as HandleUploadBody; const json = await handleUpload({ body, request: req, onBeforeGenerateToken: async () => ({ allowedContentTypes: ["image/*", "application/pdf"], }), onUploadCompleted: async () => {}, }); return Response.json(json); } ``` In the adapter's `add`, call `upload(name, file, { access: "public", handleUploadUrl: "/api/upload" })` from `@vercel/blob/client` and use the returned `url` for `publicUrl`. Uploadthing wraps both halves. Define a file router on the server, then call `useUploadThing` from the adapter. ```ts title="app/api/uploadthing/core.ts" import { createUploadthing, type FileRouter } from "uploadthing/next"; const f = createUploadthing(); export const ourFileRouter = { chatAttachment: f({ image: { maxFileSize: "8MB" }, pdf: { maxFileSize: "16MB" } }) .middleware(async () => ({ /* auth */ })) .onUploadComplete(async () => {}), } satisfies FileRouter; ``` In the adapter's `add`, call Uploadthing's client upload and read `url` from the result. The `send`/`remove` shape is unchanged. ## Notes \[#notes] * **Persistence.** A URL the model sees on Monday must still resolve next week if the thread is reloaded. Either use storage with no expiry on the public URL, or have your [history adapter](/docs/integrations/persistence/custom-adapter) regenerate signed URLs on `load`. Don't store presigned URLs in the message row. * **Cleanup.** `remove` runs only if the user dismisses the attachment before sending. Files in sent messages are kept; deleting them later breaks message rendering. * **`accept` string.** Comma-separated MIME types or extensions, including wildcards (`image/*`). The composer file picker uses this directly. To handle multiple type families with different upload paths, use [`CompositeAttachmentAdapter`](/docs/runtimes/concepts/adapters#attachment-adapter). * **Server-side validation.** Even with presigning, validate `contentType` and file size on the server. The browser controls what it sends; trust nothing. ## Related \[#related]