Skip to content

Architecture

MohamedAshrafElsaed edited this page Jul 9, 2026 · 2 revisions

Loupe — Architecture

Loupe turns a comment a PM leaves on a live product into an actionable, fully-contextual task for a developer (or for Claude Code). This document explains how every piece fits together and how the non-obvious logic works.

System overview

flowchart LR
  subgraph Product["Customer product (any site)"]
    SDK["@loupekit/sdk<br/>Shadow-DOM widget"]
  end
  subgraph Ext["Any site (no install)"]
    EXT["@loupekit/extension<br/>MV3 + captureVisibleTab"]
  end
  subgraph Backend["@loupekit/server (one Node process)"]
    API["HTTP API<br/>node:http"]
    DBSEAM["db.ts seam"]
    BLOB["blobs.ts seam"]
    STATIC["static hosting<br/>/dashboard /demo /sdk"]
  end
  PG[("Postgres<br/>PGlite / hosted")]
  OBJ[["Object storage<br/>disk / S3"]]
  DASH["@loupekit/dashboard<br/>Kanban triage"]
  MCP["@loupekit/mcp<br/>MCP server"]
  CLAUDE["Claude Code"]

  SDK -->|"identity HMAC"| API
  EXT -->|"identity HMAC"| API
  API --> DBSEAM --> PG
  API --> BLOB --> OBJ
  API --> STATIC
  DASH -->|"admin key"| API
  MCP -->|"admin key"| API
  CLAUDE <-->|"MCP tools"| MCP
  DASH -.serves.- STATIC
Loading

Everything runs from a single Node process locally — the API, the database (embedded PGlite), object storage (disk), and static hosting of the dashboard, demo, and SDK bundle.

The feedback loop (end to end)

sequenceDiagram
  actor PM as PM (in the product)
  participant SDK as @loupekit/sdk
  participant API as @loupekit/server
  participant PG as Postgres
  participant OBJ as Object storage
  actor Dev as Developer
  participant MCP as @loupekit/mcp
  participant Claude as Claude Code

  PM->>SDK: click element, write comment
  SDK->>SDK: captureAnchor() + element context + screenshot
  SDK->>API: POST /v1/blobs (screenshot)  [HMAC]
  API->>OBJ: store PNG
  OBJ-->>API: url
  SDK->>API: POST /v1/comments (with screenshot URL)  [HMAC]
  API->>PG: INSERT (url normalized)
  Note over API,PG: comment now visible to dashboard + MCP
  Dev->>Claude: "work through Loupe comments"
  Claude->>MCP: list_comments / get_comment  [admin]
  MCP->>API: GET /v1/comments
  API->>PG: SELECT
  API-->>Claude: request + element HTML + styles + screenshot URL
  Claude->>MCP: update_status(done)
  MCP->>API: PATCH /v1/comments/:id
Loading

Packages

flowchart TD
  SHARED["@loupekit/shared<br/>types + normalizeUrl (built)"]
  SDK["@loupekit/sdk"]
  DASH["@loupekit/dashboard"]
  SRV["@loupekit/server"]
  MCP["@loupekit/mcp"]
  EXT["@loupekit/extension"]
  SHARED --> SDK
  SHARED --> DASH
  SHARED --> SRV
  SHARED --> MCP
  SDK --> EXT
Loading
Package Runtime Build Responsibility
@loupekit/shared both tsc → dist Canonical types + normalizeUrl
@loupekit/sdk browser tsup (ESM + IIFE) Inspect, comment, capture, re-anchor
@loupekit/server Node (native TS) none API, Postgres, blobs, auth, static
@loupekit/dashboard browser tsup (ESM) Kanban triage board
@loupekit/mcp Node (native TS) none MCP server for Claude Code
@loupekit/extension browser tsup (IIFE) MV3 extension, pixel-perfect capture

Re-anchoring: the crown jewel

A pin must survive the developer rewriting the markup. Loupe stores a multi-signal fingerprint and re-resolves it by scoring candidates on every load and DOM change.

flowchart TD
  A["resolveAnchor(anchor)"] --> B{"unique testid / id?"}
  B -- yes --> B1["score 0.98 — return"]
  B -- no --> C{"cssPath rooted at a<br/>stable id, single match,<br/>same tag?"}
  C -- yes --> C1["score 0.90 — return<br/>(survives changed content)"]
  C -- no --> D["weighted similarity scan<br/>text .34 · attrs .22 · testid .22<br/>cssPath .20 · tag .12 · position .10"]
  D --> E{"best score ≥ 0.5?"}
  E -- yes --> E1["pin to best element"]
  E -- no --> E2["mark 'detached'<br/>(never pin to the wrong element)"]
Loading

captureAnchor records: stable id/testid, a CSS path anchored at the nearest stable ancestor, an XPath, normalized text, identifying attributes, nth-of-type, and the bounding rect + viewport (for positional scoring and the detached fallback). Live proof is in docs/before-redeploy.png / docs/after-redeploy.png.

Authentication

flowchart TD
  R["request"] --> P{"project found?"}
  P -- no --> E404["404"]
  P -- yes --> ADM{"X-Loupe-Admin == secret?"}
  ADM -- yes --> OKA["authorized (admin) — dashboard / MCP"]
  ADM -- no --> USR{"HMAC-SHA256(user, secret)<br/>== X-Loupe-Hmac?"}
  USR -- yes --> OKU["authorized (user) — SDK"]
  USR -- no --> E401["401"]
Loading

The host app's server computes the user HMAC and injects it into the page — the browser never sees the secret. The dashboard and MCP server act as admin. Writes are gated; a user may only post comments as themselves.

Data model

erDiagram
  PROJECTS ||--o{ COMMENTS : has
  PROJECTS {
    text project_key PK
    text name
    text secret
    text_array allowed_origins
    timestamptz created_at
  }
  COMMENTS {
    text id PK
    text project_key FK
    text url "normalized"
    text status "open|in_progress|done"
    text body
    jsonb author
    jsonb anchor
    jsonb context
    jsonb offset
    text screenshot_url
    timestamptz created_at
  }
Loading

Screenshots & object storage

The SDK uploads the PNG to POST /v1/blobs and stores only the returned URL on the comment, so lists and reads never carry base64. blobs.ts is a seam: local disk today, S3/R2 with signed URLs in production. [data-loupe-redact] regions and Loupe's own UI are excluded before the image ever leaves the browser.

URL normalization

normalizeUrl (in @loupekit/shared) strips utm_*, click ids (gclid, fbclid, …), and Loupe's dev params (api, key), sorts the remaining query, and drops trailing slashes. Applied server-side on write and on the list filter, so /checkout?utm_source=x and /checkout share one comment thread instead of fragmenting.

The browser extension

Identical SDK core; the only difference is the screenshot source.

sequenceDiagram
  participant C as content.js (SDK core)
  participant BG as background.js (worker)
  C->>BG: LOUPE_CAPTURE
  BG->>BG: chrome.tabs.captureVisibleTab (real pixels)
  BG-->>C: full-page PNG
  C->>C: crop to element rect (× dpr), paint over redacted regions
  C-->>C: return data URL to the SDK (uploaded as a blob)
Loading

The SDK exposes a captureScreenshot override in its config; the extension passes this function, so nothing else in the widget changes.

Seams (extension points)

Change implementations behind these; leave the contracts alone.

Seam File Local Production
Storage adapter sdk/src/store.ts / http-adapter.ts localStorage / HTTP HTTP
Database server/db.ts PGlite hosted Postgres (DATABASE_URL)
Object storage server/blobs.ts disk S3 / R2 signed URLs
Screenshot capture SDK captureScreenshot config modern-screenshot extension captureVisibleTab

Clone this wiki locally