Attachments
Introduction
Section titled “Introduction”A user can attach images to a message. The client uploads each file in a separate request and sends only references with the message. The server keeps the bytes behind an AttachmentStore, and the conversation checkpoint keeps references, never bytes. This page covers both halves: the client that picks and shows files, and the server store.
Letting users attach files
Section titled “Letting users attach files”Create an uploader, hold the picked files with useAttachments, and wire them into the composer. send(text, references) puts the references in the run.
import { createAttachmentUploader, useAttachments } from '@pilotoo/chat-core';import { AttachmentAction, AttachmentChips, AttachmentPickerProvider, Composer } from '@pilotoo/chat-ui';
const upload = createAttachmentUploader(apiUrl, fetch, { credentials });
function Screen() { const a = useAttachments(upload); return ( <Composer actions={<AttachmentAction onPick={a.add} disabled={a.full} />} header={<AttachmentChips items={a.items} onRemove={a.remove} />} canSendWithoutText={a.references.length > 0} submitBlocked={a.uploading} onSubmit={(text) => send(text, a.references) && a.clear()} /> );}
<AttachmentPickerProvider pick={pickFromPhotoLibrary}>{app}</AttachmentPickerProvider>;The mobile demo app does this with the photo library. apps/mobile/src/app/_layout.tsx wraps the tree in AttachmentPickerProvider and AttachmentImageProvider.
useAttachments refuses a file the server would refuse before uploading it. The limits are exported: ATTACHMENT_MAX_BYTES, ATTACHMENT_MEDIA_TYPES and MAX_ATTACHMENTS_PER_RUN. Files are held in memory, not with the draft, because a restored chip could point at an upload the server no longer has.
The picker
Section titled “The picker”pickFromPhotoLibrary is a PickAttachments. It resolves to AttachmentFile[] (name, mediaType, size, body, previewUri), or to nothing when the reader cancels.
A DOM host supplies nothing: the browser’s file dialog is the default. @pilotoo/ui/chat takes the same port, so under its AttachmentPickerProvider the AttachmentButton opens your picker instead of the file dialog.
Showing attachments
Section titled “Showing attachments”Show what a message carried with MessageList’s renderFooter and MessageAttachments. A thumbnail is drawn when you supply a ResolveAttachmentImage through AttachmentImageProvider. Otherwise, or when an image cannot be fetched, the file name and type are shown.
The API answers only to a bearer token, so the browser cannot load the image from a plain <img src>. Pick the resolver for your platform:
| Resolver | For | Returns |
|---|---|---|
createAttachmentImageResolver(apiUrl, { credentials }) |
React Native | { uri, headers }, which <Image> sends itself |
createBlobAttachmentImageResolver(apiUrl, fetch, { credentials }) |
DOM | An object URL; release() revokes it |
The demo web app builds the blob resolver next to its other catalogs:
<AttachmentImageProvider resolve={createBlobAttachmentImageResolver(apiUrl, fetch, { credentials })}> {app}</AttachmentImageProvider>Storing uploads on the server
Section titled “Storing uploads on the server”Choose where the bytes live.
| Store | Use it for |
|---|---|
createS3AttachmentStore(options) |
Any S3-compatible bucket. |
createFileAttachmentStore(directory) |
The same contract on a folder. The local backend uses it. |
Your own AttachmentStore |
put, stat, get and markUsed, each naming the owner. |
With PilotooModule, pass the attachmentStore option, or set ATTACHMENTS_S3_BUCKET and the other ATTACHMENTS_S3_* variables. With no ATTACHMENTS_S3_BUCKET the store is off, and null turns it off whatever the environment says.
Outside the module, give the store to the runner:
import { createS3AttachmentStore } from '@pilotoo/agent-runtime';
const runner = new AgentRunner(saver, models, { ...options, attachments: createS3AttachmentStore({ bucket: 'my-attachments', region: 'us-east-1', endpoint, credentials }),});Endpoints
Section titled “Endpoints”| Route | Behavior |
|---|---|
POST /attachments |
One non-empty file part, filed under the caller’s identity. 201 with the reference; 400 missing or empty file; 413 over the size limit; 415 type not an allowed image or not matching the bytes; 503 no store configured. |
GET /attachments/:id |
The bytes, to the identity that uploaded them, so the bucket stays private. 400 for an id that is not a UUID; 404 for one that is missing or another caller’s, alike; 503 no store. |
Reads answer the stored Content-Type, Cache-Control: private, max-age=31536000, immutable and an inline Content-Disposition with the stored name. The limits live in packages/chat-protocol/src/index.ts.
In a run
Section titled “In a run”run:start takes attachments, at most MAX_ATTACHMENTS_PER_RUN references, and the text may be empty when there is one. A reference the caller does not own is refused as one that does not exist.
Expiring unused uploads
Section titled “Expiring unused uploads”put tags the object pilotoo-state=pending. A run that admits it turns the tag to used. The app never deletes anything, so a production bucket needs a lifecycle rule that removes uploads nobody sent. Save this as lifecycle.json:
{ "Rules": [ { "ID": "expire-pending-attachments", "Status": "Enabled", "Filter": { "Tag": { "Key": "pilotoo-state", "Value": "pending" } }, "Expiration": { "Days": 1 } } ]}Apply it, for example, with aws s3api put-bucket-lifecycle-configuration --bucket <bucket> --lifecycle-configuration file://lifecycle.json. The credentials need s3:PutObjectTagging beside the object read and write actions.
Next steps
Section titled “Next steps”- Models for which providers accept images.
- Storage for how the local backend keeps uploads on disk.
- Editing and regenerating reuses attachments when a message is edited.