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.
Reading files
Wait for status: "done", then read bytes through useRectAttachments() by
attachment id. The src in $attachments is a stable, canonical reference;
the host delivers rotating access credentials separately to the runtime.
Credential renewal does not change the view model or notify its subscribers.
import { useEffect, useState } from 'react';
import { useRectAttachments } from '@rectsh/rect/react';
function TextAttachment({ attachment }: { attachment: RectAttachment }) {
const attachments = useRectAttachments();
const [text, setText] = useState('');
useEffect(() => {
if (attachment.status !== 'done') return;
let cancelled = false;
void attachments
.fetch(attachment.id)
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then((body) => {
if (!cancelled) setText(body);
});
return () => {
cancelled = true;
};
}, [attachment.id, attachment.status, attachments]);
return <pre>{text}</pre>;
}Use attachment.id or a content checksum as the dependency and cache key for
PDF/PPTX parsing, thumbnails, and other expensive rendering. Never key work by
resolveUrl() or src; access URLs are intentionally short-lived. For a link
or media API that requires a URL, call attachments.resolveUrl(id) immediately
before use. Prefer attachments.fetch(id) when the consumer accepts bytes.
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 upload files directly from the sandboxed iframe. Ask the
parent host shell to run the attachment pipeline. Omitting files keeps the
picker and file bytes entirely host-owned:
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 picker,
validation, upload, and $attachments updates. The iframe receives the result
and normal state snapshots, not the selected File.
Drop targets
A trusted view can forward files captured by the current drag or paste event.
Copy the FileList before leaving the event handler, then use the same host
request:
function AttachmentDropzone() {
const requestAttachmentUpload = useRectAttachmentUpload();
return (
<div
onDragOver={(event) => {
if (!event.dataTransfer.types.includes('Files')) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
}}
onDrop={(event) => {
if (!event.dataTransfer.types.includes('Files')) return;
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
void requestAttachmentUpload({
files,
accept: 'image/*,.pdf,text/plain',
maxSize: 10 * 1024 * 1024,
});
}}
>
Drop a file here
</div>
);
}The host rejects non-File payloads and rechecks multiple, empty files,
maxSize, and accept before entering the normal authenticated server and
Storage flow. Keep a host-picker button as the keyboard, mobile, and
accessibility fallback.
Multi-file requests upload sequentially and stop at the first failure. A
failure after earlier files commit returns ok: true, the committed
attachmentIds, partial: true, and an explanatory message. Keep those ids
instead of retrying the whole batch; retry only the
files that failed.
If completion commits but the latest state snapshot is temporarily unavailable,
the SDK keeps the request pending while it recovers at least the committed
revision. If recovery ultimately disconnects, the result uses
state_unavailable and still carries the committed attachmentIds and
revision; reload before retrying so those files are not uploaded twice.
Passing files changes the trust boundary: the hosted view can read those
bytes because the browser delivered them to its drop or paste handler. Only
pass File objects from the current human event. Do not synthesize files,
retain them, or initiate background uploads. The host cannot prove that event
provenance, so only trusted views should use direct drop targets.
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.
- Short-lived attachment credentials live in the runtime and renew without a view-model snapshot.
- The content route serves files through the Rect host:
/api/rect/<instanceId>/attachments/<attachmentId>/<fileName>. - Host-picker uploads never send
File,ArrayBuffer, or base64 payloads to the iframe. Direct drop targets expose only the files the browser delivered to that trusted view; the host never sends file bytes back. - Render-host API access stays narrow: attachment content
GETis allowed, but mutation endpoints remain host-owned.