Quickstart · Install · Docs · Agent API · Architecture
Markup is a single-container web app for reviewing interactive HTML/CSS/JS mockups. Reviewers drop pin-style annotations directly on the rendered page — same shape as a GitHub PR review, but for live frontends. An equally first-class HTTP API lets AI dev assistants and agent orchestrators (Claude Code, Cursor, Aider, custom LangGraph/CrewAI workflows) read those annotations as structured payloads and ship fixes back as small unified diffs.
- Review the live frontend, not screenshots. See the actual rendered mockup in an iframe, drop a teardrop pin where the issue is, type a comment, and resolve threads as the work lands.
- Self-host on a small box. Single Docker container. SQLite + filesystem. No PostgreSQL, no Redis, no external services. A 256 MB instance is enough for a small team.
- An API agents can actually use. Every surface the UI offers — server-side DOM resolution, computed-style extraction, unified-diff versioning — is exposed as a stable HTTP contract. The same routes the browser hits power autonomous review loops, AI dev assistants, and custom CI integrations.
docker run -d --name markup \
-p 3000:3000 \
-e AUTH_SECRET=$(openssl rand -hex 32) \
-v $(pwd)/markup-data:/app/data \
ghcr.io/alexandrecamillo/markup:latestOpen http://localhost:3000 and follow the setup wizard. The first request redirects to /setup; create the admin account, then subsequent visits go to /login. To seed agent tokens at boot, set AGENT_TOKENS=name1:secret1,name2:secret2 — the seeder is idempotent across restarts.
docker run -d --name markup \
-p 3000:3000 \
-e AUTH_SECRET=$(openssl rand -hex 32) \
-v $(pwd)/markup-data:/app/data \
ghcr.io/alexandrecamillo/markup:latestOr with Compose — see docker-compose.example.yml.
git clone https://github.com/AlexandreCamillo/markup.git
cd markup
pnpm install
cp .env.example .env.local
pnpm prisma migrate deploy
pnpm devRequires Node.js 22+ and pnpm. Visit http://localhost:3000.
Mockup ─< MockupVersion each mockup is a versioned bundle of HTML / CSS / JS
│
└─< Annotation ── Thread ─< Message
one pin → one thread → many replies (user or agent)
- Mockup — a named, sluggable artefact representing one frontend under review. Each upload (zip with
index.htmlat the root) is a new immutableMockupVersion. The currently-served version isMockup.currentVersionId. - Annotation — a draft composed in the rail's
DraftCardand persisted on send. Carries a body, an array of DOM anchors (text-anchor or element-anchor — resilient to reflow), acolorIndexshared by all of the annotation's pins, and a stamp of which version was current at creation time. - Thread — one per annotation, holding the conversation.
statusisopen,needs review, orresolved. - Message — a reply on a thread. Authored by a
user(cookie session) or anagent(Bearer token).
| Surface | What it does |
|---|---|
| Inline DraftCard | Mounted at the top of the annotations rail while a user is drafting. Three terminal actions — Cancel · Draft (⌘S) · Send (⌘↵). Survives reloads via localStorage. |
| Pin-based review | Click anywhere on the live mockup to drop a pin while a draft is active. Pins reflow with the layout via DOM-anchored coordinates and persist across reloads. |
| Versioning | Every upload is immutable. Side-by-side and overlay diff views compare any two versions; the current version powers the serve route at /m/<id>/. |
| Agent API | A single-call aggregator returns annotation metadata, the inline current HTML, and a unified diff against the version-at-creation. |
| Cookie or Bearer | Same routes serve the browser UI (cookie JWT) and non-browser clients (Bearer agent tokens). One contract, two front doors. |
| Single-mount deploy | All state lives under ${DATA_DIR} — SQLite DB, mockup blobs, annotation screenshots, sidecar caches. Mount one volume, back up one tree. |
A typical fix loop is three calls:
# 1. Read everything an agent needs in a single request
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:3000/api/agent/context/$ANNOTATION_ID"
# → { annotation, thread, current_version: { files, … }, diff_since_creation }
# 2. Apply a fix as a unified diff (no zip rebuild)
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg base "$BASE_VERSION_ID" --arg diff "$DIFF" \
'{base_version_id: $base, patches: {"index.html": $diff}}')" \
"http://localhost:3000/api/mockups/$MOCKUP_ID/version-patch"
# 3. Reply on the annotation thread
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"body":"fixed in v2 — see diff above"}' \
"http://localhost:3000/api/threads/$THREAD_ID/reply"Typical round-trip is 5–15 KB on the wire. The full contract — response shapes, error codes, cache invalidation rules — is documented under docs/agent-loop/.
| Variable | Required | Default | Purpose |
|---|---|---|---|
AUTH_SECRET |
yes | — | Session JWT signing key, ≥ 32 chars. Rotation invalidates existing sessions. |
DATA_DIR |
yes | — | Root path for SQLite DB and mockup blobs. Mount as a volume in Docker. |
APP_URL |
no | http://localhost:3000 |
Public URL. Required for the puppeteer-backed region screenshot endpoint to reach the serve route. |
DATABASE_URL |
no | file:./prisma/dev.db |
Prisma connection string. |
LOG_LEVEL |
no | info |
One of fatal / error / warn / info / debug / trace. |
PUID |
no | 1000 |
Linux UID the container drops to (Docker only). |
PGID |
no | 1000 |
Linux GID the container drops to (Docker only). |
AGENT_TOKENS |
no | empty | Boot-seed tokens, format name1:secret1,name2:secret2. Idempotent across restarts. |
MAX_UPLOAD_MB |
no | 50 |
Total zip size cap on upload. |
MAX_FILES_PER_UPLOAD |
no | 1000 |
File count cap inside a zip. |
MAX_FILE_SIZE_MB |
no | 10 |
Per-file cap inside a zip. |
The container only serves HTTP. Terminate TLS upstream.
markup.example.com {
reverse_proxy localhost:3000
}server {
listen 443 ssl;
server_name markup.example.com;
ssl_certificate /etc/letsencrypt/live/markup.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/markup.example.com/privkey.pem;
client_max_body_size 60m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Everything lives under ${DATA_DIR} (the SQLite DB in WAL mode, mockup files, annotation screenshots, sidecar caches). For a cold backup:
docker stop markup
tar -czf markup-backup-$(date +%F).tar.gz -C /path/to/markup-data .
docker start markupFor online backup, use SQLite's online-backup API or Litestream against the DB; the blob layer under ${DATA_DIR}/mockups/ is safe to rsync hot since files are append-mostly.
If you lose admin access or need to reseed tokens:
# Reset only auth (admin user + sessions). Mockups, annotations, threads preserved.
docker exec -it --user 1000:1000 markup pnpm reset:auth
# Reset only agent tokens (AGENT_TOKENS reseeded on next boot)
docker exec -it --user 1000:1000 markup pnpm reset:tokens
# Nuke everything (interactive confirmation; --force to skip)
docker exec -it --user 1000:1000 markup pnpm reset:allA single Node process hosts both the web UI and the API. Persistence is split between SQLite (rows) and the filesystem (blobs).
┌───────────────────────────────────────────────────────────┐
│ Reverse proxy (Caddy / nginx) — TLS termination │
└──────────────────────────────┬────────────────────────────┘
│ HTTP
┌──────────────────────────────▼────────────────────────────┐
│ Markup container (single Node process) │
│ │
│ Next.js 16 App Router (standalone build) │
│ ├── /m/[id]/... serve mockup files │
│ ├── /api/mockups, /api/annotations, /api/threads │
│ ├── /api/agent/context, /version-patch │
│ └── /projects, /annotations, /settings, /login │
│ │
│ Headless Chromium (puppeteer) │
│ ↳ on demand for region screenshots │
│ │
│ Prisma 7 + better-sqlite3 (WAL mode) │
└──────────────────────────────┬────────────────────────────┘
│
▼
${DATA_DIR}/ (single mount)
├── prisma/dev.db
└── mockups/<id>/
├── thumbnail.png
├── versions/<vid>/build/...
└── annotations/<aid>/
└── region.png (sidecar cache)
- Next.js 16 App Router, served as a standalone build (client-side rendering only)
- React 18 for the UI; every page is a
'use client'component - Radix Primitives —
react-form,react-alert-dialog,react-dialog,react-popover,react-toast - Prisma 7 +
better-sqlite3adapter (WAL mode) - puppeteer for server-side region cropping at the bbox a user drew
- sharp for screenshot processing
- Vitest + Biome for testing and linting
- Pino for structured logs
The full stack and folder layout are documented in docs/stack.md.
Browse the full documentation index or jump to a specific area:
- Agent loop — the agent API contract (
/contextaggregator, patch format, version-patch flow) - API conventions — REST conventions, auth, storage layout
- Data schema — Prisma models and relationships
- Frontend — components, styling tokens, design system
- Design system — DS authoring guide (mandatory before touching any DS file)
- Task rules — what every change must do before opening a PR
- CI — pre-push checklist (
pnpm biome check . && pnpm tsc --noEmit && pnpm test && pnpm build)
Every semver tag (v*) pushed to main triggers the image workflow and publishes to ghcr.io/alexandrecamillo/markup:
| Tag | When published |
|---|---|
vX.Y.Z |
On semver tag push — multi-arch (amd64 + arm64) |
vX.Y, vX |
Floating aliases updated on each patch/minor tag |
latest |
Points to the most recent main-green release |
sha-<7> |
Every main push for debugging |
Images pass a mandatory smoke test (/api/health → 200) before the release is created. No manual approval needed — a green tag is a published release. Pull a specific version with docker pull ghcr.io/alexandrecamillo/markup:v1.2.3. Release notes live on the releases page.
Open an issue for bug reports, feature requests, or questions about deploying Markup.
Contributions of any size are welcome.
-
Read
docs/INDEX.md— every change starts there. -
Run the pre-push checklist from
docs/ci.md:pnpm biome check . && pnpm tsc --noEmit && pnpm test && pnpm build
-
Follow the commit conventions in
docs/git/conventions.md: conventional-commits subject only, no body, noCo-Authored-Bytrailer. -
Open a PR with a short description of what changed and why. For non-trivial changes, please open an issue first to discuss the design.
MIT © Markup contributors