Attachments
Upload files to a rect without putting bytes in the view model.
Attachments are files that belong to one issued rect instance. They are not
part of the view bundle, and their bytes never live in view_model.
The host stores file bytes in private Storage, records metadata in Postgres, and
then writes a small reference into the reserved $attachments namespace. Open
views receive that update through the same live snapshot channel as every other
state change.
The model
$attachments is host-owned state. View authors should read it, but they should
not use the same field name for their own domain model. Raw view-model patches
and action handlers that change this namespace are rejected with
code: "reserved_key"; attachment upload APIs are the only writer.
type AttachmentStatus = 'uploading' | 'done' | 'error';
interface RectAttachmentRegistry {
version: 1;
order: string[];
items: Record<string, RectAttachment>;
}
interface RectAttachment {
id: string;
status: AttachmentStatus;
name: string;
contentType: string;
size: number;
kind: 'image' | 'pdf' | 'download';
createdAt: string;
updatedAt: string;
uploadedBytes?: number;
progress?: number;
src?: string;
error?: { code: string; message: string };
}Example:
{
"$attachments": {
"version": 1,
"order": ["att_123"],
"items": {
"att_123": {
"id": "att_123",
"status": "done",
"name": "report.txt",
"contentType": "text/plain",
"size": 41,
"kind": "download",
"progress": 1,
"uploadedBytes": 41,
"src": "/api/rect/rect_abc/attachments/att_123/report.txt",
"createdAt": "2026-07-06T00:00:00.000Z",
"updatedAt": "2026-07-06T00:00:01.000Z"
}
}
}
}The registry is a map plus an order array because view-model sync uses JSON Merge Patch. Arrays replace wholesale; object keys let the host update one attachment's status without rewriting the whole list.
Upload lifecycle
Every upload follows the same state transition:
uploading -> done
uploading -> erroruploading appears as soon as the host creates an upload record. done appears
after the bytes are present in Storage and the host commits the final
attachment ref. error is used for validation, upload, or completion failures.
The view does not need to poll. It subscribes to the store, and the host sends a
new snapshot whenever $attachments changes.
Rendering files
Use the src only when status is done.
function AttachmentList({
state,
}: {
state: { $attachments?: RectAttachmentRegistry };
}) {
const registry = state.$attachments;
const attachments = (registry?.order ?? [])
.map((id) => registry?.items[id])
.filter(Boolean) as RectAttachment[];
return (
<ul>
{attachments.map((attachment) => (
<li key={attachment.id}>
{attachment.status === 'uploading' ? (
<span>
Uploading {attachment.name}{' '}
{Math.round((attachment.progress ?? 0) * 100)}%
</span>
) : attachment.status === 'error' ? (
<span>
{attachment.name}: {attachment.error?.message}
</span>
) : attachment.kind === 'image' ? (
<img src={attachment.src} alt={attachment.name} />
) : (
<a href={attachment.src} download={attachment.name}>
{attachment.name}
</a>
)}
</li>
))}
</ul>
);
}Attachment URLs are root-relative:
/api/rect/<instanceId>/attachments/<attachmentId>/<sanitizedFileName>The filename is for human-readable links and browser save-as behavior. Lookup is
by instanceId and attachmentId; the server does not trust the filename.
MCP uploads
For agents, prefer signed uploads:
rect_create_attachment_upload({
instanceId: "...",
fileName: "report.txt",
contentType: "text/plain",
size: 41
})The tool returns:
attachmentwithstatus: "uploading"revisionbucketstoragePathtokensignedUrlcontentType
Upload the bytes to the returned Storage ticket, then complete it:
rect_complete_attachment_upload({
instanceId: "...",
attachmentId: "..."
})Completion verifies the Storage object, marks the DB row ready, writes
status: "done" plus src into $attachments, and returns the new revision.
For small smoke tests, agents can still use the JSON-only convenience tool:
rect_upload_attachment({
instanceId: "...",
fileName: "report.txt",
contentType: "text/plain",
dataBase64: "aGVsbG8K"
})Use base64 only when the file is small enough that putting the bytes in a JSON tool call is acceptable.
CLI uploads
The CLI uses the signed-upload path by default:
rect attachment upload <rect-id> ./report.txt --jsonIt creates the upload ticket, uploads bytes directly to Storage, completes the
upload, and prints the final attachment id, src, and revision.
Against the local dev bridge, use the reserved dev id:
rect attachment upload dev ./report.txt --dev --jsonThe Vite dev host stores the file in memory and writes the same $attachments
shape into the dev store.
Browser uploads
A view should not open its own file input and upload bytes from inside the sandboxed iframe. Instead, ask the parent host shell to do it:
import { useRectAttachmentUpload } from '@rectsh/rect/react';
function AttachButton() {
const requestAttachmentUpload = useRectAttachmentUpload();
return (
<button
onClick={() =>
void requestAttachmentUpload({
accept: 'image/*,.pdf,text/plain',
maxSize: 10 * 1024 * 1024,
})
}
>
Attach file
</button>
);
}The iframe sends an upload request to the parent. The parent owns the file
picker, validation, bytes, upload, and $attachments updates. The iframe only
receives normal state snapshots.
Domain linking
Uploading a file is platform state, not view-owned business logic. Do not call a
view action just to put the attachment in $attachments; the host has already
done that.
Use actions only when the view needs domain meaning:
import { defineActions } from '@rectsh/rect/actions';
export default defineActions({
attachToIssue: {
inputSchema: {
type: 'object',
properties: {
issueId: { type: 'string' },
attachmentId: { type: 'string' },
},
required: ['issueId', 'attachmentId'],
additionalProperties: false,
},
handler: (state, input, ctx) => {
const attachment = state.$attachments?.items?.[input.attachmentId];
if (!attachment || attachment.status !== 'done') {
ctx.reject(
'ATTACHMENT_NOT_READY',
'Upload the file before linking it.',
);
}
state.issueAttachments ??= {};
state.issueAttachments[input.issueId] ??= [];
state.issueAttachments[input.issueId].push(input.attachmentId);
},
},
});That keeps the binary metadata in $attachments and the view's domain model in
its own fields.
Security boundary
- Storage is private.
- The view model stores references, not bytes or signed URLs.
- The content route serves files through the Rect host:
/api/rect/<instanceId>/attachments/<attachmentId>/<fileName>. - The iframe never receives
File,ArrayBuffer, or base64 payloads from a human upload. - Render-host API access stays narrow: attachment content
GETis allowed, but mutation endpoints remain host-owned.