UI components for attaching and viewing files in messages.
Note: These components provide the UI for attachments, but you also need to configure attachment adapters in your runtime to handle file uploads and processing. See the Attachments Guide for complete setup instructions.
Getting Started
Add attachment
npx shadcn@latest add @assistant-ui/attachmentThe @assistant-uinamespace resolves the Radix or Base UI flavor from your project's style through the style-aware registry entry in components.json. Without that entry, add by direct URL instead:
npx shadcn@latest add https://r.assistant-ui.com/base/attachment.jsonMain Component
npm install @assistant-ui/react zustand"use client";import { type PropsWithChildren, useEffect, useState, type FC, isValidElement,} from "react";import { XIcon, PlusIcon, FileText, Loader2Icon, AlertCircleIcon,} from "lucide-react";import { AttachmentPrimitive, ComposerPrimitive, MessagePrimitive, useAuiState, useAui,} from "@assistant-ui/react";import { useShallow } from "zustand/shallow";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip";import { Dialog, DialogTitle, DialogContent, DialogTrigger,} from "@/components/ui/dialog";import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";import { cn } from "@/lib/utils";const useFileSrc = (file: File | undefined) => { const [src, setSrc] = useState<string | undefined>(undefined); useEffect(() => { if (!file) { setSrc(undefined); return; } const objectUrl = URL.createObjectURL(file); setSrc(objectUrl); return () => { URL.revokeObjectURL(objectUrl); }; }, [file]); return src;};const useAttachmentSrc = () => { const { file, src } = useAuiState( useShallow((s): { file?: File; src?: string } => { if (s.attachment.type !== "image") return {}; if (s.attachment.file) return { file: s.attachment.file }; const src = s.attachment.content?.filter((c) => c.type === "image")[0] ?.image; if (!src) return {}; return { src }; }), ); return useFileSrc(file) ?? src;};type AttachmentPreviewProps = { src: string;};const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => { const [isLoaded, setIsLoaded] = useState(false); return ( <img src={src} alt="Attachment preview" className={cn( "block h-auto max-h-[80vh] w-auto max-w-full object-contain", isLoaded ? "aui-attachment-preview-image-loaded" : "aui-attachment-preview-image-loading invisible", )} onLoad={() => setIsLoaded(true)} /> );};const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => { const src = useAttachmentSrc(); if (!src) return children; return ( <Dialog> <DialogTrigger nativeButton={false} className="aui-attachment-preview-trigger hover:bg-accent/50 cursor-pointer transition-colors" render={isValidElement(children) ? children : <button type="button" />} /> <DialogContent className="aui-attachment-preview-dialog-content [&>button]:bg-foreground/60 [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0!"> <DialogTitle className="aui-sr-only sr-only"> Image Attachment Preview </DialogTitle> <div className="aui-attachment-preview bg-background relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden"> <AttachmentPreview src={src} /> </div> </DialogContent> </Dialog> );};const AttachmentThumb: FC = () => { const src = useAttachmentSrc(); return ( <Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none"> <AvatarImage src={src} alt="Attachment preview" className="aui-attachment-tile-image object-cover" /> <AvatarFallback> <FileText className="aui-attachment-tile-fallback-icon text-muted-foreground size-8" /> </AvatarFallback> </Avatar> );};const AttachmentUI: FC = () => { const aui = useAui(); const isComposer = aui.attachment.source !== "message"; const isImage = useAuiState((s) => s.attachment.type === "image"); const typeLabel = useAuiState((s) => { const type = s.attachment.type; switch (type) { case "image": return "Image"; case "document": return "Document"; case "file": return "File"; default: return type; } }); const uploadState = useAuiState((s) => s.attachment.status.type === "running" ? "uploading" : s.attachment.status.type === "incomplete" && s.attachment.status.reason === "error" ? "error" : undefined, ); const isUploading = uploadState === "uploading"; const isError = uploadState === "error"; const errorMessage = useAuiState((s) => s.attachment.status.type === "incomplete" && s.attachment.status.reason === "error" ? (s.attachment.status.message ?? "Upload failed") : undefined, ); return ( <TooltipProvider> <Tooltip> <AttachmentPrimitive.Root className={cn( "aui-attachment-root relative", isImage && !isComposer && "aui-attachment-root-message only:*:first:size-24", )} > <AttachmentPreviewDialog> <TooltipTrigger render={ <div className={cn( "aui-attachment-tile bg-muted relative size-14 cursor-pointer overflow-hidden rounded-[calc(var(--composer-radius)-var(--composer-padding))] border transition-opacity hover:opacity-75", isError && "border-destructive", )} role="button" tabIndex={0} aria-label={`${typeLabel} attachment${ isError ? ", upload failed" : isUploading ? ", uploading" : "" }`} /> } > <AttachmentThumb /> {isUploading && ( <div aria-hidden="true" className="aui-attachment-tile-uploading bg-background/60 absolute inset-0 flex items-center justify-center backdrop-blur-[1px]" > <Loader2Icon className="text-muted-foreground size-5 animate-spin" /> </div> )} {isError && ( <div aria-hidden="true" className="aui-attachment-tile-error bg-destructive/10 absolute inset-0 flex items-center justify-center" > <AlertCircleIcon className="text-destructive size-5" /> </div> )} </TooltipTrigger> </AttachmentPreviewDialog> {isComposer && <AttachmentRemove />} </AttachmentPrimitive.Root> <TooltipContent side="top"> <AttachmentPrimitive.Name /> {errorMessage && ( <p className="aui-attachment-error-message">{errorMessage}</p> )} </TooltipContent> </Tooltip> </TooltipProvider> );};const AttachmentRemove: FC = () => { return ( <AttachmentPrimitive.Remove render={ <TooltipIconButton tooltip="Remove file" className="aui-attachment-tile-remove text-muted-foreground hover:[&_svg]:text-destructive absolute end-1.5 top-1.5 size-3.5 rounded-full bg-white opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black" side="top" /> } > <XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" /> </AttachmentPrimitive.Remove> );};export const UserMessageAttachments: FC = () => { return ( <div className="aui-user-message-attachments-end col-span-full col-start-1 row-start-1 flex w-full flex-row justify-end gap-2"> <MessagePrimitive.Attachments> {() => <AttachmentUI />} </MessagePrimitive.Attachments> </div> );};export const ComposerAttachments: FC = () => { return ( <div className="aui-composer-attachments flex w-full flex-row items-center gap-2 overflow-x-auto empty:hidden"> <ComposerPrimitive.Attachments> {() => <AttachmentUI />} </ComposerPrimitive.Attachments> </div> );};export const ComposerAddAttachment: FC = () => { return ( <ComposerPrimitive.AddAttachment render={ <TooltipIconButton tooltip="Add Attachment" side="bottom" variant="ghost" size="icon" className="aui-composer-add-attachment hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30 size-7 rounded-full p-1 text-xs font-semibold" aria-label="Add Attachment" /> } > <PlusIcon className="aui-attachment-add-icon size-4.5 stroke-[1.5px]" /> </ComposerPrimitive.AddAttachment> );};assistant-ui dependencies
"use client";import { type ComponentPropsWithRef, forwardRef } from "react";import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger,} from "@/components/ui/tooltip";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";export type TooltipIconButtonProps = ComponentPropsWithRef<typeof Button> & { tooltip: string; side?: "top" | "bottom" | "left" | "right";};export const TooltipIconButton = forwardRef< HTMLButtonElement, TooltipIconButtonProps>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { return ( <TooltipProvider> <Tooltip> <TooltipTrigger render={ <Button variant="ghost" size="icon" {...rest} className={cn( "aui-button-icon size-6 p-1 active:scale-90", className, )} ref={ref} /> } > {children} <span className="aui-sr-only sr-only">{tooltip}</span> </TooltipTrigger> <TooltipContent side={side}>{tooltip}</TooltipContent> </Tooltip> </TooltipProvider> );});TooltipIconButton.displayName = "TooltipIconButton";shadcn/ui dependencies
npm install @base-ui/react"use client";import * as React from "react";import { Dialog as DialogPrimitive } from "@base-ui/react/dialog";import { XIcon } from "lucide-react";import { Button } from "@/components/ui/button";import { cn } from "@/lib/utils";function Dialog({ ...props }: DialogPrimitive.Root.Props) { return <DialogPrimitive.Root data-slot="dialog" {...props} />;}function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;}function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;}function DialogClose({ ...props }: DialogPrimitive.Close.Props) { return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;}function DialogOverlay({ className, ...props}: DialogPrimitive.Backdrop.Props) { return ( <DialogPrimitive.Backdrop data-slot="dialog-overlay" className={cn( "data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0 fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs", className, )} {...props} /> );}function DialogContent({ className, children, showCloseButton = true, ...props}: DialogPrimitive.Popup.Props & { showCloseButton?: boolean;}) { return ( <DialogPortal> <DialogOverlay /> <DialogPrimitive.Popup data-slot="dialog-content" className={cn( "bg-popover text-popover-foreground ring-foreground/10 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl p-4 text-sm ring-1 duration-100 outline-none sm:max-w-sm", className, )} {...props} > {children} {showCloseButton && ( <DialogPrimitive.Close data-slot="dialog-close" render={ <Button variant="ghost" className="absolute top-2 right-2" size="icon-sm" /> } > <XIcon /> <span className="sr-only">Close</span> </DialogPrimitive.Close> )} </DialogPrimitive.Popup> </DialogPortal> );}function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="dialog-header" className={cn("flex flex-col gap-2", className)} {...props} /> );}function DialogFooter({ className, showCloseButton = false, children, ...props}: React.ComponentProps<"div"> & { showCloseButton?: boolean;}) { return ( <div data-slot="dialog-footer" className={cn( "bg-muted/50 -mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t p-4 sm:flex-row sm:justify-end", className, )} {...props} > {children} {showCloseButton && ( <DialogPrimitive.Close render={<Button variant="outline" />}> Close </DialogPrimitive.Close> )} </div> );}function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { return ( <DialogPrimitive.Title data-slot="dialog-title" className={cn( "cn-font-heading text-base leading-none font-medium", className, )} {...props} /> );}function DialogDescription({ className, ...props}: DialogPrimitive.Description.Props) { return ( <DialogPrimitive.Description data-slot="dialog-description" className={cn( "text-muted-foreground *:[a]:hover:text-foreground text-sm *:[a]:underline *:[a]:underline-offset-3", className, )} {...props} /> );}export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger,};"use client";import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip";import { cn } from "@/lib/utils";function TooltipProvider({ delay = 0, ...props}: TooltipPrimitive.Provider.Props) { return ( <TooltipPrimitive.Provider data-slot="tooltip-provider" delay={delay} {...props} /> );}function Tooltip({ ...props }: TooltipPrimitive.Root.Props) { return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;}function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) { return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;}function TooltipContent({ className, side = "top", sideOffset = 4, align = "center", alignOffset = 0, children, ...props}: TooltipPrimitive.Popup.Props & Pick< TooltipPrimitive.Positioner.Props, "align" | "alignOffset" | "side" | "sideOffset" >) { return ( <TooltipPrimitive.Portal> <TooltipPrimitive.Positioner align={align} alignOffset={alignOffset} side={side} sideOffset={sideOffset} className="isolate z-50" > <TooltipPrimitive.Popup data-slot="tooltip-content" className={cn( "bg-foreground text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95 z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md px-3 py-1.5 text-xs has-data-[slot=kbd]:pr-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm", className, )} {...props} > {children} <TooltipPrimitive.Arrow data-slot="tooltip-arrow" className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" /> </TooltipPrimitive.Popup> </TooltipPrimitive.Positioner> </TooltipPrimitive.Portal> );}export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };"use client";import * as React from "react";import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar";import { cn } from "@/lib/utils";function Avatar({ className, size = "default", ...props}: AvatarPrimitive.Root.Props & { size?: "default" | "sm" | "lg";}) { return ( <AvatarPrimitive.Root data-slot="avatar" data-size={size} className={cn( "group/avatar after:border-border relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten", className, )} {...props} /> );}function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { return ( <AvatarPrimitive.Image data-slot="avatar-image" className={cn( "aspect-square size-full rounded-full object-cover", className, )} {...props} /> );}function AvatarFallback({ className, ...props}: AvatarPrimitive.Fallback.Props) { return ( <AvatarPrimitive.Fallback data-slot="avatar-fallback" className={cn( "bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs", className, )} {...props} /> );}function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { return ( <span data-slot="avatar-badge" className={cn( "bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none", "group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden", "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", className, )} {...props} /> );}function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="avatar-group" className={cn( "group/avatar-group *:data-[slot=avatar]:ring-background flex -space-x-2 *:data-[slot=avatar]:ring-2", className, )} {...props} /> );}function AvatarGroupCount({ className, ...props}: React.ComponentProps<"div">) { return ( <div data-slot="avatar-group-count" className={cn( "bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", className, )} {...props} /> );}export { Avatar, AvatarImage, AvatarFallback, AvatarGroup, AvatarGroupCount, AvatarBadge,};import { Button as ButtonPrimitive } from "@base-ui/react/button";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "@/lib/utils";const buttonVariants = cva( "group/button focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:ring-3 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:ring-3 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/80", outline: "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground aria-expanded:bg-secondary aria-expanded:text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]", ghost: "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", icon: "size-8", "icon-xs": "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "icon-lg": "size-9", }, }, defaultVariants: { variant: "default", size: "default", }, },);function Button({ className, variant = "default", size = "default", ...props}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { return ( <ButtonPrimitive data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} /> );}export { Button, buttonVariants };This adds a /components/assistant-ui/attachment.tsx file to your project, which you can adjust as needed.
Use in your application
import {
ComposerAttachments,
ComposerAddAttachment,
} from "@/components/assistant-ui/attachment";
const Composer: FC = () => {
return (
<ComposerPrimitive.Root className="...">
<ComposerAttachments />
<ComposerAddAttachment />
<ComposerPrimitive.Input
autoFocus
placeholder="Write a message..."
rows={1}
className="..."
/>
<ComposerAction />
</ComposerPrimitive.Root>
);
};import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
const UserMessage: FC = () => {
return (
<MessagePrimitive.Root className="...">
<UserActionBar />
<UserMessageAttachments />
<div className="...">
<MessagePrimitive.Parts />
</div>
<BranchPicker className="..." />
</MessagePrimitive.Root>
);
};API Reference
Composer Attachments
ComposerPrimitive.Attachments
Renders all attachments in the composer.
ComposerPrimitiveAttachmentsPropscomponents?: AttachmentComponentsComponents to render for different attachment types.
Image?: ComponentTypeComponent for image attachments.
Document?: ComponentTypeComponent for document attachments (PDF, etc.).
File?: ComponentTypeComponent for generic file attachments.
Attachment?: ComponentTypeFallback component for all attachment types.
ComposerPrimitive.AddAttachment
A button that opens the file picker to add attachments.
ComposerPrimitiveAddAttachmentPropsmultiple: boolean= trueAllow selecting multiple files at once.
asChild: boolean= falseMerge props with child element instead of rendering a wrapper button.
This primitive renders a <button> element unless asChild is set.
Message Attachments
MessagePrimitive.Attachments
Renders all attachments in a user message.
MessagePrimitiveAttachmentsPropscomponents?: AttachmentComponentsComponents to render for different attachment types (same as ComposerPrimitive.Attachments).
Attachment Primitives
AttachmentPrimitive.Root
Container for a single attachment.
AttachmentPrimitiveRootPropsasChild: boolean= falseMerge props with child element instead of rendering a wrapper div.
AttachmentPrimitive.Name
Renders the attachment's file name.
AttachmentPrimitive.Remove
A button to remove the attachment from the composer.
AttachmentPrimitiveRemovePropsasChild: boolean= falseMerge props with child element instead of rendering a wrapper button.
Attachment Types
Attachments have the following structure:
type Attachment = {
id: string;
type: "image" | "document" | "file" | (string & {});
name: string;
contentType?: string;
file?: File;
status:
| { type: "running" | "requires-action" | "incomplete"; progress?: number }
| { type: "complete" };
};The type field accepts custom strings (e.g. "data-workflow") beyond the built-in types. When an unknown type is encountered, the generic Attachment component is used as a fallback. The contentType field is optional — it can be omitted for non-file attachments where a MIME type is not meaningful.
Related Components
- Thread - Main chat interface that displays attachments
- Attachments Guide - Complete setup instructions for attachment adapters