rect.sh

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 -> error

uploading 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:

  • attachment with status: "uploading"
  • revision
  • bucket
  • storagePath
  • token
  • signedUrl
  • contentType

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 --json

It 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 --json

The 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 GET is allowed, but mutation endpoints remain host-owned.

On this page