Round-trip design loop between your IDE and claude.ai/design. Capture a route, send a brief, iterate visually with Claude, fetch the handoff bundle, translate it into framework-native scaffolds, and verify the result.
claude-design-loop automates the boring parts of design iteration with Claude:
- 📸 captures screenshots of a running route at multiple breakpoints,
- ✍️ drafts a short brief and uploads it + the screenshots into a fresh
claude.ai/designproject, - 🪄 hands the canvas off to you for visual iteration (no time limit),
- 📦 on a single keypress, drives Share → Handoff to Claude Code, downloads the bundle, translates it into your framework's component shape, and prints a ready-to-paste prompt for your IDE,
- 🔁 resumes cleanly if the browser or terminal got killed mid-session.
Designed for teams that already use Cursor/Claude Code as their IDE and Claude Design as their UX surface — but the only hard dependency is Claude Design itself.
TL;DR: do the first-time setup once. After that, daily use is one command:
pnpm design. Follow the wizard.
You only do these steps once per project (and once per machine for the auth + browser binary).
pnpm add -D github:EkoLabs/claude-design-loop playwright
pnpm exec playwright install chromium # ~150MB, one-time per machineplaywright is a peer dependency — installed at the project level so you control the browser version. The package itself ships pre-built (the prepare lifecycle script runs tsup on install, so consumers always get a freshly built dist/).
pnpm exec design-loop initThis writes a .design-loop.config.ts with sensible defaults and adds design-loops/ to your repo's root .gitignore (creating it if needed) so loop run artifacts are never accidentally committed. See Loop artifacts & cleanup for details. Open the config and fill in:
framework—'svelte','react'(Next.js), or'html'devUrl— your dev-server URL (e.g.http://localhost:5173)routesDir— where the wizard scans for routable files (src/routesfor SvelteKit;src/apporsrc/pagesfor Next.js)
Leave designSystem.id as an empty string for now — you'll fill it in step 4.
pnpm exec design-loop loginA Chromium window opens. Log in normally, then close the window when prompted. Your session is persisted to ~/.config/design-loop/chromium-profile/ and reused on every subsequent run — the same Anthropic session you'd use in your normal browser is what's saved. No credentials are stored or transmitted by us. Once per machine.
pnpm exec design-loop systemsThis scrapes the New Project picker on claude.ai/design and prints every design system available to your account, with its UUID and a diff against your config. Paste the right ones into your .design-loop.config.ts's designSystem array.
In your project's package.json:
Now you can run pnpm design instead of pnpm exec design-loop.
Make sure your dev server is running, then:
pnpm designThat's it. The interactive wizard:
- checks for in-progress designs (offers to resume if any exist),
- otherwise scans your
routesDirand asks which route to send to Claude, - asks which design system if you have more than one,
- takes an optional one-line intent (e.g. "compress the header, surface stats first"),
- suggests a project name, captures screenshots, opens
claude.ai/design, attaches everything, and sends the brief.
After the first design pass lands in the browser, your terminal becomes the controller:
What next?
[f] Fetch — Share → Handoff in this browser, then pull bundle
[w] Wait — keep iterating in Claude Design (no timeout)
[u] URL — print the project URL again
[q] Quit — close browser without fetching
>
Pick [w] to keep designing in the browser. Pick [f] when you're happy — the bundle is fetched, translated to framework-native scaffolds, and a CURSOR_PROMPT.md is dropped into the loop directory ready to paste into your IDE chat.
Got disconnected mid-session? Just run
pnpm designagain. The wizard will offer to resume the in-flight design rather than starting fresh.
The default install string in the Quickstart tracks main, so the package can ship hotfixes to you without anyone bumping a package.json. To upgrade to whatever's currently on main:
pnpm update @ekolabs/claude-design-loop # or: npm update @ekolabs/claude-design-loopWhy
update, notinstall?npm/pnpmresolvegithub:refs to a specific commit SHA in your lockfile on first install. A subsequentinstallre-uses that same SHA — it does not re-fetch the latest.update <pkg>is the explicit "go get the newest commit on the configured ref" command. This is true for any GitHub-backed dependency, not just this one.
To pin to a specific release for reproducible installs, suffix the install string with #vX.Y.Z:
pnpm add -D github:EkoLabs/claude-design-loop#v0.2.2Pinning is recommended in shared CI/CD environments and any repo that values build determinism over auto-updates. See CHANGELOG.md for the list of releases.
The repo is public (the LICENSE stays proprietary — Eko-internal — so source-readability doesn't grant external usage rights). CI runners can fetch the package without any auth wiring; just pnpm install --frozen-lockfile works on fresh GitHub Actions / Vercel / etc. runners. No PAT, no GitHub App, no deploy key needed.
Consumer installs also skip the build step — dist/ ships in the package, so there's no tsup/Node-version sensitivity on your runners. Install is a few seconds.
If you want the bin available globally so you can drop the pnpm exec prefix:
pnpm add -g github:EkoLabs/claude-design-loop
design-loop --helpDrop a .design-loop.config.ts (or .js / .mjs / .mts) at your repo root:
import { defineConfig } from '@ekolabs/claude-design-loop';
export default defineConfig({
framework: 'svelte', // 'svelte' | 'react' | 'html'
devUrl: 'http://localhost:5173',
routesDir: 'src/routes', // 'src/app' (Next.js App) or 'src/pages' (Pages)
excludeRoutes: ['/admin'],
// Single ref OR an array (first = default; the wizard shows a picker
// when there's more than one).
designSystem: [
{ name: 'Eko Customer Tools Design System', id: 'e40685d3-...' },
{ name: 'Eko Design System', id: '2d44a08e-...' },
],
// Optional — paths to repo files attached as additional context to every
// brief (markdown rendered + screenshotted). Use sparingly.
contextSources: ['docs/DESIGN_SYSTEM.md'],
loopsDir: 'design-loops',
breakpoints: [1280, 768, 375],
// Optional — wait for a selector to disappear before screenshotting
// (e.g. a "Checking auth…" overlay).
waitFor: {
hidden: 'text=Checking auth',
timeoutMs: 20_000,
},
});| Key | Required | Default | Notes |
|---|---|---|---|
framework |
yes | — | 'svelte', 'react' (Next.js App or Pages Router), or 'html'. New adapters: see CONTRIBUTING.md. |
devUrl |
yes | — | URL of your running dev server. Routes are appended to it for capture. |
routesDir |
yes | — | Where the wizard scans for routable files. |
excludeRoutes |
no | [] |
Hide these from the route picker. Prefix matches: '/admin' hides /admin/*. |
designSystem |
yes | — | Single { name, id } or an array. The id is a UUID from claude.ai/design. Run design-loop systems to discover ids. |
loopsDir |
no | 'design-loops' |
Where loop folders are written. |
breakpoints |
no | [1280, 768, 375] |
Viewport widths captured for each brief (px). |
waitFor.hidden |
no | — | CSS / Playwright text selector that must disappear before capturing. |
waitFor.timeoutMs |
no | 20000 |
How long to wait for the above selector. |
contextSources |
no | [] |
Repo files attached as extra context. |
| Command | What it does |
|---|---|
design-loop (no args) |
Interactive wizard. Recommended entry point. |
design-loop init |
Drop a .design-loop.config.ts stub in the current directory. |
design-loop login |
Open claude.ai/design so you can log in once. Session is persisted. |
design-loop systems |
List every design system on your account with its UUID + diff against your config. |
design-loop brief <route> |
Capture a route's screenshots + write a brief. No browser automation. |
design-loop submit <loopId> [--headed] |
Open a fresh project in claude.ai/design, attach screenshots, send the brief, and hand control to the interactive [f]/[w]/[u]/[q] review prompt. |
design-loop resume <loopId> |
Re-attach to an in-progress project (e.g. after a crash). Same review prompt. |
design-loop fetch <loopId> |
Drive Share → Handoff for a finished project, capture the bundle URL, run pull, then apply. |
design-loop pull <loopId> --bundle-url=<url> |
Expand a handoff bundle into the loop folder. |
design-loop apply <loopId> |
Translate the bundle into framework-native scaffolds. |
design-loop verify <loopId> |
Re-capture the route after apply and diff against the bundle. |
design-loop loop <route> |
Convenience: brief + submit, no wizard. Good for scripting. |
Most commands accept --no-interactive for CI usage. See design-loop <cmd> --help for full per-command flags.
Every browser-driving command (submit, resume, fetch, the wizard) holds a per-repo lock at <loopsDir>/.lock.json while it's running. The lock records the pid, start time, and command. If you start a second session while one is live, you'll get a clear error pointing at the running pid. Stale locks (process gone) are cleaned up automatically. The wizard offers a force-unlock prompt for the rare case where the lock survives an unclean shutdown.
This guard exists because all sessions share one Chromium profile (so auth state survives between runs) — running two at once corrupts the profile and fights over auth.
Each brief run creates one folder under loopsDir/:
design-loops/2026-05-15T14-16-34-489-root/
├── brief.md ← short prose, sent to Claude Design
├── manifest.json ← saves the project URL after submit (resume-safe)
├── inputs/
│ ├── screenshot-1280.png
│ ├── screenshot-768.png
│ ├── screenshot-375.png
│ └── dom.yaml ← informational, not uploaded
├── bundle/ ← after `fetch` / `pull`
├── review-checklist.md ← human ticks ✅/✗ here
└── output/ ← after `apply`
├── translated/ ← framework-native scaffolds
├── after/ ← after `verify`
├── CURSOR_PROMPT.md ← ready-to-paste IDE prompt
└── APPLY_SUMMARY.md ← what was generated and why
Everything under loopsDir/ (default: design-loops/) is local working state — bundles, screenshots, scaffolds, manifests, and the per-repo lockfile. None of it should ever be committed to version control. The package handles this with two complementary, idempotent safety nets:
-
design-loop initappends a two-line rule to your repo's root.gitignore:design-loops/* !design-loops/.gitignoreThe first line ignores all loop run output; the second line punches a hole for the sub-
.gitignore(see below) so it propagates through git to teammates.initrecognises any pre-existing rule that already coversloopsDir(design-loops,design-loops/,design-loops/*) and does nothing in that case — never clobbers customisations. -
Every loop run plants
loopsDir/.gitignorewith*\n!.gitignore\nas a second-layer defense — so even if someone edits or removes the root rule, the directory's own gitignore prevents accidental commits. Because of the!design-loops/.gitignoreexception in the root rule, this sub-.gitignoreis tracked by git: commit it once and your teammates inherit the protection automatically without having to runinit. If you've customised the sub-.gitignore(e.g. to track a specific loop) we leave it alone.
There's no automated GC yet — loops accumulate over time and each one is typically 10–50 MB. When loopsDir/ gets too big, just delete the old folders:
# delete every loop run
rm -rf design-loops/2026-*
# or keep the most recent 10
ls -1t design-loops/ | tail -n +11 | xargs -I{} rm -rf "design-loops/{}"The persistent Chromium profile and your claude.ai/design auth state live in ~/.config/design-loop/, outside the repo. Delete that folder if you want to fully sign out.
You can drive the same primitives from your own scripts:
import {
loadConfig,
runBrief,
runSubmit,
runFetch,
runApply,
} from '@ekolabs/claude-design-loop';
const { config, rootDir } = await loadConfig();
const { loopId } = await runBrief({
config,
rootDir,
route: '/dashboard',
intent: 'compress the header, show stats first',
});
await runSubmit({ config, rootDir, loopId, headed: true });
// User iterates in the browser, then quits via [q]
await runFetch({ config, rootDir, loopId });
await runApply({ config, rootDir, loopId });Full type signatures live in dist/index.d.ts (built; not committed) — or read src/index.ts directly.
"No saved Claude Design auth" — run design-loop login. The browser will open to claude.ai/design; log in, then close the window when prompted.
"Cloudflare bot-check detected — waiting up to 2 min for it to clear..." — Cloudflare's "Performing security verification" interstitial is showing on claude.ai/design. With a real browser fingerprint (i.e. --headed) it usually clears on its own within 30-60s; if it asks for a click, do it in the visible window and the script will continue. With --no-headed (headless) Chromium can't reliably pass Cloudflare's JS challenge, so the wait is bounded short and you'll likely fall through to the picker error below — rerun with --headed.
"Couldn't find the project picker" — your saved session expired, Anthropic served a verification challenge that wasn't solved in time, or Cloudflare blocked a headless run. Rerun the failing command with --headed and solve any challenges manually; the script will continue once it sees the New Project form.
"Another design-loop session is running (pid=…)" — the lockfile says someone else is driving the browser. If you're sure that pid is dead, the wizard offers a force-unlock prompt (y to take over). Or delete <loopsDir>/.lock.json manually.
"No design system named X exists on your claude.ai/design account" — your config has a name that doesn't match anything Anthropic has published for your account. Run design-loop systems to see the actual names + ids and update your config.
Bundle is empty / apply produces nothing — Anthropic's handoff sometimes ships only CSS + JSX with an empty <body>. The Svelte and Next.js adapters both handle this case (inlines CSS, copies the source files with a hint for the next agent). If you see truly empty output, open the bundle folder manually — bundle/canvas.html is the source of truth.
Proprietary — Eko Labs internal use only. See LICENSE.