Attachments flow
<kai-prompt-input> ships with an attach button. Click the paperclip, pick a file, send — kai-submit delivers { value, attachments } together, and the user’s <kai-message> renders the files inline. You own the upload logic; AI/UI handles the staging and display.
The paperclip, the removable chips, and the staged-file state are all built in. You don’t wire an upload component for the common case — you just read kai-submit.
How it works
Section titled “How it works”1 — Read the payload from kai-submit
The composer stages files internally as the user picks them. On submit, kai-submit carries the staged list alongside the text — always an array, even when empty:
import '@kitn.ai/ui/elements';
const prompt = document.getElementById('prompt');
prompt.addEventListener('kai-submit', async (e) => { const { value, attachments } = e.detail; // attachments: AttachmentData[] — always an array, never undefined
const aId = crypto.randomUUID(); chat.messages = [ ...chat.messages, { id: crypto.randomUUID(), role: 'user', parts: [ // one `file` part per attachment → shown inline on the message ...attachments.map((a) => ({ type: 'file', attachment: a })), ...(value ? [{ type: 'text', text: value }] : []), ], }, { id: aId, role: 'assistant', parts: [] }, ]; chat.loading = true;
// Forward value + attachments to your model API, then stream the reply. let answer = ''; for await (const token of streamFromModel({ value, attachments })) { answer += token; chat.messages = chat.messages.map((m) => m.id === aId ? { ...m, parts: [{ type: 'text', text: answer }] } : m, ); } chat.loading = false;});<kai-chat> carries the same composer and fires the same kai-submit — the paperclip works there with zero extra setup too.
2 — Render attachments on the user message
Add a { type: 'file', attachment } part per file to the message’s parts array. <kai-chat> and <kai-message> render consecutive file parts inline beneath the message text — the same display the <kai-attachments> element uses — with no extra wiring:
// The message shape — file parts are optional; omit them for text-only turns.const userMsg = { id: crypto.randomUUID(), role: 'user', parts: [ { type: 'file', attachment: { id: 'a1', type: 'file', filename: 'design-spec.pdf', mediaType: 'application/pdf' } }, { type: 'file', attachment: { id: 'a2', type: 'file', filename: 'screenshot.png', mediaType: 'image/png' } }, { type: 'text', text: value }, ],};AttachmentData shape:
| Field | Required | Notes |
|---|---|---|
id | Yes | Stable identifier — used for removals via the chip’s remove button |
type | Yes | 'file' or 'source-document' |
filename | No | Shown as the chip label |
mediaType | No | MIME type — drives the icon (image / video / audio / document) |
url | No | data: URI or an https URL — previews in the hover card, and it is the field the wire encodes. Not URL.createObjectURL: see below |
title | No | Display name for source-document attachments |
url is what reaches the model
Section titled “url is what reaches the model”Set it to a data: URI or an https URL, on every file rather than only images.
Do not use URL.createObjectURL. An object URL resolves only inside the tab that minted it, so it renders a flawless thumbnail and is meaningless to any provider — toOpenAIMessages and toAnthropicMessages reject a blob: URL rather than send an address the model cannot fetch. An attachment with no url at all is unencodable for the same reason, which is why a document needs one just as much as an image does.
<kai-prompt-input>’s built-in paperclip already does this for you; you only need the conversion below when you stage files yourself.
Pre-populating staged files
Section titled “Pre-populating staged files”Assign prompt.attachments in JavaScript after mount to seed the composer before the user types — for files already linked to this conversation, say. The element manages its own staged list from there: the user can add more with the paperclip, remove chips, and kai-submit always delivers the current state:
prompt.attachments = [ { id: 'ctx-1', type: 'file', filename: 'brief.pdf', mediaType: 'application/pdf', // Seeded files need a `url` too, or they render as chips and reach the // model as nothing. An https URL is the natural one here. url: 'https://cdn.example.com/brief.pdf', },];Optional: a dedicated drop zone
Section titled “Optional: a dedicated drop zone”When you want a large drop target for a whole page or panel — not just the composer’s paperclip — add a standalone <kai-file-upload> and feed its files into the composer. The round-trip from kai-submit onward is identical; you’re only changing how files get staged.
<kai-file-upload> fires kai-files-added when the user picks or drops files. Convert each File to an AttachmentData and append it to prompt.attachments:
const upload = document.getElementById('upload');const prompt = document.getElementById('prompt');
/** Every file, as a `data:` URI. NOT URL.createObjectURL: an object URL renders * a perfect thumbnail here and resolves only inside the tab that minted it, so * `toOpenAIMessages` / `toAnthropicMessages` refuse it rather than send the * model an address it can never fetch. A data URI previews identically and is * the form both providers actually take. */const readAsDataUrl = (file) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(reader.error ?? new Error(`Could not read ${file.name}`)); reader.readAsDataURL(file); });
upload.addEventListener('kai-files-added', async (e) => { // Snapshot before the first await: the upload element is free to clear its // own list as soon as the handler yields. const files = Array.from(e.detail.files ?? []); if (!files.length) return;
const added = await Promise.all( files.map(async (f) => ({ id: crypto.randomUUID(), type: 'file', filename: f.name, mediaType: f.type || undefined, // Every file, not just images. A document with no `url` is unencodable // for exactly the same reason a blob: URL is. url: await readAsDataUrl(f), })), );
// Re-read AFTER the await rather than reusing a value captured before it, so // a second drop landing mid-read is appended to instead of overwritten. prompt.attachments = [...(prompt.attachments ?? []), ...added];});The drop zone and the paperclip stack: files from either path land in the same staged list and ride along on the next kai-submit.
Next steps
Section titled “Next steps”kai-prompt-inputreference —attachmentsproperty, the built-in paperclip,kai-submitdetail,loading,stoppable.kai-attachmentsreference — standalone display ofAttachmentData[]withvariant,hoverCard, andremovable.kai-file-uploadreference —accept,multiple,kai-files-added.kai-messagereference — fullChatMessageshape, includingfileparts.- Drop-in chat — the complete
kai-submitstreaming loop with<kai-chat>.