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.
Before & After
fp attach NOXR-abc screenshot.png.fp/blobs/ by content hashFour 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.
A. Content-Addressable Blob Store
SHA-256 hash of file bytes as ID. Blobs stored on filesystem locally, R2 remotely. Ydoc stores only metadata + hash references. Sync is separate from Yjs protocol.
- Automatic deduplication (same image = same hash)
- Ydoc stays small — only metadata syncs via CRDT
- Blob sync is idempotent and resumable
- Proven by Excalidraw, git-lfs, IPFS
- Can lazy-load blobs (don't need all images immediately)
- Two sync channels to maintain (Yjs + blob HTTP)
- Orphan cleanup needed for unreferenced blobs
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
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
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
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.
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.
Ydoc Schema Extension
// 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
}
// 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:  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
// 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()),
},
)
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.
Blob API Endpoints
| Method | Path | Description |
|---|---|---|
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"
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).
User-facing commands and interactions
CLI Commands
| Command | Description |
|---|---|
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  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
- Drag-and-drop image onto issue detail view
- Paste from clipboard (Cmd+V / Ctrl+V)
- File picker via toolbar button
- Screenshot capture (platform native)
- 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
How images appear in different contexts
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
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} />
}
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.
Modified and new files
| File | Change |
|---|---|
| packages/fp-core (core library) | |
storage/database/schema.ts | Add attachmentBlobs table |
storage/database/yjs-schemas.ts | Add YDOC_KEYS.ATTACHMENTS, AttachmentMetaSchema, AttachmentIdSchema |
storage/database/yjs-accessors.ts | Add getAttachments(), setAttachment() accessors |
storage/database/yjs-seeding.ts | Initialize empty attachments Y.Map on new issues |
models/issue.ts | Add attachments field to Issue domain model |
storage/database/yjs-mapping.ts | Map Ydoc attachments to domain model |
lib/attachments.ts | New: hashAttachment(), storeBlob(), resolveBlob() |
services/blob-sync.ts | New: HTTP-based blob sync client (upload/download/manifest) |
services/sync-client.ts | Add blob sync phase after Yjs sync completes |
commands/attach.ts | New: attach command implementation |
commands/comment.ts | Add --attach flag support |
lib/materializer.ts | Preserve fp-attach:// references in materialized markdown |
| apps/fp (CLI) | |
src/commands/attach.ts | New: yargs command for fp attach |
src/commands/comment.ts | Wire up --attach flag |
src/commands/sync.ts | Add blob sync phase to sync command output |
| apps/desktop (Electron) | |
src/main/ipc/attachments.ts | New: IPC handlers for attachment operations |
src/renderer/components/ | Drag-drop and paste handlers in issue detail view |
| apps/console (API + Worker) | |
wrangler.jsonc | Add R2 bucket binding (BLOB_BUCKET) |
worker/routes/blobs.ts | New: Hono routes for GET/PUT/HEAD blob endpoints |
worker/index.ts | Mount blob routes |
| packages/fp-ui (rendering) | |
components/story/story-markdown.tsx | Custom img component to resolve fp-attach:// |
components/review/comment-markdown.tsx | Same img resolver for comment rendering |
lib/blob-resolver.ts | New: Platform-aware blob URL resolution |
What could go wrong
| Scenario | Expected 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. |
Key considerations
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).
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.
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.
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).
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
~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.
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
| Question | Options 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. |