skills/tools-and-apis/policyengine-interactive-tools-skill/SKILL.md
Building standalone interactive calculators and dashboards that embed in policyengine.org
npx skillsauth add policyengine/policyengine-claude policyengine-interactive-toolsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
How to build standalone React apps (calculators, dashboards, visualizations) that integrate into policyengine.org as Next.js multi-zones (preferred) or via iframe (legacy).
Use /new-tool plus this skill as the canonical scaffold for new repos. The production repos below are useful pattern references, but they do not all match the current frontend standard.
PolicyEngine/givecalc) — custom Modal API with policyengine-usPolicyEngine/state-legislative-tracker) — precomputed/static data with external app embeddingPolicyEngine/snap-bbce-repeal) — precomputed CSV dashboardPolicyEngine/marriage) — older Vite-era frontend; use for business logic, not scaffoldingPolicyEngine/ACA-Calc) — older Vite-era frontend; use for data ideas, not scaffoldingPolicyEngine/student-loan-calculator) — legacy design-system/CDN setup; do not copyPolicyEngine/uk-spring-statement-2026) — custom migration case; use for policy logic, not baseline architectureNew tools default to Next.js 14 + Tailwind 4 + Recharts. Some deployed tools predate this stack; treat those repos as migration targets or pattern references, not templates.
| Component | Choice |
|-----------|--------|
| Framework | Next.js 14 (App Router) |
| CSS | Tailwind 4 with @policyengine/ui-kit theme |
| Charts | Recharts |
| Code highlighting | Prism React Renderer |
| Testing | Vitest |
| Deploy | Vercel under policy-engine scope |
| Package manager | bun (not npm) |
Requirements:
@policyengine/ui-kit theme (installed via bun add @policyengine/ui-kit)var(--primary), var(--chart-1), var(--font-sans))@policyengine/ui-kit or PolicyEngine assets; never hotlink a raw GitHub URLpolicyengine.py versions, model/data versions, static-estimate caveats, and calculation notes must not be added to the shared PolicyEngine header, footer, or global layout. When touching these notes, add or preserve a regression test that the shared chrome does not contain app-specific provenance and the results/methodology footnote does.PolicyEngine tools integrate with policyengine.org as Next.js multi-zones. The host website (policyengine-app-v2/website/) proxies specific URL paths to standalone Vercel deployments via rewrites, so users see one site while each tool remains independently deployable.
Multi-zone replaces iframe embedding for all new tools. Iframe embedding is retained only for legacy tools and the obbba-iframe / custom apps.json types — see "Legacy iframe embedding" below.
Pick the pattern from the zone's path shape first, then layer on the build-type config:
| Zone owns | Pattern |
|---|---|
| One public path matching the repo's kebab name | Path-mounted zone — literal basePath |
| Multiple public paths (e.g. /us/api + /uk/api) | Root-served zone — no basePath, host rewrites map each public path to the zone root |
| Zone build type | Additional config |
|---|---|
| Server-rendered, path-mounted | None — basePath scopes _next/* automatically |
| Server-rendered, root-served | assetPrefix: '/_zones/<repo-name>' (zone has no basePath, so _next/* would collide with the host without a prefix) |
| Static export (output: 'export'), either pattern | Phase-gated assetPrefix: '/_zones/<repo-name>' + vercel.json self-rewrite |
Both patterns are endorsed by the official docs — pick the one that fits the zone's path shape.
basePath: '/us/my-tool'. Hardcoded string matching the public path. Used by the official Next.js with-zones example. Default for single-path zones — i.e. when the zone owns exactly one public path that matches the repo name in kebab case.basePath; host rewrites map each public path to the zone's root. Used by the Next.js multi-zones guide's own example (the blog zone uses assetPrefix: '/blog-static' with no basePath). Requires assetPrefix: '/_zones/<repo-name>' so the zone's _next/static/* assets do not collide with the host or other zones. Required when one zone owns multiple public paths, since basePath accepts only one literal string. Production reference: household-api-docs (serves both /us/api and /uk/api from one deployment via [countryId] dynamic routes).The Next.js multi-zones guide states only one cross-zone constraint: "URL paths should be unique to a zone." A zone can own multiple paths; two zones can't share one.
Why no env-driven
basePath(e.g.process.env.NEXT_PUBLIC_BASE_PATH ?? '/us/my-tool')? Neither documented pattern uses an env override. The intended dev workflow is to hit the zone atlocalhost:<port>/<basePath>directly (path-mounted) orlocalhost:<port>/<public-path>(root-served), or to run the host with itsrewrites()destinationpointed atlocalhost:<zone-port>for end-to-end local development. There is no docs-endorsed "drop the basePath in dev" escape hatch, and adding one hides basePath bugs that would otherwise surface in dev. A few existing zones (keep-your-pay-act,oregon-kicker-refund,working-parents-tax-relief-act) still use this pattern; they're tracked for retrofit but are not multizone blockers.
Host rewrite shape depends on the chosen pattern — see the "Canonical zone config" sections below; each shows the matching host rewrite inline.
basePath: route URLs — page paths, next/link hrefs, API route paths. Auto-scopes _next/static/ assets for server-rendered builds.assetPrefix: static asset URLs only — _next/static/*, next/image, next/script. Needed when basePath can't scope assets automatically (static exports) or when the zone has no basePath (root-served).Both may coexist — they govern different URL types and never conflict.
// zone's next.config.ts
const nextConfig: NextConfig = {
basePath: '/us/my-tool',
// no assetPrefix needed — basePath scopes _next/static automatically
};
export default nextConfig;
// host: policyengine-app-v2/website/next.config.ts — in beforeFiles
// Hardcode the zone's production Vercel URL (whatever Vercel auto-assigned on first deploy).
{ source: '/us/my-tool', destination: 'https://my-tool.vercel.app/us/my-tool' },
{ source: '/us/my-tool/:path*', destination: 'https://my-tool.vercel.app/us/my-tool/:path*' },
Static exports need three coordinated pieces — each covers a different environment; omitting any one breaks that environment. (The root-served variant of this pattern, with no basePath, is in production at PolicyEngine/household-api-docs.)
1. Zone's next.config.mjs — phase-gated assetPrefix
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js';
export default function nextConfig(phase) {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
return {
output: 'export',
basePath: '/us/my-tool',
// Drop the prefix in `next dev` so local assets resolve without /_zones/*.
// In builds, apply it so assets don't collide with the host's _next/static.
assetPrefix: isDev ? undefined : '/_zones/my-tool',
trailingSlash: true,
};
}
Export a function (not an object) so Next.js passes the build phase. PHASE_DEVELOPMENT_SERVER fires during next dev; all other phases (next build, next start) do not.
2. Zone's vercel.json — self-rewrite
{
"rewrites": [
{ "source": "/_zones/my-tool/_next/:path*", "destination": "/_next/:path*" }
]
}
Lets the zone's own Vercel deployment serve its built, prefixed assets when hit directly at its .vercel.app URL (e.g. for zone-only previews).
3. Host's website/next.config.ts — asset rewrite
// in beforeFiles, alongside the route rewrites
{ source: '/_zones/my-tool/:path*', destination: 'https://my-tool.vercel.app/_zones/my-tool/:path*' },
Plus the two route rewrites (same as server-rendered). Total: three rewrites for static-export zones, two for server-rendered.
| Environment | Who serves the request | Which piece fixes it |
|---|---|---|
| bun dev at localhost:3001/us/my-tool | next dev — ignores vercel.json | Phase gate drops the prefix |
| Zone-only preview at my-tool.vercel.app/us/my-tool | Zone's own Vercel deploy | vercel.json self-rewrite strips the prefix internally |
| Prod via host at policyengine.org/us/my-tool | Host website, proxying to zone | Host asset rewrite forwards /_zones/* to the zone |
Drop any one and the corresponding environment 404s its JS/CSS.
beforeFiles in the host. Zone rewrites must take priority over the website's dynamic [slug] routes.<a>, not <Link>. next/link does client-side routing and breaks across zones.assetPrefix. Always use /_zones/<repo-name> so the zone isn't hardcoded to a specific Vercel domain.assetPrefix on PHASE_DEVELOPMENT_SERVER. See template above. Unconditional assetPrefix breaks next dev.@policyengine/ui-kit (Header, Footer) so zones look native to the host.The Next.js docs recommend the icon file convention over metadata.icons in general ("the file-based API will automatically generate the correct metadata for you"). Under multi-zone that recommendation becomes load-bearing, because metadata.icons URLs aren't basePath-prefixed (see below).
Drop the icon image directly into app/:
app/icon.{ico,jpg,jpeg,png,svg} → emitted as <link rel="icon">app/apple-icon.{jpg,jpeg,png} (PNG only — Safari ignores SVG; recommended size 180×180) → emitted as <link rel="apple-touch-icon">Next.js generates the link tag with the basePath already prefixed, plus a content hash and the right MIME type, with no extra config.
Do not put icon URLs in metadata.icons (e.g. icons: { icon: '/favicon.svg' }). The Next.js docs don't explicitly address the multi-zone interaction, but those URLs are not auto-prefixed with basePath — see vercel/next.js#61487 (closed as not planned). Under multi-zone, an icon defined in metadata.icons resolves at the host root (policyengine.org/favicon.svg) instead of under the zone's basePath, and 404s if the host doesn't serve a file at that path.
Working example: policyengine-taxsim (dashboard/src/app/icon.png).
/us/<kebab-name> or /uk/<kebab-name> (e.g. /us/watca, /us/keep-your-pay-act)/<kebab-name> (e.g. /slides, /plugin-blog)/embed (e.g. /us/california-wealth-tax/embed)The zone path must match the repo name's kebab-case form unless there's a strong reason to differ.
output: 'export': phase-gated assetPrefix: '/_zones/<repo-name>' + vercel.json self-rewritepolicyengine-app-v2/website/next.config.ts in beforeFiles — shape matches the chosen pattern (preserve basePath in destination for path-mounted; map to zone root for root-served), hardcoded to the zone's production Vercel URL<a>, not <Link>app/icon.{png,svg,...}) so basePath is auto-prefixed — do not use metadata.icons URLs (see vercel/next.js#61487)Run /audit-multizone <path> to validate an existing tool against these rules. The multizone-validator agent reports findings without editing.
Known nonstandard: Model docs use an absolute-URL assetPrefix pointing at its Vercel domain — migrate to /_zones/policyengine-model when touching that repo.
NEVER manually copy numbers from ad-hoc calculations (bash, Python REPL, etc.) into source files. All data displayed in charts or UI must come from a generation script that writes to a data file (JSON, CSV) which the frontend imports.
The correct flow is always:
Python script (reads reform/config) → data file (JSON/CSV) → frontend imports data file
Never:
Ad-hoc Python in terminal → copy numbers → paste into .tsx/.jsx file
If a repo has a data generation script (e.g., scripts/generate_*.py), update that script and re-run it. If one doesn't exist, create one. The script should:
reform.json)Simulation call)Choose based on what the tool needs from PolicyEngine:
Best when the parameter space is small enough to enumerate, or the tool shows static analysis results.
When to use: Dashboards showing pre-run scenarios, legislative trackers, tools where inputs map to a finite set of outputs.
┌─────────────┐ ┌──────────┐ ┌───────────┐
│ Python script│───>│ JSON file│───>│ Next.js │
│ (one-time) │ │ (static) │ │ (fast) │
└─────────────┘ └──────────┘ └───────────┘
Example: State legislative tracker pre-computes budget impacts for every state bill and ships a JSON file.
# scripts/precompute.py
from policyengine_us import Microsimulation
results = {}
for reform_id, reform in reforms.items():
sim = Microsimulation(reform=reform)
results[reform_id] = {
"revenue_change": float(sim.calculate("revenue_change")),
"poverty_change": float(sim.calculate("poverty_change")),
}
with open("src/data/results.json", "w") as f:
json.dump(results, f)
// React — just reads the JSON
import results from "./data/results.json";
function Dashboard({ reformId }) {
const data = results[reformId];
return <MetricCard value={data.revenue_change} />;
}
Pros: Zero latency, no API costs, works offline. Cons: Can't handle continuous user inputs; stale if policy changes.
Best when the tool calculates household-level impacts with varying incomes/demographics. The main PolicyEngine API (api.policyengine.org) handles standard household simulations.
When to use: Tools where users enter income, family size, state, and see tax/benefit impacts. Works when all the variables you need are in the PolicyEngine API.
┌───────────┐ ┌──────────────────┐ ┌──────────┐
│ Next.js │───>│ api.policyengine │───>│ Results │
│ (browser) │<───│ .org/us/calculate │<───│ │
└───────────┘ └──────────────────┘ └──────────┘
Example: Marriage calculator sends household JSON and gets back tax/benefit amounts.
// api.js
const API_BASE = "https://api.policyengine.org";
export async function calculateHousehold(countryId, household) {
const res = await fetch(`${API_BASE}/${countryId}/calculate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ household }),
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
Household JSON structure:
{
"people": {
"head": { "age": { "2025": 40 }, "employment_income": { "2025": 50000 } },
"spouse": { "age": { "2025": 35 }, "employment_income": { "2025": 30000 } }
},
"tax_units": { "tax_unit": { "members": ["head", "spouse"] } },
"spm_units": { "spm_unit": { "members": ["head", "spouse"] } },
"households": { "household": { "members": ["head", "spouse"], "state_code": { "2025": "CA" } } }
}
Comparing scenarios: To show the effect of marriage, call the API twice (unmarried vs married household) and diff the results.
Pros: Always up-to-date with latest policy rules, handles arbitrary inputs. Cons: Network latency (1-5s per call), rate limits, limited to variables the API supports.
Best when you need variables or calculations not in the main PolicyEngine API — custom reform parameters, non-standard entity structures, or computations that combine PolicyEngine with other models.
Decision rule: Before choosing Pattern C, verify that the PolicyEngine API (
api.policyengine.org) cannot handle the computation. Pattern C is only needed when:
- You need microsimulation (society-wide) results
- You need custom reform parameters not exposed by the API
- You need variables or entity structures not supported by the API
If the tool only needs household-level calculations, Pattern B (PolicyEngine API) is always preferred — it's faster, always up-to-date, and requires no backend maintenance.
When to use: Tools that vary parameters not exposed by the main API (e.g., varying UBI amounts, custom phase-outs), or tools that need microsimulation (society-wide) results for arbitrary reforms.
Architecture: Two-layer gateway + worker with frontend polling. This mirrors the pattern used by PolicyEngine API v1 and API v2.
┌───────────┐ POST /submit ┌──────────────────┐ spawn() ┌──────────────┐
│ Next.js │──────────────>│ Gateway (FastAPI) │─────────>│ Worker │
│ (browser) │ │ (lightweight) │ │ (policyengine)│
│ │ GET /status │ │ poll │ │
│ │<──────────────│ │<─────────│ │
└───────────┘ {status,data} └──────────────────┘ └──────────────┘
Resource principle: The gateway and workers have opposite resource profiles:
| Layer | CPU | Memory | Scaling | Why |
|-------|-----|--------|---------|-----|
| Gateway | Minimal (default) | Minimal (128–256 MB) | Always-on is fine — it's cheap | Only does HTTP routing, spawn(), and FunctionCall.from_id() — no heavy computation |
| Workers | High (4–8 CPU) | High (16–32 GB) | Must wind down to zero instances | Expensive to keep warm; Modal cold-starts are fast (~2s with image snapshot) |
The gateway MUST be lightweight — no policyengine-us/policyengine-uk dependency, no large memory allocation. It exists solely to accept requests, dispatch jobs to workers via spawn(), and report status. Keep its image small (just fastapi and pydantic) and its resource footprint minimal.
The worker functions do the heavy lifting (loading the tax-benefit system, running simulations) and should be configured with high CPU/memory. But they MUST be allowed to scale to zero when idle — never set keep_warm or min_containers on worker functions. Modal's image snapshot (via .run_function()) keeps cold starts fast enough that always-warm workers are not worth the cost.
Why not synchronous HTTP? Modal's dev gateway (modal serve) and production gateway have a ~150s timeout. Long-running requests (like US statewide microsimulations, which take 2-5+ minutes) get an HTTP 303 redirect that browser fetch() cannot follow for POST requests. The gateway + polling architecture avoids this entirely.
The backend uses a three-file structure mirroring policyengine-api-v2's simulation service. This prevents a common crash-loop where module-level imports of pydantic or policyengine fail because those packages are only available inside the Modal function's image, not at module import time.
| File | Purpose | Module-level imports |
|------|---------|---------------------|
| backend/_image_setup.py | Standalone snapshot function — runs during image build | None (all inside function body) |
| backend/app.py | Modal app + function decorators | Only modal |
| backend/simulation.py | Pure business logic | policyengine_us/_uk (captured in image snapshot) |
| backend/modal_app.py | Lightweight gateway (FastAPI) | modal, fastapi, pydantic |
backend/_image_setup.py)Standalone function with no package imports at module level — executed during image build via .run_function():
def snapshot_models():
"""Pre-load models at image build time for fast cold starts."""
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Pre-loading tax-benefit system...")
from policyengine_us import CountryTaxBenefitSystem # or policyengine_uk
CountryTaxBenefitSystem()
logger.info("Models pre-loaded into image snapshot")
backend/app.py)Only modal at module level. Imports business logic inside each function body:
import modal
from pathlib import Path
from _image_setup import snapshot_models
app = modal.App("my-tool-workers")
_BACKEND_DIR = Path(__file__).parent
image = (
modal.Image.debian_slim(python_version="3.11")
.pip_install("policyengine-us==X.Y.Z", "pydantic") # Pin to latest — look up from PyPI
.run_function(snapshot_models)
.add_local_file(str(_BACKEND_DIR / "simulation.py"), remote_path="/root/simulation.py")
)
# Workers: high resources, but wind down to zero when idle.
# NEVER set keep_warm or min_containers — cold starts are fast thanks to image snapshot.
@app.function(image=image, cpu=8.0, memory=32768, timeout=3600)
def compute_household(params: dict) -> dict:
from simulation import run_household
return run_household(params)
@app.function(image=image, cpu=8.0, memory=32768, timeout=3600)
def compute_statewide(params: dict) -> dict:
from simulation import run_statewide
return run_statewide(params)
backend/simulation.py)Pure business logic — policyengine imports at module level (captured in the image snapshot via .run_function()). No Modal imports here.
from policyengine_us import Simulation, Microsimulation # Snapshotted at build time
def run_household(params: dict) -> dict:
sim = Simulation(situation=params["household"])
return {
"net_income": float(sim.calculate("household_net_income", 2025).sum()),
}
def run_statewide(params: dict) -> dict:
baseline = Microsimulation()
reform = Microsimulation(reform=params["reform"])
# ... compute impacts
return {"revenue_change": ..., "winners": ..., "losers": ...}
backend/modal_app.py)The gateway is lightweight — no policyengine dependency. It spawns worker jobs and polls for results:
import modal
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = modal.App("my-tool")
gateway_image = modal.Image.debian_slim(python_version="3.11").pip_install(
"fastapi", "pydantic",
)
WORKER_APP = "my-tool-workers"
FUNCTION_MAP = {
"household-impact": "compute_household",
"statewide-impact": "compute_statewide",
}
web_app = FastAPI()
web_app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
class SubmitResponse(BaseModel):
job_id: str
class StatusResponse(BaseModel):
status: str # "computing" | "ok" | "error"
result: dict | None = None
message: str | None = None
@web_app.post("/submit/{endpoint}")
def submit(endpoint: str, params: dict):
if endpoint not in FUNCTION_MAP:
raise HTTPException(status_code=404, detail=f"Unknown endpoint: {endpoint}")
fn = modal.Function.from_name(WORKER_APP, FUNCTION_MAP[endpoint])
call = fn.spawn(params)
return SubmitResponse(job_id=call.object_id)
@web_app.get("/status/{job_id}")
def status(job_id: str):
from modal.functions import FunctionCall
call = FunctionCall.from_id(job_id)
try:
result = call.get(timeout=0)
return StatusResponse(status="ok", result=result)
except TimeoutError:
return StatusResponse(status="computing")
except Exception as e:
return StatusResponse(status="error", message=str(e))
# Gateway: minimal resources — just HTTP routing, no heavy computation.
@app.function(image=gateway_image, memory=256)
@modal.asgi_app()
def fastapi_app():
return web_app
const API_URL = process.env.NEXT_PUBLIC_API_URL || "https://policyengine--my-tool-fastapi-app.modal.run";
export async function submitJob(endpoint: string, params: unknown): Promise<string> {
const res = await fetch(`${API_URL}/submit/${endpoint}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error(`Submit failed: ${res.status}`);
const data = await res.json();
return data.job_id;
}
export async function pollStatus(jobId: string) {
const res = await fetch(`${API_URL}/status/${jobId}`);
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
return res.json(); // { status: "computing" | "ok" | "error", result?, message? }
}
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { submitJob, pollStatus } from "../api/client";
export function useAsyncCalculation(queryKey: unknown[], endpoint: string, params: unknown, enabled = true) {
const [jobId, setJobId] = useState<string | null>(null);
// Step 1: Submit job when params change
const submit = useQuery({
queryKey: [...queryKey, "submit"],
queryFn: async () => {
const id = await submitJob(endpoint, params);
setJobId(id);
return id;
},
enabled,
});
// Step 2: Poll for results
const poll = useQuery({
queryKey: [...queryKey, "poll", jobId],
queryFn: () => pollStatus(jobId!),
enabled: !!jobId,
refetchInterval: (query) =>
query.state.data?.status === "computing" ? 2000 : false,
});
return {
isLoading: submit.isLoading || (!!jobId && poll.isLoading),
isComputing: poll.data?.status === "computing",
isError: submit.isError || poll.data?.status === "error",
data: poll.data?.status === "ok" ? poll.data.result : undefined,
error: poll.data?.message || submit.error?.message,
};
}
Deploy:
# Deploy the worker functions first (includes image snapshot — first build takes ~5 min)
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET
modal deploy backend/app.py
# Deploy the gateway
modal deploy backend/modal_app.py
URL pattern: https://policyengine--my-tool-fastapi-app.modal.run
Set Vercel env var:
vercel env add NEXT_PUBLIC_API_URL production
# Enter: https://policyengine--my-tool-fastapi-app.modal.run
vercel --prod --force --yes --scope policy-engine
Pros: Full control over calculations, can use any policyengine variables/reforms, can do microsimulation, no timeout issues. Cons: Fast cold starts (~2s thanks to model pre-loading via .run_function(); without snapshot, cold starts take 3-5 minutes), Modal costs, must pin policyengine version, must redeploy when policy rules update, more complex architecture (four files).
Failure mode: Modal apps can silently disappear. If frontend gets network errors, curl the Modal URL — if 404, redeploy.
| Context | Default timeout | Max timeout | Notes |
|---------|----------------|-------------|-------|
| @app.function(timeout=...) | 300s | 86,400s (24h) | Set per-function |
| modal serve dev gateway | ~150s | Not configurable | Returns HTTP 303 on timeout |
| modal deploy prod gateway | ~150s | Not configurable | Returns HTTP 303 on timeout |
US statewide microsimulations take 2-5+ minutes. This exceeds the gateway timeout, which is why synchronous HTTP calls fail for microsimulation endpoints. The gateway + polling architecture avoids this by using non-blocking job submission. Household-level simulations typically complete in 10-40s, within the gateway timeout, but polling is still recommended for consistency.
For analysis repos that precompute data with Python microsimulation pipelines:
┌─────────────────┐ ┌──────────┐ ┌────────────────┐
│ Python pipeline │───>│ CSV files│───>│ Next.js app │
│ (Microsimulation)│ │ public/ │ │ (static export)│
└─────────────────┘ └──────────┘ └────────────────┘
Python side: Pipeline generates CSVs to public/data/.
Frontend side: Fetch CSVs at runtime, parse with a lightweight CSV parser.
Example: PolicyEngine/snap-bbce-repeal, PolicyEngine/uk-spring-statement-2026.
bunx create-next-app@14 my-tool --js --app --tailwind --eslint --no-src-dir --import-alias "@/*"
cd my-tool
bun add @policyengine/ui-kit recharts
bun add -D vitest
import "./globals.css";
export const metadata = {
title: "TOOL_TITLE | PolicyEngine",
description: "DESCRIPTION",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);
}
@import "tailwindcss";
@import "@policyengine/ui-kit/theme.css";
body {
font-family: var(--font-sans);
color: var(--foreground);
background: var(--background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
The single @import "@policyengine/ui-kit/theme.css" replaces the entire manual @theme block. It provides all color, spacing, and typography tokens as CSS variables that Tailwind 4 picks up automatically.
Use Tailwind classes from the ui-kit theme:
<div className="bg-muted border border-border rounded-lg p-4">
Or use style= with var() for inline styles:
<div style={{
backgroundColor: "var(--muted)",
border: "1px solid var(--border)",
borderRadius: "var(--radius)",
padding: "1rem",
}}>
For new tools, use multi-zone integration instead. This section covers iframe embedding, retained for legacy tools and for apps.json types that still require it (
obbba-iframe,custom).
Add entry to website/src/data/apps.json in policyengine-app-v2:
{
"type": "iframe",
"slug": "my-tool",
"title": "My interactive tool",
"description": "What this tool does",
"source": "https://my-tool-auto-url.vercel.app/",
"tags": ["us", "featured", "policy", "interactives"],
"countryId": "us",
"displayWithResearch": true,
"image": "my-tool-cover.png",
"date": "2026-02-14 12:00:00",
"authors": ["author-slug"]
}
App types: iframe (standard), obbba-iframe (special layout), custom (React component).
Multi-country: Same slug, different countryId:
{ "slug": "marriage", "countryId": "us", ... },
{ "slug": "marriage", "countryId": "uk", "displayWithResearch": false, ... }
Source URL: Use the auto-assigned Vercel production URL (e.g., marriage-zeta-beryl.vercel.app), not a custom alias — aliases may have deployment protection issues.
Required fields for displayWithResearch: true: image, date, authors.
If the tool needs a proxied path or nonstandard rewrite instead of a direct iframe source URL, update website/next.config.ts in policyengine-app-v2 at the same time.
When embedded at /uk/my-tool, policyengine.org injects #country=uk into the iframe URL.
// Read country from hash — independently of other params
function getCountryFromHash() {
const params = new URLSearchParams(window.location.hash.slice(1));
return params.get("country") || "us";
}
const [countryId, setCountryId] = useState(getCountryFromHash());
Important: Read country independently. Don't require region or income to be present — the parent may only send #country=uk.
The parent app syncs the iframe hash to the browser URL bar:
// Update hash when inputs change
const hash = `#region=CA&head=50000&spouse=40000`;
window.history.replaceState(null, "", hash);
// Notify parent
if (window.self !== window.top) {
window.parent.postMessage({ type: "hashchange", hash }, "*");
}
When embedded, skip the country param in hash — it's redundant with the URL path:
const isEmbedded = window.self !== window.top;
if (countryId !== "us" && !isEmbedded) params.set("country", countryId);
Point to policyengine.org, not the Vercel URL:
function getShareUrl(countryId) {
const hash = window.location.hash;
if (window.self !== window.top) {
return `https://policyengine.org/${countryId}/my-tool${hash}`;
}
return window.location.href;
}
Hide when embedded (country comes from the route):
<InputForm countries={isEmbedded ? null : COUNTRIES} ... />
Recharts is the PE standard for all charts:
bun add recharts
For simple visualizations: Use SVG directly. The marriage calculator uses hand-rolled SVG heatmaps.
Color conventions:
var(--chart-1)var(--chart-3) or var(--destructive)var(--border)Inverted metrics (taxes): When positive delta means bad (more taxes), pass invertDelta to your chart component to flip labels and colors.
Recharts accepts CSS variables directly via fill and stroke props:
<BarChart data={data}>
<CartesianGrid stroke="var(--border)" />
<XAxis niceTicks="snap125" domain={["auto", "auto"]} tick={{ fontSize: 12, fontFamily: "var(--font-sans)" }} />
<YAxis niceTicks="snap125" domain={["auto", "auto"]} tick={{ fontSize: 12, fontFamily: "var(--font-sans)" }} />
<Bar dataKey="value" fill="var(--chart-1)" />
</BarChart>
Always set niceTicks="snap125" on every <XAxis> and <YAxis>. This snaps tick step sizes to {1, 2, 2.5, 5} × 10^n, producing human-friendly round labels like 0, 5, 10, 15, 20. Do NOT use niceTicks as a bare boolean or niceTicks="auto" — always specify "snap125" explicitly. The snap125 algorithm may leave some blank space at chart edges; this is the correct trade-off for readability.
Always pair with domain={["auto", "auto"]} — the default recharts domain [0, 'auto'] clamps the minimum to 0, which breaks tick calculation for data that doesn't start at 0 (e.g., all-negative values). Setting both ends to "auto" lets recharts compute the domain from the data.
Format negative dollar values as -$100 not $-100 — use a custom tickFormatter like:
tickFormatter={(v) => v < 0 ? `-$${Math.abs(v)}` : `$${v}`}
Never pass hardcoded hex values like fill="#319795" to Recharts — always use CSS variables (e.g., fill="var(--chart-1)").
For tools that show code or formulas, use Prism React Renderer:
bun add prism-react-renderer
Use Tailwind responsive prefixes (sm:, md:, lg:) or custom media queries:
/* Tablet — sidebar collapses to top */
@media (max-width: 768px) { ... }
/* Phone — form rows stack */
@media (max-width: 480px) {
.form-row { flex-direction: column; }
}
Key patterns:
bun add -D vitest
bunx vitest run
Test API responses against Python fixtures for numerical accuracy. See PolicyEngine/marriage/tests/ for examples.
Every standalone tool repo should add .github/workflows/ci.yml before launch:
name: CI
on:
pull_request:
push:
branches: [main, master]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Run PolicyEngine migration guardrails
run: |
curl -fsSL https://raw.githubusercontent.com/PolicyEngine/policyengine-skills/main/scripts/audit_next_migration.py -o /tmp/audit_next_migration.py
python3 /tmp/audit_next_migration.py --root .
- run: bun install --frozen-lockfile
- name: Run lint
run: |
if jq -e '.scripts.lint' package.json >/dev/null; then
bun run lint
else
echo "No lint script"
fi
- name: Run tests
run: |
if jq -e '.scripts.test' package.json >/dev/null; then
bun run test
else
echo "No test script"
fi
- run: bun run build
Do not leave embedded repos deploy-only or schedule-only. If the repo ships on policyengine.org, it needs pull-request validation and migration guardrails.
curl returning 200 does NOT mean a frontend works. SPAs serve an HTML shell regardless of whether React components render. The only reliable check is bun run build.lsof -i :<port>.bun install fails, try at most 2 approaches before asking the user. Do not rabbit-hole into manual tar extraction, rm -rf node_modules, or obscure npm flags.@policyengine/ui-kit installed (bun add @policyengine/ui-kit)@import "@policyengine/ui-kit/theme.css" in globals.cssvar(--font-sans)fill="var(--chart-1)" pattern for SVG props (font, colors)niceTicks="snap125" with domain={["auto", "auto"]} for human-friendly tick values-$100 not $-100policy-engine scope.github/workflows/ci.yml)audit_next_migration.py)See "New-zone checklist" in the Multi-zone integration section above.
#country=uk)policyengine-app-v2/website/src/data/apps.json (with cover image if displayWithResearch)policyengine-app-v2/website/next.config.ts updated when the tool needs a proxy/rewrite pathpolicyengine-design-skill — Full token referencepolicyengine-vercel-deployment-skill — Vercel deployment patternspolicyengine-app-skill — app-v2 development (different from standalone tools)development
ALWAYS LOAD THIS SKILL for PolicyEngine PR reviews, including when the user invokes $review-program or Codex /review on a PolicyEngine PR. Performs read-only code validation, source-reference checks, regulatory review, optional PDF audit, summary reporting, and optional GitHub comment posting.
development
Use when the user invokes $fix-pr or asks Codex to apply fixes to a PolicyEngine PR based on $review-program findings, GitHub review comments, CI failures, or local review reports.
development
Use when the user invokes $encode-policy-v2 or asks Codex to implement a new PolicyEngine-US state benefit program from official rules. Covers research, source collection, requirement extraction, scoped implementation, tests, validation, and draft PR preparation.
development
Deploying PolicyEngine frontend apps to Vercel - naming, scope, team settings