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.

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:

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

On this page