Feature Plan

Image Attachments for Issues & Comments

Architectural exploration for embedding screenshots and images in FP issues and comments, across CLI, desktop app, and synced cloud projects. Local-first storage with content-addressable sync to R2.

fp-core fp (CLI) desktop console API fp-ui sync protocol
1 The Problem

Before & After

Current behavior
1. User takes a screenshot of a bug
2. Has no way to attach it to an fp issue
3. Pastes into Slack/Discord instead
4. Context is lost — image lives outside the issue tracker
5. Team members syncing the project only get text
6. Markdown descriptions and comments are text-only
Desired behavior
1. User takes a screenshot
2. fp attach NOXR-abc screenshot.png
3. Image stored locally in .fp/blobs/ by content hash
4. Ydoc updated with attachment metadata reference
5. On sync: blob uploaded to R2, metadata syncs via Yjs
6. Other clients download missing blobs after Yjs sync
7. Desktop/web renders inline images in issue views
2 Architecture Options

Four approaches, one clear winner

Every local-first system with binary attachments faces the same fundamental question: do you put the bytes inside the CRDT, or outside it? The answer is nearly always "outside," but the details of how matter enormously.

Not recommended

B. Inline Base64 in Ydoc

Encode images as base64 strings directly inside Y.Map entries within the Ydoc. Syncs through existing protocol.

  • Simplest implementation — no new storage layer
  • Atomic consistency (image + metadata in same doc)
  • Ydoc bloat: 1MB image = 1.37MB base64 in every update
  • CRDT history is append-only, so old images never GC
  • Sync becomes very slow for image-heavy projects
  • Breaks hash-based skip detection
Considered

C. WebSocket Blob Channel

Extend the sync protocol with new message types (BLOB_PUSH, BLOB_PULL) to transfer binary data over the same WebSocket.

  • Unified transport (single connection)
  • Can multiplex with Yjs sync messages
  • Large blobs block Yjs message processing
  • WebSocket not ideal for large file transfer
  • Complex protocol changes in both DO and client
  • Harder to resume interrupted transfers
Considered

D. Git-Annex Pattern

Store "pointer files" in the Ydoc that describe the blob (hash, size, URL). HTTP-based blob transfer with smudge/clean semantics.

  • Familiar model for developers
  • Composable with different backends
  • Effectively the same as Option A, but with more complexity
  • Pointer file abstraction adds indirection without benefit here
  • Git-annex semantics don't map well to CRDTs
Industry consensus: keep blobs outside the CRDT

Excalidraw uses SHA-1 content hashes with Firebase Storage. Notion treats images as blocks stored in S3. The Yjs community recommends storing binary data outside the CRDT and referencing by hash. Ink & Switch's research confirms this pattern for local-first systems. Option A follows all of these.

3 Recommended Architecture

Content-Addressable Blob Store

The core idea: images are identified by the SHA-256 hash of their bytes. The Ydoc stores only lightweight metadata (hash, filename, mime type, dimensions). Blob bytes live on the filesystem locally and in R2 remotely, synced via a simple HTTP API separate from the Yjs WebSocket protocol.

flowchart TD
    subgraph CLI["CLI / Desktop"]
        A["User attaches image"] --> B["SHA-256 hash bytes"]
        B --> C["Store in .fp/blobs/<hash>"]
        B --> D["Write metadata to Ydoc"]
        D --> E["attachments Y.Map"]
    end

    subgraph SYNC["Sync Layer"]
        E --> F["Yjs sync via WebSocket"]
        F --> G["Metadata arrives on server"]
        C --> H["Blob upload via HTTP PUT"]
        H --> I["R2: project/blobs/<hash>"]
    end

    subgraph REMOTE["Other Clients"]
        G --> J["Yjs sync delivers metadata"]
        J --> K["Client sees attachment ref"]
        K --> L{"Blob in local .fp/blobs?"}
        L -->|No| M["HTTP GET from R2"]
        M --> N["Store in .fp/blobs/<hash>"]
        L -->|Yes| O["Render from local cache"]
        N --> O
    end

    classDef cliNode fill:#1e3a5f22,stroke:#1e3a5f
    classDef syncNode fill:#b8860b22,stroke:#b8860b
    classDef remoteNode fill:#4a7c5922,stroke:#4a7c59
    class A,B,C,D,E cliNode
    class F,G,H,I syncNode
    class J,K,L,M,N,O remoteNode
    
End-to-end flow: attach locally, sync metadata via Yjs, transfer blobs via HTTP/R2
4 Data Model

Ydoc Schema Extension

New: Ydoc Attachments Map
// New YDOC_KEYS entry
YDOC_KEYS.ATTACHMENTS = "attachments"

// Y.Map<AttachmentId, AttachmentMeta>
interface AttachmentMeta {
  id: string        // SHA-256 hex (64 chars)
  filename: string  // Original filename
  mimeType: string  // "image/png", "image/jpeg"
  size: number      // Bytes
  width?: number    // Pixel width (if image)
  height?: number   // Pixel height (if image)
  createdAt: string // ISO 8601
  author: string    // Who attached it
}
New: AttachmentId Type
// Branded type for compile-time safety
const AttachmentIdSchema = Schema.String.pipe(
  Schema.pattern(/^[a-f0-9]{64}$/),
  Schema.brand("AttachmentId"),
)

// Content addressing function
async function hashAttachment(
  bytes: Uint8Array
): Promise<AttachmentId> {
  const digest = await crypto.subtle.digest(
    "SHA-256", bytes
  )
  return Array.from(new Uint8Array(digest))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("")
}

Markdown Reference Format

Images embedded in issue descriptions and comments use a custom URI scheme that resolves to local blobs or remote URLs at render time:


Here's the bug I found:

![Screenshot of error dialog](fp-attach://a1b2c3d4e5f6...64chars)

The error appears after clicking "Submit".

The fp-attach://<hash> scheme is resolved by the rendering layer. The hash maps to .fp/blobs/<hash> locally, or to an R2 URL when viewed in the web console.

Local Database Changes

packages/fp-core/src/storage/database/schema.ts
// New table for attachment blob index
// Tracks which blobs exist locally and their sync state
export const attachmentBlobs = sqliteTable(
  "attachment_blobs",
  {
    hash: text("hash").primaryKey(),      // SHA-256 hex
    filename: text("filename").notNull(),
    mimeType: text("mime_type").notNull(),
    size: integer("size").notNull(),       // bytes
    width: integer("width"),               // pixels
    height: integer("height"),             // pixels
    /** Whether this blob has been uploaded to remote */
    synced: integer("synced", { mode: "boolean" })
      .notNull().default(false),
    createdAt: integer("created_at", { mode: "timestamp" })
      .$defaultFn(() => new Date()),
  },
)
5 Sync Flow

Two-phase sync: metadata then blobs

The key architectural decision: blob transfer happens outside the Yjs WebSocket protocol, via plain HTTP. This keeps the sync protocol clean and lets us leverage R2's HTTP interface directly.

sequenceDiagram
    participant C as Client
    participant WS as Sync DO
WebSocket participant API as Console API
HTTP participant R2 as R2 Bucket Note over C,WS: Phase 1: Yjs Sync (existing protocol) C->>WS: HELLO + known issues WS-->>C: WELCOME + issue manifest C->>WS: SYNC messages per issue WS-->>C: SYNC replies with updates Note over C: Ydocs now contain attachment metadata Note over C,R2: Phase 2: Blob Sync (new HTTP layer) C->>C: Scan Ydocs for attachment hashes C->>C: Compare with local .fp/blobs/ C->>API: GET /blobs/manifest API-->>C: List of hashes server has loop For each missing-locally blob C->>API: GET /blobs/sha256-abcdef API->>R2: Get object R2-->>API: Blob bytes API-->>C: Blob bytes C->>C: Verify hash, store in .fp/blobs/ end loop For each unsynced-locally blob C->>API: PUT /blobs/sha256-abcdef API->>R2: Put object R2-->>API: OK API-->>C: 200 OK C->>C: Mark blob as synced in SQLite end
Blob sync is a separate HTTP phase after Yjs sync completes

Blob API Endpoints

MethodPathDescription
GET /api/v1/workspaces/:ws/projects/:proj/blobs/manifest Returns JSON array of blob hashes the server has for this project. Used by client to determine which blobs to download/upload.
GET /api/v1/workspaces/:ws/projects/:proj/blobs/:hash Download a blob by hash. Returns raw bytes with appropriate Content-Type. Supports If-None-Match with hash as ETag.
PUT /api/v1/workspaces/:ws/projects/:proj/blobs/:hash Upload a blob. Server verifies SHA-256 matches the hash in the URL. Rejects if hash mismatch. Idempotent — re-uploading the same hash is a no-op.
HEAD /api/v1/workspaces/:ws/projects/:proj/blobs/:hash Check if blob exists without downloading. Returns 200 or 404.

R2 Storage Layout

R2 Bucket: fp-project-blobs
  Key format: {projectId}/blobs/{sha256-hash}

  Example:
    proj_01KEEVRPGCV0W07PCN549PM2AM/blobs/a1b2c3d4...64chars

  Custom metadata per object:
    x-fp-filename: "screenshot.png"
    x-fp-mime-type: "image/png"
    x-fp-size: "245760"
Why not sync blobs through the Durable Object?

DO SQLite has a 10GB limit and isn't designed for large binary storage. R2 is purpose-built for this: S3-compatible, no egress fees, and the console Worker can access it directly via bindings. The DO handles Yjs sync (small, frequent updates); R2 handles blob storage (large, infrequent, immutable).

6 CLI & Desktop UX

User-facing commands and interactions

CLI Commands

CommandDescription
fp attach <issue-id> <file-path> Attach an image file to an issue. Hashes the file, copies to .fp/blobs/, updates the Ydoc attachments map, and appends an ![](fp-attach://hash) reference to the issue description.
fp attach <issue-id> --clipboard Attach image from system clipboard (screenshot). Uses platform-specific clipboard access. Generates filename from timestamp.
fp comment <id> "text" --attach <file> Add a comment with an inline image attachment. The comment body includes the fp-attach:// reference.
fp attach list <issue-id> List all attachments for an issue, showing filename, size, hash prefix, and sync status.
fp attach open <hash-prefix> Open an attachment in the system default viewer.
fp sync (Modified) After Yjs sync, runs blob sync phase. Shows blob upload/download progress.

Desktop App

Input methods
  • Drag-and-drop image onto issue detail view
  • Paste from clipboard (Cmd+V / Ctrl+V)
  • File picker via toolbar button
  • Screenshot capture (platform native)
Processing pipeline
  • Main process receives file bytes via IPC
  • Compute SHA-256 hash
  • Write to .fp/blobs/<hash>
  • Insert metadata into Ydoc attachments map
  • Insert fp-attach:// ref into description/comment
  • Mark blob as unsynced in SQLite
7 Rendering

How images appear in different contexts

CLI Terminal

Terminal can't render inline images. Show a text placeholder with the filename and a hint to open it:

[image: screenshot.png (245KB)]
  fp attach open a1b2c3
Desktop / fp-ui

Custom img component in react-markdown resolves fp-attach:// to local file paths or data URIs:

// In react-markdown components
img: ({ src, alt }) => {
  if (src?.startsWith("fp-attach://")) {
    const hash = src.slice(12)
    const blobUrl = resolveBlobUrl(hash)
    return <img src={blobUrl} alt={alt} />
  }
  return <img src={src} alt={alt} />
}
Web Console

Resolves to R2-backed URL. Can use Cloudflare Image Resizing for thumbnails:

// Server-side URL resolution
function resolveAttachmentUrl(
  projectId: string,
  hash: string
): string {
  return `/api/v1/.../blobs/${hash}`
}

// Or with image resizing:
// /cdn-cgi/image/w=400/blobs/hash

Materialization

When materializing Ydocs to markdown files on disk, fp-attach:// references are preserved as-is in the markdown. The materialized .fp/issues/*.md files contain the raw references. A future enhancement could resolve them to relative paths for rendering in editors that support image preview.

8 File Changes

Modified and new files

FileChange
packages/fp-core (core library)
storage/database/schema.tsAdd attachmentBlobs table
storage/database/yjs-schemas.tsAdd YDOC_KEYS.ATTACHMENTS, AttachmentMetaSchema, AttachmentIdSchema
storage/database/yjs-accessors.tsAdd getAttachments(), setAttachment() accessors
storage/database/yjs-seeding.tsInitialize empty attachments Y.Map on new issues
models/issue.tsAdd attachments field to Issue domain model
storage/database/yjs-mapping.tsMap Ydoc attachments to domain model
lib/attachments.tsNew: hashAttachment(), storeBlob(), resolveBlob()
services/blob-sync.tsNew: HTTP-based blob sync client (upload/download/manifest)
services/sync-client.tsAdd blob sync phase after Yjs sync completes
commands/attach.tsNew: attach command implementation
commands/comment.tsAdd --attach flag support
lib/materializer.tsPreserve fp-attach:// references in materialized markdown
apps/fp (CLI)
src/commands/attach.tsNew: yargs command for fp attach
src/commands/comment.tsWire up --attach flag
src/commands/sync.tsAdd blob sync phase to sync command output
apps/desktop (Electron)
src/main/ipc/attachments.tsNew: IPC handlers for attachment operations
src/renderer/components/Drag-drop and paste handlers in issue detail view
apps/console (API + Worker)
wrangler.jsoncAdd R2 bucket binding (BLOB_BUCKET)
worker/routes/blobs.tsNew: Hono routes for GET/PUT/HEAD blob endpoints
worker/index.tsMount blob routes
packages/fp-ui (rendering)
components/story/story-markdown.tsxCustom img component to resolve fp-attach://
components/review/comment-markdown.tsxSame img resolver for comment rendering
lib/blob-resolver.tsNew: Platform-aware blob URL resolution
9 Edge Cases

What could go wrong

ScenarioExpected Behavior
Same image attached twice Content hash is identical, so .fp/blobs/ already has the file. Ydoc gets the same attachment ID. Deduplication is automatic. No extra storage consumed.
Blob exists in Ydoc but not on disk Rendering shows a placeholder ("image not available locally"). Next fp sync downloads missing blobs. Desktop app shows download button.
Blob on disk but not in R2 attachment_blobs.synced = false. Next sync uploads it. Local rendering works immediately regardless.
Two clients attach different images simultaneously No conflict. Each gets a unique content hash. Both entries appear in the attachments Y.Map (CRDT merge). Both blobs sync independently.
Large image (>10MB) CLI warns about file size. Configurable max size (default 10MB). R2 supports up to 5GB per object. Consider auto-compression for screenshots (PNG → lossy WebP).
Network interruption during blob upload Blob remains marked synced = false. Next sync retries. PUT is idempotent — partial uploads are overwritten.
Orphaned blobs (referenced by nothing) Periodic GC: scan all Ydoc attachment maps, compare with .fp/blobs/ directory, delete unreferenced blobs older than 7 days. Server-side: scheduled Worker cron job scans R2.
Offline-only project (never synced) Everything works locally. Blobs in .fp/blobs/, metadata in Ydoc, rendering from local files. No remote storage needed.
Non-image file attached Phase 1: only images (png, jpg, gif, webp, svg). Reject other MIME types with clear error. The architecture supports arbitrary blobs for future expansion.
Git worktree with shared .fp .fp/blobs/ is shared across worktrees (symlinked). Content-addressed files are safe to share since they're immutable.
Hash collision SHA-256 collision probability: ~10^-60 for billions of images. Not a practical concern. If paranoid, store size alongside hash as secondary check.
10 Implementation Notes

Key considerations

Backward Compatibility

Adding YDOC_KEYS.ATTACHMENTS is forward-compatible — old clients ignore unknown Y.Doc keys. No need to bump CURRENT_YDOC_VERSION since the new key is optional. Old clients simply won't see attachment metadata, and fp-attach:// references render as broken images (acceptable degradation).

Critical: Blob sync must be idempotent

Content-addressed storage makes this natural. PUT of the same hash is a no-op. GET always returns the same bytes. But the sync ordering matters: always sync Yjs metadata before blobs, so the receiving client knows which blobs to fetch. Never start blob sync before the Yjs handshake completes.

Performance: Lazy blob loading

Don't download all blobs on sync. Instead, download on-demand when a user views an issue with attachments. The attachment_blobs table tracks what's available locally. Sync only downloads a manifest (list of hashes), not the blobs themselves. Background download can be enabled as a preference.

R2 Bucket Setup

The console app currently has no R2 bucket. This feature requires adding one to apps/console/wrangler.jsonc. Suggested name: fp-project-blobs. Consider lifecycle rules for auto-expiry of orphaned objects (e.g., 90-day TTL for unreferenced blobs).

Phased Rollout Suggestion

Phase 1: Local-only attachments (CLI fp attach, .fp/blobs/, Ydoc schema, markdown rendering). No sync. Phase 2: R2 storage + blob HTTP API. Phase 3: Desktop drag-drop, clipboard paste, image compression. Phase 4: Web console rendering, image resizing, lazy loading.

Size Estimates

Ydoc overhead per attachment

~200 bytes of metadata (hash + filename + mime + dimensions + timestamp). For 100 attachments on an issue, that's ~20KB added to the Ydoc. Negligible compared to typical issue descriptions.

Blob storage per project

Average screenshot: 200-500KB. A project with 500 screenshots: ~100-250MB locally. R2 free tier: 10GB/month storage, 10M reads. Well within limits for most teams.

Open Questions

QuestionOptions to Consider
Should .fp/blobs/ be gitignored? Yes — binary blobs don't belong in git. Add to .gitignore by default. Teams that want git-tracked images can opt out.
Auto-compression for screenshots? Optional: convert large PNGs to WebP on attach. Saves 60-80% storage. But changes the hash (content changes). Could store original + compressed as separate blobs.
Thumbnail generation? Client-side: generate 200px thumbnail on attach, store as separate blob. Server-side: use Cloudflare Image Resizing on R2 objects. Both approaches work.
Max attachments per issue? Soft limit (warn at 20, hard limit at 100). Prevents Ydoc bloat from metadata accumulation. Configurable per project.
Access control on blobs? Same as project access. Authenticated via PAT (CLI) or session (web). R2 bucket is private, accessed only through authenticated Worker routes.