widget-generator
The command-dash dashboard lets users describe a widget ("show me a heatmap of S&P sectors") and have Claude scaffold a working React component into the running app. The trick: a widget is a folder that Vite auto-discovers, so writing files to the right location is all it takes — no rebuild, no registration step.
When to use
Trigger this skill when the user wants to:
- Add an AI-powered "extend this app" feature where end-users describe new components
- Build a plug-in system that's discovered by directory scan + filesystem glob
- Generate UI code from natural language and persist it for later editing
- Hot-reload newly written modules without restarting the dev server
Architecture
apps/web/src/widgets/
├── gex/
│ ├── manifest.json ← name, description, defaultSize
│ └── index.tsx ← default-export React component
├── polymarket/
│ ├── manifest.json
│ └── index.tsx
└── <newly-generated-slug>/ ← Claude writes here
├── manifest.json
└── index.tsx
The registry (src/lib/widget-registry.ts):
import type { ComponentType } from 'react'
const modules = import.meta.glob('../widgets/*/index.tsx', { eager: true })
const manifests = import.meta.glob('../widgets/*/manifest.json', { eager: true })
export function loadWidgets() {
return Object.entries(modules).map(([path, mod]) => {
const slug = path.split('/')[2]
const manifest = (manifests[`../widgets/${slug}/manifest.json`] as any).default
return { slug, manifest, Component: (mod as any).default as ComponentType }
})
}
Vite resolves import.meta.glob with { eager: true } at module-load time. When a new folder appears under src/widgets/, Vite's HMR picks it up on the next save and triggers a page reload — no rebuild needed.
The Claude prompt
Two priorities: a strict output schema, and a concrete example. The model fills in the rest.
WIDGET_PROMPT = """You are scaffolding a new widget for the command-dash trading dashboard.
The dashboard auto-discovers widgets under `apps/web/src/widgets/<slug>/`. Each
widget folder must contain exactly two files:
1. `manifest.json` with shape:
```json
{
"name": "Human Readable Name",
"description": "One-sentence purpose.",
"defaultSize": { "w": 6, "h": 8 }
}
Grid units: w is 1–12, h is 1–20.
index.tsxexporting a default React component. The component must:
- Be self-contained (no external state stores)
- Fit any size — use flex/grid to fill its container
- Use Tailwind classes and these palette tokens:
bg-bg-card,bg-bg-elevated,border-bordertext-text-primary,text-text-secondary,text-text-dimtext-accent,text-bull,text-bear
- Fetch data via
fetch('/api/...')if needed
Example widget for reference:
import { useEffect, useState } from 'react'
export default function GexWidget() {
const [data, setData] = useState<any>(null)
useEffect(() => {
fetch('/api/gex/snapshot').then(r => r.json()).then(setData)
}, [])
if (!data) return <div className="p-4 text-text-dim text-sm">Loading…</div>
return (
<div className="h-full flex flex-col bg-bg-card border border-border rounded-lg p-3">
<div className="text-xs text-text-secondary mb-2">SPX Gamma</div>
<div className="text-2xl text-text-primary">${(data.gammaNotional / 1e9).toFixed(2)}B</div>
</div>
)
}
User's request: {prompt}
Respond with EXACTLY this JSON shape — no prose, no markdown fences: {{ "slug": "kebab-case-slug", "manifest": {{ "name": "...", "description": "...", "defaultSize": {{"w": 6, "h": 8}} }}, "tsx": "" }} """
## Endpoint that writes the files
```python
import json, re
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from anthropic import AsyncAnthropic
WIDGETS_DIR = Path("apps/web/src/widgets")
SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{1,40}$")
router = APIRouter()
class Req(BaseModel):
prompt: str
@router.post("/api/widgets/generate")
async def generate(req: Req):
client = AsyncAnthropic()
msg = await client.messages.create(
model="claude-sonnet-4-6",
max_tokens=4096,
messages=[{"role": "user", "content": WIDGET_PROMPT.format(prompt=req.prompt)}],
)
text = "".join(b.text for b in msg.content if hasattr(b, "text"))
# Tolerate optional code fences from the model
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("\n", 1)[1].rsplit("```", 1)[0]
spec = json.loads(cleaned)
slug = spec["slug"]
if not SLUG_RE.match(slug):
raise HTTPException(400, f"invalid slug: {slug}")
target = WIDGETS_DIR / slug
if target.exists():
raise HTTPException(409, f"{slug} already exists")
target.mkdir(parents=True)
(target / "manifest.json").write_text(json.dumps(spec["manifest"], indent=2))
(target / "index.tsx").write_text(spec["tsx"])
return {"slug": slug, "manifest": spec["manifest"]}
Trust model and security caveats
This pattern executes Claude-authored React code in the user's browser. That's fine when:
- The dashboard runs locally on a single user's machine
- The Anthropic API key is theirs
- They can read the generated code before clicking refresh
It is not safe for multi-tenant deployments. Don't ship this pattern to production without:
- Sandboxing the generated code (e.g., iframe + postMessage)
- Or constraining the output to a JSON spec interpreted by a fixed renderer (no
eval-equivalent paths) - Or routing generation through a CI step where humans review before merging
Patterns that don't work
- Trying to load widgets at runtime via dynamic
import()— Vite needs static analysis to know about modules.import.meta.globis the supported pattern; runtimeimport('/widgets/foo/index.tsx')won't tree-shake correctly and breaks production builds. - Returning prose with the JSON — the model loves to add "Here's your widget:" before the code block. The
cleaned.startswith("```")strip + a one-shot example in the prompt keeps it consistent. - Letting Claude pick the slug freely — validate against a regex, or you'll get
../../etc/passwdinslugfrom a prompt-injection attempt. TheSLUG_REcheck above is non-negotiable.
Reference
In command-dash:
apps/web/src/lib/widget-registry.ts— the glob loaderapps/web/src/components/WidgetCreator.tsx— the prompt modalapps/api/app/widgets/routes.py— the/api/widgets/generateendpoint