Skip to content
Pilotoo
v0.1

Attachments

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.

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.

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.

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>

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 }),
});
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.

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.

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.