
dashboard-builder
Orchestrated AI workflow for creating PolicyEngine dashboards from natural-language descriptions
- version
- 4.1.1
- GitHub stars
- 31
Install
/plugin install dashboard-builder@policyengine-claudeRun it inside Claude Code after adding the marketplace with /plugin marketplace add PolicyEngine/policyengine-claude.
Contents
Commands 2
Creating Dashboard: $ARGUMENTS
Coordinate a multi-agent workflow to plan, scaffold, implement, validate, and commit a production-ready PolicyEngine dashboard application.
When to use vs /new-tool
/create-dashboard— multi-page policy analysis apps with plan/scaffold/implement/validate phases. Think "child poverty rates by state under different CTC reform scenarios" — multiple views, filters, embedded charts, backend calls./new-tool— bootstrapper for a single-purpose interactive calculator (e.g., "SALTernative", "ACA-calc"). Blank canvas + design tokens; you implement the specific tool logic yourself.
Arguments
$ARGUMENTS should contain:
- Dashboard description (required) — a natural-language description of the desired dashboard
- Options:
--repo NAME_OR_PATH— use an existing repo (searches sibling of cwd,~/Documents/PolicyEngine/,~/PolicyEngine/,~/)--skip-init— use current working directory as the repo (no repo creation or cloning)--skip-validate— skip the Phase 5 validation loop--auto-confirm— headless mode: no AskUserQuestion gates (see "Headless mode" below)--reform-json PATH— path to a validated reform_dict JSON file; the planner must treat it as the canonical reform definition (typically produced by a prior /analyze-policy run)--dry-run— with--auto-confirm: run Phase 0 argument parsing + Phase 1 planning only; no repo creation, no build, no push--horizon {1|10}— impact time horizon (default1).10adds 10-year series via the API's budget-window compute (~15-25 min extra) — opt in only when the dashboard needs it--no-household— omit the household example page (included by default)
Standard pages: every policy-analysis dashboard includes a policy
explanation page (provisions + source links + parameter table) and a
validation page (results vs external benchmarks, versions, methodology) in
addition to the impacts pages — see dashboard-planner.md "Step 1b". The
household page is standard unless --no-household; its inputs must mirror
the reform's levers (CTC → children/ages; SS taxation → benefit + filing
status + other income) with 3-5 representative precomputed preset cases —
see dashboard-planner.md Step 1b item 4.
Standard chrome: every page keeps the real PolicyEngine site header
(ui-kit PolicyEngineHeader) on top — site nav, logo, country selector —
with the flagship dashboard chrome below it (hero band + folder-style page
tabs + white content card on gray, as on south-carolina-2026-tax-changes).
Never swap the dashboard's page links into the header's nav (see
dashboard-planner.md "Standard chrome" and frontend-builder.md Step 5).
Examples:
/create-dashboard A dashboard showing child poverty rates by state under different CTC reform scenarios
/create-dashboard --repo child-poverty-dashboard "Add a comparison mode showing baseline vs reform"
/create-dashboard --skip-init "Build a SNAP eligibility calculator for a single household"
/create-dashboard --skip-validate "A simple income tax calculator using the PolicyEngine API"
/create-dashboard --repo ctc-restoration-dashboard --auto-confirm --reform-json /tmp/reform.json "Dashboard analyzing the ARPA CTC restoration: budgetary cost, child poverty impact, distributional effects by decile and state"
Headless mode (--auto-confirm)
Used by CI (.github/workflows/create-dashboard.yml, dispatched from the CRM
publication router). Every human gate gets a deterministic resolution:
- Phase 0 (repo name / clone path):
--repo NAMEis REQUIRED with--auto-confirm— it names the desired repo. If not found locally andgh repo view PolicyEngine/{NAME}404s, createPolicyEngine/{NAME}and clone to the sibling of cwd. No questions. - Phase 1 (plan approval): auto-approve the planner's plan.yaml.
- Phase 5 (validation loop): if validators still fail after max fix cycles, CONTINUE with warnings (record them in the result file) rather than stopping.
- Phase 6 (commit gate): auto commit + push.
- Any error that would otherwise AskUserQuestion (auth failure, repo
collision with
--skip-initambiguity, etc.): STOP and write the result file with"status": "failed"and the reason.
CI compute constraint (CRITICAL in headless mode): hosted runners have
16GB RAM — a policyengine-us national microsimulation OOM-kills the runner
(verified 2026-07-13: exit 143 mid-build). Agents must NEVER run local
microsimulations in headless mode. For precomputed data patterns, fetch
impacts from www.policyengine.org's dashboard API instead, which needs no
credential: create the policy with POST https://www.policyengine.org/api/policies,
then read GET https://www.policyengine.org/api/economy/compare (query
country, policy, baseline, region, year) with the polling rules in
policyengine-api-access, "From a dashboard's precompute script": every 5
seconds until its status is ok, giving up after 30 minutes, waiting 60
seconds after a 429, and stopping at once on a failed comparison. Record a
failed or timed-out comparison in the result file's "warnings". The
Public API computes it server-side. Scope the
plan's metrics to what that comparison provides (budget, decile, poverty,
winners/losers).
Pass this constraint into the planner and backend-builder prompts verbatim.
If a required metric genuinely cannot come from the API, drop it and record
the omission in the result file's "warnings" rather than computing locally.
Result file contract (headless runs MUST write this, success or failure):
// /tmp/create-dashboard-result.json
{
"status": "pushed", // pushed | dry-run | failed
"repo_url": "https://github.com/PolicyEngine/ctc-restoration-dashboard",
"dashboard_name": "ctc-restoration-dashboard",
"warnings": [],
"error": null
}
With --dry-run, stop after Phase 1: no repo creation, no build, no push;
write the result file with "status": "dry-run" and the plan summary path in
"warnings". Deployment (/deploy-dashboard) stays a separate, human-run
step in both modes.
Constants
DASHBOARD_NAME = derived from description or user input
REPO_PATH = absolute path to the dashboard repository
PREFIX = /tmp/dashboard-{DASHBOARD_NAME}
PLUGIN_ROOT = ${CLAUDE_PLUGIN_ROOT} (plugin root dir Claude Code injects for this plugin)
YOUR ROLE: ORCHESTRATOR ONLY
CRITICAL — Context Window Protection:
- You are an orchestrator. You do NOT write code, build components, or implement features.
- ALL implementation work is delegated to agents via the Task tool.
- You only read files marked "Short" in the handoff table (max 20 lines each).
- When spawning agents, point them to files on disk — do NOT paste file contents into prompts.
You DO:
- Parse arguments
- Create repos and clone (Phase 0)
- Spawn agents (in parallel where possible)
- Run quality gates via Bash (build, test)
- Read SHORT summary files (≤20 lines)
- Present checkpoints to user via AskUserQuestion
- Commit and push (Phase 6)
You MUST NOT:
- Write any code yourself
- Read full implementation files (plan.yaml, component files, etc.)
- Fix any issues manually — delegate to agents
Context Window Protection
Disk File Handoff Table
| File | Writer | Reader | Size |
|---|---|---|---|
{REPO_PATH}/plan.yaml | planner agent | All subsequent agents | Full |
{PREFIX}-plan-summary.md | planner agent | Orchestrator | Short ≤20 |
{PREFIX}-design-report.md | design-validator | Orchestrator | Short ≤15 |
{PREFIX}-arch-report.md | arch-validator | Orchestrator | Short ≤15 |
{PREFIX}-plan-report.md | plan-validator | Orchestrator | Short ≤15 |
Phase 0: Parse Arguments & Initialize Repository
Step 0A: Parse Arguments & Clean Up
Parse $ARGUMENTS:
- DESCRIPTION: the natural-language dashboard description
- OPTIONS: --repo, --skip-init, --skip-validate
Clean up leftover files from previous runs:
rm -f /tmp/dashboard-*-plan-summary.md /tmp/dashboard-*-build-report.md /tmp/dashboard-*-design-report.md /tmp/dashboard-*-arch-report.md /tmp/dashboard-*-plan-report.md
Resolve PLUGIN_ROOT from the environment variable Claude Code injects for the running plugin:
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
Step 0B: Determine Repository Path
If --skip-init: Set REPO_PATH to the current working directory. Skip to Phase 1.
If --repo NAME_OR_PATH: Search for the repo:
- If it's an absolute path → use directly
- If it contains slashes → resolve relative to cwd
- Otherwise search: sibling of cwd →
~/Documents/PolicyEngine/{NAME}→~/PolicyEngine/{NAME}→~/{NAME} - Set
REPO_PATHto the found path. Skip to Phase 1.
Otherwise: Create a new repository:
If --auto-confirm: a bare description with no --repo is an error — STOP and write the result file with "status": "failed" (headless runs must name the repo). With --repo NAME that wasn't found locally: if gh repo view PolicyEngine/{NAME} 404s, run steps 2-3 below with DASHBOARD_NAME = NAME, clone to the sibling of cwd, and skip both AskUserQuestion gates.
- Derive a dashboard name from the description (kebab-case, 2-4 words), then ask user:
AskUserQuestion:
Question: "What should the GitHub repository be named?"
Options:
- "{auto-derived-name}" (Recommended)
- "Let me type a name"
Set DASHBOARD_NAME from the answer.
- Check GitHub auth and org membership:
gh api user --jq '.login'
If this fails, tell the user to run gh auth login and STOP.
gh api orgs/PolicyEngine/memberships/{username} --jq '.role'
If not admin or member, report and STOP.
- Create the repo:
gh repo create PolicyEngine/{DASHBOARD_NAME} --public --description "PolicyEngine {DASHBOARD_NAME} dashboard"
- Ask where to clone:
AskUserQuestion:
Question: "Where should I clone the repository?"
Options:
- "{sibling-of-cwd}/{DASHBOARD_NAME}" (Recommended)
- "~/Documents/PolicyEngine/{DASHBOARD_NAME}"
- "Let me specify a path"
- Clone and initial commit:
gh repo clone PolicyEngine/{DASHBOARD_NAME} "{CLONE_PATH}"
cd {CLONE_PATH} && git add -A && git commit --allow-empty -m "Initialize dashboard repository" && git push -u origin main
- Set
REPO_PATH = {CLONE_PATH}.
Set PREFIX = /tmp/dashboard-{DASHBOARD_NAME}.
Phase 1: Plan (Human Gate)
Step 1A: Spawn planner agent
Spawn a general-purpose agent ("dashboard-planner"):
subagent_type: "general-purpose"
"You are the dashboard-planner agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
DASHBOARD DESCRIPTION:
{DESCRIPTION}
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/dashboard-planner.md
ADDITIONAL REQUIREMENTS:
- Write plan.yaml to {REPO_PATH}/plan.yaml
- Also write a SHORT summary (≤20 lines) to {PREFIX}-plan-summary.md containing:
- Dashboard name and purpose
- Data pattern chosen and why
- Number and types of components
- Key API endpoints or data sources
- Any questions or concerns about the description"
Step 1B: Plan approval
Read {PREFIX}-plan-summary.md (SHORT file). Present the summary to the user:
AskUserQuestion:
Question: "Here's the dashboard plan. How should we proceed?"
Options:
- "Approve — start building" (Recommended)
- "Modify — I have feedback"
- "Reject — start over"
If --auto-confirm: auto-approve (skip the question). If --dry-run is also set, STOP here and write /tmp/create-dashboard-result.json with "status": "dry-run".
If "Approve": Continue to Phase 2.
If "Modify": Ask follow-up question for feedback. Then re-spawn planner agent with extra context:
"User feedback on your previous plan:
{USER_FEEDBACK}
Read the existing plan.yaml in {REPO_PATH} and update it based on this feedback.
Write updated plan.yaml and updated {PREFIX}-plan-summary.md."
Then repeat Step 1B (approval loop).
If "Reject": STOP.
Phase 2: Scaffold (Quality Gates)
Step 2A: Spawn scaffold agent
Spawn a general-purpose agent ("dashboard-scaffold"):
subagent_type: "general-purpose"
"You are the dashboard-scaffold agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/dashboard-scaffold.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Step 2B: Quality gates
After the agent completes, run quality gates:
cd {REPO_PATH} && bun run build 2>&1 | tail -50
cd {REPO_PATH} && bunx vitest run 2>&1 | tail -50
If both pass (exit code 0): Continue to Phase 3.
If either fails: Re-spawn scaffold agent with error context:
"Quality gate failed. Fix the issues:
Command: {FAILED_COMMAND}
Exit code: {EXIT_CODE}
Output:
{STDERR_AND_STDOUT}
Fix the issues in {REPO_PATH} and ensure both build and tests pass."
Max 2 retries. If still failing after retries:
AskUserQuestion:
Question: "Build/tests still failing after 2 fix attempts. What should we do?"
Options:
- "Continue anyway — I'll fix manually later"
- "Stop"
Phase 3: Backend + Frontend (PARALLEL)
Spawn both agents in a single message so they run concurrently:
Agent 1: backend-builder
subagent_type: "general-purpose"
run_in_background: true
"You are the backend-builder agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/backend-builder.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Agent 2: frontend-builder
subagent_type: "general-purpose"
run_in_background: true
"You are the frontend-builder agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/frontend-builder.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Wait for both agents to complete before proceeding.
Phase 4: Integrate
Spawn a general-purpose agent ("dashboard-integrator"):
subagent_type: "general-purpose"
"You are the dashboard-integrator agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/dashboard-integrator.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Phase 5: Validation (3 Parallel Validators + Fix Routing)
Skip this phase if --skip-validate flag is set.
Validation Loop (max 3 cycles)
ROUND = 1
MAX_ROUNDS = 3
PENDING_VALIDATORS = [design, architecture, plan] # all 3 initially (build/tests gated directly)
Step 5A: Run validators
Spawn all pending validators in a single message, each with run_in_background: true.
For each validator, use this prompt template:
subagent_type: "general-purpose"
run_in_background: true
"You are the {VALIDATOR_NAME} agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/{VALIDATOR_FILE}
The plan.yaml is at: {REPO_PATH}/plan.yaml
ADDITIONAL: Write your report to {PREFIX}-{REPORT_FILE} in addition to returning it."
Validator details (build and tests are gated directly by the orchestrator via bun run build + bunx vitest run — see Phase 3 and Error Handling — not by a subagent):
| Validator | Agent file | Report file |
|---|---|---|
| design | dashboard-design-validator.md | design-report.md |
| architecture | dashboard-architecture-validator.md | arch-report.md |
| plan | dashboard-plan-validator.md | plan-report.md |
Wait for all validators to complete.
Step 5B: Parse results
Read each validator's SHORT report. Look for PASS or FAIL.
If all pass: Exit validation loop, continue to Phase 6.
If any fail: Collect the failure details from each failed validator.
Step 5C: Route failures to builders
Use this routing table to determine which builder agent should fix each failure:
| Failed Validator | Failure contains | Route to builder |
|---|---|---|
| design | (any failure) | frontend-builder.md |
| architecture | "tailwind", "next.js", "package manager" | dashboard-scaffold.md |
| architecture | "ui-kit", or other | frontend-builder.md |
| plan | "api contract" | backend-builder.md |
| plan | "component", "chart" | frontend-builder.md |
| plan | "embedding", "loading", "error" | dashboard-integrator.md |
Group failures by target builder. For each builder that needs to fix issues, spawn it:
subagent_type: "general-purpose"
"You are the {BUILDER_NAME} agent. Fix the following validation failures:
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths. Prefix Bash with: cd {REPO_PATH} &&
Read the full instructions in:
{PLUGIN_ROOT}/agents/dashboard/{BUILDER_FILE}
VALIDATION FAILURES TO FIX:
{LIST_OF_FAILURES_FROM_REPORTS}
Fix each issue, ensuring the build and tests still pass.
Run: cd {REPO_PATH} && bun run build && bunx vitest run"
Step 5D: Re-validate
After fixes, set PENDING_VALIDATORS to only the validators that failed (don't re-run passed ones).
Increment ROUND. If ROUND <= MAX_ROUNDS, go back to Step 5A.
If ROUND > MAX_ROUNDS and failures remain:
AskUserQuestion:
Question: "Validation found {N} remaining issues after {MAX_ROUNDS} fix rounds. What should we do?"
Options:
- "Accept as-is — I'll fix manually"
- "Keep trying one more round"
- "Stop — don't commit"
If --auto-confirm: accept as-is (skip the question) and record the remaining issues in the result file's "warnings".
If "Accept": Continue to Phase 6. If "Keep trying": Increment MAX_ROUNDS by 1, go back to Step 5A. If "Stop": STOP.
Phase 6: Review & Commit (Human Gate)
Present a summary to the user:
## Dashboard Build Complete
**Repository**: {REPO_PATH}
**Phases completed**: plan → scaffold → backend + frontend → integrate → validate
### How to run locally
cd {REPO_PATH}
make dev # Start full dev stack
make dev-frontend # Frontend only
make test # Run tests
make build # Production build
### Next steps
After reviewing locally, commit and push. Then use /deploy-dashboard to deploy.
AskUserQuestion:
Question: "Ready to commit and push to GitHub?"
Options:
- "Commit and push" (Recommended)
- "Stop — I want to review locally first"
If --auto-confirm: commit and push without asking, then write /tmp/create-dashboard-result.json with "status": "pushed" and the repo URL.
If "Commit and push":
cd {REPO_PATH} && git add -A && git commit -m "Implement dashboard from plan" && git push origin HEAD
Report the result to the user with the repo URL.
If "Stop": Report that code is ready locally at {REPO_PATH} and the user can commit when ready.
Error Handling
| Category | Example | Action |
|---|---|---|
| GitHub auth failure | gh api user fails | Tell user to run gh auth login, STOP |
| Repo already exists | gh repo create says exists | Tell user to use --repo NAME, STOP |
| Build failure | bun run build fails | Re-run scaffold with error context (max 2 retries) |
| Test failure | bunx vitest run fails | Re-run scaffold with error context (max 2 retries) |
| Validation failure | Validator reports FAIL | Route to builder, re-validate (max 3 cycles) |
| Agent failure | Agent errors or doesn't complete | Report to user, suggest re-running that phase |
| User rejects plan | "Reject" at plan approval | STOP |
Escalation Path
- Agent encounters error → Attempt fix via re-spawning agent
- Fix fails after max retries → Report to user with AskUserQuestion
- User chooses to stop → STOP cleanly
- Never proceed past a human gate without approval
Execution Instructions
YOUR ROLE: You are an orchestrator ONLY. You must:
- Invoke agents using the Task tool with
subagent_type: "general-purpose" - Wait for agent completion (or use
run_in_background: truefor parallel) - Read ONLY short summary/report files (≤20 lines)
- Run quality gates via Bash
- Present checkpoints to user via AskUserQuestion
- Proceed to the next phase after approval
YOU MUST NOT:
- Write any code yourself
- Fix any issues manually
- Read full implementation files
- Skip human gates
IMPORTANT — Agent Working Directory: Every agent prompt MUST include:
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
This ensures agents work in the correct directory regardless of Claude Code's cwd.
Execution Flow (CONTINUOUS):
Execute all phases sequentially without stopping (unless a STOP condition is hit):
- Phase 0: Parse args, init repo (or use existing)
- Phase 1: Plan → HUMAN APPROVAL (approve/modify/reject)
- Phase 2: Scaffold → quality gates (build + test, max 2 retries)
- Phase 3: Backend + Frontend (PARALLEL — spawn both in one message)
- Phase 4: Integrate
- Phase 5: Validate (4 validators parallel, fix routing, max 3 cycles) — skip if
--skip-validate - Phase 6: Review → HUMAN APPROVAL → commit and push
- Phase 7: Overview update (silent, background)
Deploying dashboard: $ARGUMENTS
Deploy a completed PolicyEngine dashboard to production. Run this AFTER merging your feature branch into main.
Precondition: The user should be on the main branch with a clean working tree and the dashboard code merged.
Skills Used
- policyengine-tools — Vercel frontend deployment (all dashboards) and Modal backend deployment (only if
custom-modalpattern) - policyengine-cloud-costs — What may stay running after the deploy, and the declared exceptions to report (only if
custom-modalpattern)
Step 1: Verify Prerequisites
# Check we're on main
git branch --show-current
# Check for clean working tree
git status
# Verify build passes
bun install --frozen-lockfile && bun run build && bunx vitest run
If not on main: Tell the user to merge their feature branch first:
You're currently on branch
{branch}. Please merge intomainfirst:git checkout main git merge {branch} git pushThen run
/deploy-dashboardagain.
If build fails: Report the error and STOP. Do not deploy broken code.
Step 2: Read the Plan
cat plan.yaml
Extract:
dashboard.name— for Vercel project and Modal app namesdashboard.zone_path— for multi-zone host rewrite verificationdata_pattern— determines if Modal deploy is needed (custom-modalneeds it;precomputed,precomputed-csv, andpolicyengine-apido not)tech_stack.framework— should bereact-nextjs(env var prefix:NEXT_PUBLIC_*)embedding.register_in_apps_json— determines if apps.json update is neededembedding.slug— the URL slug for policyengine.org
2a. Pre-flight: check for existing host rewrites (informational)
If this dashboard has deployed before, the host (policyengine-app-v2/website/next.config.ts) may already have rewrites pointing at it. Check so we know whether Step 5 needs to add them or they're already in place.
# Clone or pull the host repo
if [ ! -d /tmp/policyengine-app-v2 ]; then
gh repo clone PolicyEngine/policyengine-app-v2 /tmp/policyengine-app-v2
fi
(cd /tmp/policyengine-app-v2 && git checkout main && git pull)
# Invoke the multi-zone validator in host-only mode to check all rewrite structure at once
# (beforeFiles placement, route + asset rewrite counts, destination format)
Spawn the multizone-validator agent with TARGET_PATH=. (current dashboard repo) and HOST_CONFIG_PATH=/tmp/policyengine-app-v2/website/next.config.ts. Record which host-check items PASS / FAIL — these feed into Step 5 below.
Do NOT stop if host rewrites are missing. For a brand-new dashboard, the host can't have rewrites yet because Vercel hasn't assigned a production URL. Step 5 handles adding them after the first deploy captures the URL.
Step 3: Deploy Backend (if custom-modal)
Only if data_pattern: custom-modal. For every other pattern, skip to Step 4.
See the policyengine-tools skill (Pattern C: Modal gateway + worker + polling) for the full Modal deployment reference.
3a. Authentication check (human gate)
modal token info
modal profile list
Present the output to the user. Verify:
- Active profile is
policyengine - Workspace is
policyengine
If authentication fails or shows wrong workspace: Stop and display instructions:
Modal authentication required. Your CLI is not configured for the
policyengineworkspace.Please run:
modal token new --profile policyengine modal profile activate policyengineIf you don't have access, ask a PolicyEngine workspace owner for an invite.
Do NOT proceed until modal token info shows Workspace: policyengine.
If authentication succeeds, use AskUserQuestion to confirm before proceeding:
question: "Modal is authenticated to the policyengine workspace. Proceed with deployment?"
header: "Modal auth"
options:
- label: "Proceed"
description: "Continue to environment selection and deploy"
- label: "Cancel"
description: "Stop deployment"
3b. Environment selection (human gate)
Use AskUserQuestion to select the Modal environment:
question: "Which Modal environment should this deploy to?"
header: "Environment"
options:
- label: "main (Recommended)"
description: "Production — policyengine--app-func.modal.run"
- label: "staging"
description: "Pre-production testing — policyengine-staging--app-func.modal.run"
- label: "testing"
description: "Development/CI — policyengine-testing--app-func.modal.run"
3c. Deploy
# Guard against env var override
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET
# Deploy to the selected environment
modal deploy modal_app.py --env SELECTED_ENV
3d. Verify endpoint
Construct the URL from the app name and function name in modal_app.py:
- Pattern:
https://policyengine--APP_NAME-FUNCTION_NAME.modal.run - With non-main environment:
https://policyengine-ENV--APP_NAME-FUNCTION_NAME.modal.run
# Health check (if endpoint exists)
curl -s -w "\n%{http_code}" https://policyengine--DASHBOARD_NAME-health.modal.run
# Test the calculation endpoint
curl -s -X POST https://policyengine--DASHBOARD_NAME-calculate.modal.run \
-H "Content-Type: application/json" \
-d '{"test": true}'
If deploy fails: Report error and STOP. See the policyengine-tools skill's Modal deployment guidance.
3e. Set API URL in Vercel
After successful Modal deploy, set the API URL as a Vercel environment variable.
vercel env add NEXT_PUBLIC_API_URL production
# Enter: https://policyengine--DASHBOARD_NAME-calculate.modal.run
Step 4: Deploy Frontend to Vercel
See the policyengine-tools skill ("Deployment gotchas") for the full Vercel deployment reference.
# Link to Vercel under PolicyEngine team (if not already linked)
vercel link --scope policy-engine
# Deploy to production
vercel --prod --yes --scope policy-engine
If a Modal backend was deployed in Step 3, force-rebuild to pick up the new env var:
vercel --prod --force --yes --scope policy-engine
Capture the production URL from the output.
Verify the deployment:
curl -s -o /dev/null -w "%{http_code}" https://VERCEL_PRODUCTION_URL/
IMPORTANT: Use the auto-assigned Vercel production URL, not a custom alias. Custom aliases may have deployment protection issues.
Step 5: Wire the zone into the host (policyengine-app-v2)
After the Vercel deploy in Step 4, we have a production URL. Now wire the zone into policyengine-app-v2 so it's reachable at policyengine.org/<zone_path>. This is a PR to PolicyEngine/policyengine-app-v2.
Two parts, both in the same PR:
- Host rewrites — required for every multi-zone dashboard. These make the zone reachable at
policyengine.org/<zone_path>. - apps.json entry — optional, only if
embedding.register_in_apps_json: true. This is research-listing metadata; it does NOT affect routing.
5a. Create the PR branch
# Clone app-v2 if not already available
if [ ! -d /tmp/policyengine-app-v2 ]; then
gh repo clone PolicyEngine/policyengine-app-v2 /tmp/policyengine-app-v2
fi
cd /tmp/policyengine-app-v2
git checkout main
git pull
git checkout -b wire-DASHBOARD_NAME-zone
5b. Add host rewrites to website/next.config.ts
Add these entries to rewrites().beforeFiles in website/next.config.ts (use VERCEL_PRODUCTION_URL captured from Step 4 — e.g. my-dashboard-policy-engine.vercel.app):
// Path-mounted zone: two route rewrites preserve the basePath in the destination
{ source: '<zone_path>', destination: 'https://<VERCEL_PRODUCTION_URL><zone_path>' },
{ source: '<zone_path>/:path*', destination: 'https://<VERCEL_PRODUCTION_URL><zone_path>/:path*' },
// Asset rewrite (required for zones using assetPrefix: '/_zones/<dashboard.name>')
{ source: '/_zones/<dashboard.name>/:path*', destination: 'https://<VERCEL_PRODUCTION_URL>/_zones/<dashboard.name>/:path*' },
Skip the asset rewrite only if the zone does not set assetPrefix. For root-served zones (no basePath), keep the route rewrites but map destinations to the zone root instead: <zone_path> → https://<VERCEL_PRODUCTION_URL> and <zone_path>/:path* → https://<VERCEL_PRODUCTION_URL>/:path*. See the policyengine-tools skill for the two valid patterns: path-mounted and root-served.
5c. Add apps.json entry (only if embedding.register_in_apps_json: true)
This is research-listing metadata. The dashboard is reachable at policyengine.org/<zone_path> from Step 5b alone; this entry adds it to the research/interactives listing.
Add to app/src/data/apps/apps.json:
{
"slug": "SLUG",
"title": "TITLE",
"description": "DESCRIPTION",
"path": "/COUNTRY/SLUG",
"tags": ["COUNTRY", "policy", "interactives"],
"countryId": "COUNTRY",
"displayWithResearch": true,
"image": "SLUG-cover.png",
"date": "CURRENT_DATE 12:00:00",
"authors": ["AUTHOR_SLUG"]
}
The path field is the multi-zone route on policyengine.org (the host rewrites from 5b make it reachable). Do not use "type": "iframe" — that's the legacy embedding model and does not apply to multi-zone deployments.
Use AskUserQuestion to gather required metadata:
question: "What is the author slug for the apps.json entry? (Check existing entries in apps.json for format, e.g., 'max-ghenis')"
header: "Author"
options: [] (free text — let the user type via "Other")
If displayWithResearch: true, also ask:
question: "Do you have a cover image for the apps.json listing?"
header: "Cover image"
options:
- label: "I'll provide one"
description: "You'll give me the image file or path"
- label: "Skip for now"
description: "Use a placeholder — you can add a cover image later"
5d. Commit and open the PR
git add website/next.config.ts app/src/data/apps/apps.json # second path only if 5c ran
git commit -m "Wire DASHBOARD_NAME zone"
git push -u origin wire-DASHBOARD_NAME-zone
gh pr create --repo PolicyEngine/policyengine-app-v2 \
--title "Wire DASHBOARD_NAME zone" \
--body "Adds host rewrites (and apps.json entry, if applicable) so DASHBOARD_NAME is reachable at policyengine.org/COUNTRY/SLUG.
Zone URL: VERCEL_PRODUCTION_URL
Public path: /COUNTRY/SLUG"
Step 6: Smoke Test
Direct-URL checks can run immediately. Public-path checks require the Step 5 PR to merge first.
Immediate (after Step 4):
- Direct zone URL: Visit
https://VERCEL_PRODUCTION_URL/<zone_path>, verify the dashboard loads - Hash sync: Test that URL parameters work (add
#income=50000etc.) and survive refresh - Country detection (if supported): Test with
#country=uk
After Step 5 PR merges:
- Public path via host: Verify the dashboard loads at
policyengine.org/COUNTRY/SLUG(served through host rewrites, not an iframe) - Assets load: Open DevTools → Network, refresh, confirm all
_next/static/*assets return 200. For static-export zones, verify/_zones/<dashboard.name>/*requests reach the zone. - Shared chrome (if using ui-kit): Verify Header/Footer render consistently with the rest of
policyengine.org
Step 7: Report
Present deployment summary to the user:
Dashboard deployed
- Zone URL: VERCEL_PRODUCTION_URL
- Public path (after host PR merges): policyengine.org/COUNTRY/SLUG
- Vercel project: DASHBOARD_NAME [If custom backend:]
- API endpoint: https://policyengine--DASHBOARD_NAME-calculate.modal.run
- Modal environment: SELECTED_ENV
- Running while idle: nothing, or each declared exception with its cost (from the README's "Running costs")
- Host-wiring PR: PR_URL (adds rewrites, and apps.json entry if applicable)
Verify
- Dashboard loads at the zone URL
- Calculations work (or stubs respond correctly)
- Hash parameters are preserved on refresh
- After host-wiring PR merges, dashboard loads at policyengine.org/COUNTRY/SLUG
- After host-wiring PR merges, assets (
_next/static/*) load with 200 status
Error Recovery
| Issue | Fix | Reference |
|---|---|---|
| Vercel deploy fails | Check vercel.json config, ensure project builds | policyengine-tools |
| Modal deploy fails | Check Python deps, Modal auth, function timeouts | policyengine-tools |
| Wrong Modal workspace | modal profile activate policyengine | policyengine-tools |
| 404 on Vercel URL | Wait 30s for propagation, check Vercel dashboard | policyengine-tools |
| API returns errors | Check Modal logs: modal app logs DASHBOARD_NAME | policyengine-tools |
| Hash sync broken | Check postMessage calls in embedding.ts | policyengine-tools |
Agents 8
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- What the dashboard needs to do
- Which PolicyEngine variables and API endpoints are relevant
- What components and charts are needed
- Which data pattern is appropriate and why
Take time to analyze thoroughly before producing the plan.
Dashboard Planner Agent
Produces a structured YAML implementation plan from a natural-language dashboard description.
Skills Used
- policyengine-tools - Architecture patterns, data patterns, embedding
- policyengine-design - Design tokens, visual identity, color palette
- policyengine-us - US tax/benefit variables and programs
- policyengine-uk - UK tax/benefit variables and programs
- policyengine-api - REST API endpoint catalog (v1 production, v2 alpha status)
- policyengine-api-access - What the dashboard API offers pages, and when a backend needs its own server credential
- policyengine-cloud-costs - Worker sizing, maximums and the cost plan (custom-modal only)
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-us(if US dashboard)Skill: policyengine-uk(if UK dashboard)Skill: policyengine-apiSkill: policyengine-api-accessSkill: policyengine-cloud-costs(if custom-modal pattern)
Input
You receive a natural-language description of the desired dashboard (typically 2-3 paragraphs). This description comes from a policy analyst or designer who knows what they want the dashboard to show but may not know the technical implementation details.
Output
A complete plan.yaml file written to the working directory, plus a human-readable summary presented to the user for approval.
Workflow
Step 1: Analyze the Description
Extract from the natural-language input:
- Purpose: What policy question does this dashboard answer?
- Audience: Who will use it? (public, researchers, legislators, internal)
- Country: US, UK, or both
- Data needs: What calculations or data are required?
- Interactions: What can users change or explore?
- Outputs: What visualizations, metrics, or tables should be shown?
Step 1b: Include the standard pages (policy-analysis dashboards)
Canonical exemplar: nj-ctc-increase (David's New Jersey dashboard).
When any structural question isn't answered here, answer it the way that
repo does.
Hard cap: 4 tabs. The NJ set is Policy overview / Household impact / Statewide impact / Congressional districts (districts for state reforms with district data; otherwise the 4th slot is free or unused). The standard CONTENT below maps into those tabs — validation and methodology are SECTIONS (on the policy or statewide tab, plus the methodology footer), not tabs. A 5th tab is a planning failure: ECPA shipped 7 tabs and was the complaint that created this rule.
Every explanation lives in exactly ONE place. Data caveats, validation notes, methodology — write each once, on the tab it belongs to, and link to it from anywhere else it's relevant ("see the data note on the Statewide tab"). Duplicated explainers made ECPA verbose and hard to scan; the takeaway→evidence→detail rhythm (below) plus single-sourcing is the fix.
Budgetary impact is never hidden. It is the FIRST metric in the key-results band on the landing tab AND appears on the statewide/impacts tab. If a reader can't find the cost in five seconds, the plan is wrong.
Every dashboard that analyzes a specific reform MUST plan this content, in addition to whatever the description asks for:
-
Policy explanation page — what the reform changes, explained provision-by-provision in plain language, with links to the primary sources (bill text, official pages, driving news coverage) and the parameter changes rendered readably (parameter → current law → reform value → effective dates). If a reform JSON was provided (
--reform-json), derive this table from it verbatim. -
Validation page — how the numbers were produced and checked: PolicyEngine's results next to external benchmarks/anchors when the brief provides them (fiscal notes, JCT/CBO/CRFB, prior PolicyEngine scores), the verification verdict if one exists, model + data versions, analysis year(s), and methodology notes (static analysis, dataset, single-year vs 10-year). Honest-caveats section for anything dropped or assumed. Include a data-calibration section: at build time, query the live calibration API (
https://calibration-diagnostics.vercel.app/calibration/dashboard/api/microcosm/target-diagnostics?source=<source>) and present the THREE RINGS from analyze-policy Phase 5.6 — not just the reform's own source:- Ring 1 — the primary variable's marginals (ssa, cms_*, irs_soi, jct, cbo, hhs_acf_tanf, ...).
- Ring 2 — the mechanism's other inputs: every variable entering the formula the reform changes (e.g. dividends, taxable interest, and pensions for a Social Security taxation reform — they set combined income).
- Ring 3 — the interacted quantity the reform directly reprices
when a target for it exists (e.g.
taxable_social_security_amount), shown as the headline of the section: its relative error bounds the score's data-side bias. If no ring-3 target exists, state "joint distribution untargeted" explicitly. Show: release id, targets checked per ring, share within tolerance, and any relevant target with |relative error| above 10% named explicitly, with a link to the calibration dashboard. This tells readers whether the underlying microdata is well-calibrated for THIS reform's mechanism, not just its topic.
-
Impacts page(s) — the charts and metric cards (as today). When the brief carries notable findings from the analysis's independent review (counterintuitive-but-correct results, e.g. "senior poverty essentially unchanged despite a senior tax cut"), render each as a callout next to the chart it explains — a headline plus the 2-4 sentence mechanism. These pre-empt the "is this a bug?" reaction and are usually the most interesting content on the page.
-
Household page — impact on example households. Include by default; omit ONLY when the description passes
--no-household. Two requirements:- Inputs mirror the reform's levers. The controls are the household characteristics that determine THIS reform's impact — derive them from the parameters the reform changes, not a generic income slider. CTC reform → number of children/dependents and their ages; pension-credit reform → head/spouse ages and pension income; Social Security taxation → benefit amount, filing status, and other income; state EITC → earnings, filing status, and children. An income slider alone is a planning failure unless income genuinely is the reform's only lever.
- Representative precomputed cases. 3-5 preset households shown as selectable cards with each case's reform impact. Preset labels and descriptions are QUANTITATIVE compositions, never characterizations: "Single filer, $25,000 benefit" and "Married couple, two children, $40,000 earnings" — not "average retiree", "typical family", "higher-income couple", or "modest income". The numbers ARE the label.
- Precomputed presets, live custom inputs. Presets ship as precomputed
JSON (
public/data/example_households.json) so their charts render instantly. CUSTOM household inputs go to the dashboard API's household calculation through@policyengine/api-client(policyengine-api): one calculation per user action, after a pause in typing, with every charted input varied byaxesin that same call. A Modal backend calculates them only when Step 4 givescustom-modal. Presets come from the same place as the custom inputs, so both use one model release. Onpolicyengine-api, the presets' precompute script calls the dashboard API's household calculation (policyengine-api-access, "From a dashboard's precompute script"). Oncustom-modal, it runs the backend's pinnedpolicyenginerelease in its own process, never the dashboard API. Plan a full precomputed grid ONLY when the description forbids live calculation; grid precompute burns build time and caps the input space. For theprecomputeddata pattern, the precompute script sweeps a grid over the lever inputs through the dashboard API's household calculation (one calculation per grid slice, the slice's input inaxes, at most 15 a minute; household-level, never a microsim), or with the pinned package when Step 4's model-version check allows it, so every control combination resolves to a precomputed point.
Standard chrome (every dashboard, no exceptions): the top of every page
is the real PolicyEngine site header — ui-kit PolicyEngineHeader with the
appropriate country — carrying the standard policyengine.org nav (Research,
Model, About, …), logo, and country selector. NEVER replace the site nav with
the dashboard's own page links. Below the header, the flagship dashboard
chrome (as on south-carolina-2026-tax-changes and tx-rebate-checks): a
primary-500 hero band holding the dashboard title, folder-style page tabs
(The reform / Validation / Impacts / Households), and content in a white
card on a gray-50 page — see frontend-builder.md "Site chrome rule" for the
exact classes. The plan's component list must include this chrome explicitly.
Presentation structure (every page): neutral does not mean flat — the data carries the interest, so lead with it and give the page rhythm:
- The landing page opens with a "Key results ({year})" band — 3-4 MetricCards (cost, share of households gaining, the headline poverty or subgroup change), mirroring the key-findings bullets that open every PE blog analysis.
- Never more than two consecutive paragraphs of prose. The third element must be a table, chart, metric band, or callout. Statutory schedules (thresholds x filing status x rate) are tables, not bullet lists — per the writing skill's "tables and data" rule.
- Every chart gets a one-sentence quantitative takeaway directly above it, computed from the data it plots ("14.4% of households see higher net income; 85.6% see no change"). The chart is the evidence for the sentence, never an unexplained figure.
- Notable findings render as designed callouts (left accent rule, eyebrow naming the question the reader is about to ask — "Why senior poverty barely moves" — then a quantified headline and the mechanism), placed adjacent to the chart they explain.
- Page shape: takeaway → evidence → detail. Fine print (methodology, dataset, caveats) closes the page in a bordered footer, never interrupts the middle.
Language (all user-facing copy): follow the policyengine-writing
skill everywhere — active voice, sentence case, quantitative statements.
No vague or characterizing adjectives: never "modest", "substantial",
"significant", "generous", "higher-income"/"wealthy" (state the threshold
or amount instead), "average"/"typical" as a label (state the number; a
sourced statistic like "the 2026 average retired-worker benefit is
$X/month" is fine in prose). If a sentence needs an adjective to land,
replace it with the number that would justify the adjective.
Time horizon: default to single-year impacts. Only plan 10-year series
when the description passes --horizon 10 — the API's budget-window compute
takes 15-25 minutes and most dashboards don't need it. When horizon is 1,
the validation page must state that costs are single-year (never extrapolate
a naive ×10).
Step 2: Research Existing Dashboards
Search the PolicyEngine GitHub organization for similar existing tools:
gh api 'orgs/PolicyEngine/repos?per_page=100&sort=updated' --jq '.[].name'
For any that look related, check their structure to learn from existing patterns. This helps identify:
- Reusable patterns from similar tools
- PolicyEngine variables already available
- API endpoints that match the needs
Step 3: Map to PolicyEngine Variables
Based on the description, identify:
- Which PolicyEngine variables are needed (e.g.,
income_tax,household_net_income,snap) - Which entity levels are involved (person, household, tax_unit, spm_unit)
- Whether household-level or economy-wide simulation is needed
- What reform parameters might be varied
Step 4: Determine Data Pattern
Choose from the data patterns defined in the policyengine-tools skill (loaded in the First step). The skill defines multiple patterns — select the one that best fits the dashboard's data needs based on the skill's "when to use" guidance.
Decision order. Check the four options in the skill's "Data and backend patterns", in order, for each feature the dashboard computes at run time. The dashboard takes the highest-numbered one any feature needs:
precomputed/precomputed-csv— the inputs are a small, fixed set known before deployingpolicyengine-api— the dashboard API's four operations give each result as they are: household calculations (every charted input inaxesin the same call), a parameter reform compared with current law across the economy, or country metadata (always prefer this for standard household tools). The page calls it through@policyengine/api-client, never api.policyengine.org (policyengine-api-access)- A new dashboard operation — the page needs a Public API route outside those four, used unchanged, that other dashboards would want too. Say so in the plan, ask for it as policyengine-api-access describes, and plan
precomputeduntil it ships custom-modal— ONLY for outputs specific to this dashboard that the Public API's economy result doesn't include and precomputing can't cover, or a dataset the Public API doesn't serve
Fix gaps where they are: a structural reform goes into the country package as a contributed reform switched on by a gov.contrib.… parameter, and the plan uses policyengine-api once the Public API's model includes it; a general gap in the Public API is a change to the Public API. A server the dashboard needs for something other than PolicyEngine results (an LLM call, sending email) doesn't change its data_pattern.
The Public API's model version: when it is older than the release the dashboard needs, run the check in the skill's "Data and backend patterns". If the policyengine package's model bundle is updated weekly, plan policyengine-api and say in the plan which model release it waits for. If it isn't, custom-modal is allowed, and a precomputed dashboard's script may run that release with the policyengine package; the plan says the check found no weekly updates and names the release the dashboard needs. It also says that once the check passes, a custom-modal dashboard moves to policyengine-api, and a precomputed one keeps its pattern while its script goes back to the dashboard API.
Not a reason for custom-modal: a feature coming close to a rate limit (keep it within the budget in policyengine-api-access, and precompute what can't fit).
Write the chosen pattern into data_pattern in plan.yaml using these identifiers:
| Skill pattern | data_pattern value |
|---|---|
| Pattern A: Precomputed JSON | precomputed |
| Pattern A: Precomputed CSV | precomputed-csv |
Pattern B: the dashboard API (@policyengine/api-client) | policyengine-api |
| Pattern C: Custom Modal backend (gateway + polling) | custom-modal |
If the chosen pattern is custom-modal, the plan must document why the simpler patterns are insufficient. The plan must also specify which endpoints need long-running computation (microsimulation) vs. fast computation (household), as this determines worker timeout and memory allocation.
For custom-modal, size each endpoint and fill cost_plan using the policyengine-cloud-costs skill:
- household endpoints start at
worker_cpu: 1.0andworker_memory: 2048(a US household calculation peaked at 1.4 GB); - population endpoints keep
worker_memory_measured: falseuntil a measured run sets the size; - every endpoint has a
worker_max_containers, andcost_plan.worst_houris the sum of maximum containers × hourly rate; - anything that runs while idle, or any worker of 4 or more cores or 16 GiB or more, is listed in
cost_plan.declared_exceptionswith its reason and cost, and an end date if it is temporary.
Step 5: Design Components
For each visualization or interaction:
- Specify chart type and which app-v2 pattern to follow
- Map data fields to chart axes
- Use design token color references (e.g.,
primary-500, not#319795) - Specify responsive behavior
Chart patterns must reference app-v2 components:
- Line/bar/area charts: Follow
ChartContainerpatterns from policyengine-app-v2 - Choropleth maps: Follow map patterns from policyengine-app-v2
- Metric cards: Follow existing card patterns from policyengine-app-v2
Standard impact charts are NEVER custom-built. The canonical PolicyEngine
chart components (from @policyengine/ui-kit, or the exact app-v2 pattern
when ui-kit lacks one) are REQUIRED for:
- Winners/losers by income decile (intra-decile gain/loss stacked bar)
- Average impact by income decile
- Poverty rate change (overall / child / senior)
- Budgetary impact
These must look identical to the same charts on policyengine.org — that
visual consistency is part of the brand. In plan.yaml, name the specific
component each standard chart uses (e.g.
component: ui-kit IntraDecileChart); a bespoke chart is acceptable only for a visualization genuinely specific to the reform, and the plan must say why.
Step 6: Define Test Criteria
For each component and endpoint, define what "working correctly" means:
- API response shape and value ranges
- Component render checks
- Known-input/known-output benchmark cases
- Design compliance checks
Step 7: Decide zone path
Dashboards mount as Next.js multi-zones behind policyengine.org. The zone path determines where the dashboard appears on the host site. Follow the conventions in the policyengine-tools skill → "Multizone integration":
/us/<kebab-name>— US-specific dashboard/uk/<kebab-name>— UK-specific dashboard/<kebab-name>— cross-country dashboard (rare)
The kebab-case portion should match dashboard.name. Write this to dashboard.zone_path in the plan. If unclear, flag it to the user in Step 9 rather than guessing.
Step 8: Write the Plan
Write plan.yaml to the working directory with this structure:
# Dashboard Implementation Plan
# Generated by dashboard-planner agent
dashboard:
name: "<kebab-case-name>"
title: "<Human Readable Title>"
description: "<One-paragraph description>"
country: us # us, uk, or both
audience: public # public, researchers, legislators, internal
# Multi-zone mount path on policyengine.org. REQUIRED — see the policyengine-tools
# skill's "Multizone integration" section for conventions:
# /us/<kebab-name>, /uk/<kebab-name>, or /<kebab-name> for cross-country
zone_path: "/us/<kebab-case-name>"
data_pattern: policyengine-api # precomputed | policyengine-api | custom-modal | precomputed-csv
# Pattern-specific configuration (include whichever section matches data_pattern)
api_integration: # for policyengine-api pattern
variables_requested:
- income_tax
- household_net_income
- snap
precomputed: # for precomputed / precomputed-csv patterns
source_script: "scripts/precompute.py"
output_files: ["public/data/results.json"]
custom_modal: # for custom-modal pattern
reason: "Needs policyengine-us 2.118.4, newer than the Public API's model; Step 4's model-version check found no weekly bundle updates. Moves to policyengine-api once the check passes."
policyengine_package: "policyengine[us]" # top-level extra (>=5.0.1) — pins matched country model + certified bundle; microsim endpoints use pe.us.managed_microsimulation()
architecture: gateway-polling # Always use this — mirrors API v2 simulation service
backend_files: # Three-file structure (avoids module-level import crash-loop)
image_setup: backend/_image_setup.py # Standalone snapshot function
worker_app: backend/app.py # Modal decorators (only `modal` at module level)
simulation: backend/simulation.py # Pure logic (policyengine at module level, snapshotted)
gateway: backend/modal_app.py # Lightweight FastAPI (no policyengine)
endpoints:
- name: household-impact
method: POST
long_running: false # < 60s — household-level simulation
worker_timeout: 600
worker_memory: 2048
worker_cpu: 1.0
worker_max_containers: 10
inputs:
- name: income
type: number
- name: filing_status
type: string
outputs:
- name: income_tax
type: number
policyengine_variables:
- income_tax
- child_tax_credit
- name: statewide-impact
method: POST
long_running: true # 2-5+ minutes — MUST use polling
worker_timeout: 3600
worker_memory: 32768 # placeholder until a measured run
worker_memory_measured: false
worker_cpu: 8.0
worker_max_containers: 4
inputs:
- name: reform
type: object
outputs:
- name: revenue_change
type: number
- name: winners
type: number
policyengine_variables:
- household_net_income
- state_income_tax
cost_plan: # copied into the README's "Running costs" section (policyengine-cloud-costs skill)
idle: nothing # every minimum is 0
worst_hour: "household-impact 10 × $0.063 + statewide-impact 4 × $0.63 ≈ $3.17/hour"
copies: "PR preview apps, stopped when the PR closes (.github/workflows/stop-preview-app.yml)"
secrets: "Modal secret DASHBOARD_NAME; nothing in the repo"
owner: "@github-handle"
review_by: "YYYY-MM-DD"
declared_exceptions: [] # e.g. {setting: "min_containers=2 on household-impact", rule: MO1, why: "launch day", until: "2026-11-27", cost: "≈ $3/day"}
tech_stack:
# Fixed - not configurable
framework: react-nextjs
ui: "@policyengine/ui-kit"
styling: tailwind-with-design-tokens
font: inter
testing: vitest
charts: recharts
maps: react-plotly # only if maps needed
components:
- type: input_form
id: household-inputs
fields:
- name: income
input_type: slider
label: "Employment income"
min: 0
max: 500000
default: 50000
step: 1000
- name: state
input_type: select
label: "State"
options: us_states
- name: filing_status
input_type: toggle
label: "Filing status"
options: [single, married]
- type: chart
id: tax-by-income
chart_type: line
component_ref: "app-v2:ChartContainer"
title: "Tax liability by income"
x:
variable: employment_income
label: "Employment income"
format: currency
y:
- variable: income_tax
label: "Income tax"
color: primary-500
- variable: total_benefits
label: "Benefits"
color: teal-300
- type: metric_card
id: effective-rate
title: "Effective tax rate"
value_variable: effective_tax_rate
format: percent
- type: chart
id: state-map
chart_type: choropleth
component_ref: "app-v2:ChoroplethMap"
geography: us-states
fill_variable: avg_tax_change
color_scale: diverging
embedding:
register_in_apps_json: true
display_with_research: true
slug: "<dashboard-name>"
tags: ["us", "policy", "interactives"]
tests:
api_tests:
- name: "basic_calculation"
description: "Verify tax calculation for standard household"
input:
income: 50000
state: "CA"
filing_status: "single"
expected:
income_tax:
min: 3000
max: 8000
- name: "zero_income"
description: "Verify zero income returns zero tax"
input:
income: 0
state: "CA"
filing_status: "single"
expected:
income_tax: 0
frontend_tests:
- name: "renders_without_errors"
description: "All components mount successfully"
- name: "charts_receive_data"
description: "Charts render with correct data shape"
- name: "input_validation"
description: "Form rejects invalid inputs"
design_compliance:
- name: "uses_design_tokens"
description: "No hardcoded colors - all from @policyengine/design-system"
- name: "inter_font"
description: "Inter font loaded and applied"
- name: "sentence_case"
description: "All headings use sentence case"
- name: "responsive"
description: "Layout adapts at 768px and 480px breakpoints"
embedding_tests:
- name: "country_detection"
description: "Reads country from #country= hash parameter"
- name: "hash_sync"
description: "Input changes update URL hash and postMessage to parent"
- name: "share_urls"
description: "Share URLs point to policyengine.org, not Vercel"
Step 9: Present the Plan
After writing plan.yaml, present a human-readable summary:
- Dashboard overview - name, purpose, audience
- Zone path - where it mounts on policyengine.org
- Data pattern - which pattern and why
- Components - list of inputs, charts, and metrics
- API endpoints - what data is needed
- Test plan - key acceptance criteria
- Questions or concerns - anything unclear from the description
The plan is then presented to the user for approval, modification, or rejection.
Plan Quality Checklist
Before presenting the plan:
-
dashboard.zone_pathis set and follows/us/<name>|/uk/<name>|/<name>convention - Every chart has a
component_refpointing to an app-v2 pattern - All colors reference design tokens, not hex values
- Data pattern choice is justified (simpler patterns preferred)
- If custom-modal, the
reasonexplains whypolicyengine-apiis insufficient - If custom-modal,
architecture: gateway-pollingis set - If custom-modal,
backend_filessection lists all 4 files (_image_setup, app, simulation, gateway) - If custom-modal, each endpoint has
long_running,worker_timeout,worker_memory,worker_cpuandworker_max_containers - If custom-modal,
cost_plananswers what runs while idle, the worst hour's cost, how each copy ends, where secrets live, and the owner and review date - Test criteria are specific and measurable
- Embedding configuration is complete
- Component IDs are unique kebab-case
- Variables map to real PolicyEngine variable names
- Responsive breakpoints are specified
Error Handling
- If the description is too vague to produce a plan, ask the user for clarification
- If no PolicyEngine variables match the described needs, flag this and suggest the custom-modal pattern
- If the description asks for something PolicyEngine cannot model (e.g., time-series data, historical tracking), note this limitation
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- The approved plan's requirements
- The correct project structure for the chosen data pattern
- What files need to be created and in what order
- How to ensure the scaffold passes linting and builds cleanly
Dashboard Scaffold Agent
Generates complete project structure from an approved plan.yaml into the current working directory. The repository must already exist (created via /init-dashboard).
Skills Used
- policyengine-tools - Mandatory framework and styling requirements (Next.js, Tailwind v4, ui-kit), scaffolding patterns, embedding boilerplate, Vercel configuration
- policyengine-design - Design tokens, CSS setup
- policyengine-standards - CI/CD, Git workflow
- policyengine-cloud-costs - Cost checks in CI and the README's running costs (custom backend only)
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-standardsSkill: policyengine-api-access(ifdata_pattern: policyengine-api, or a custom backend that calls the Public API)Skill: policyengine-cloud-costs(if custom backend)
CRITICAL: The policyengine-tools skill's "Required stack" defines the project structure, framework, and styling approach. Follow its specifications for project scaffolding. Where this document conflicts with the spec, THE SPEC WINS.
Input
- An approved
plan.yamlfile in the working directory - The plan has been reviewed and approved by the user
Output
- Project scaffold files generated in the current working directory
- All code on a feature branch (not main)
- Scaffold commit with all generated files, CI, and README
Workflow
Step 1: Read the Plan
cat plan.yaml
Extract key values:
dashboard.name- repo name and directory namedashboard.country- determines which PE packages to usedashboard.zone_path- REQUIRED. DrivesbasePathinnext.config.tsand host rewrites. If missing, STOP and ask the user — don't guessdata_pattern- determines backend structuretech_stack- confirms fixed stack choicescomponents- informs which dependencies to install
Verify data pattern choice: If the plan specifies custom-modal, confirm it includes a reason explaining why simpler patterns are insufficient. The preferred order is:
precomputed/precomputed-csv— if parameter space is finitepolicyengine-api— if the dashboard API suffices (prefer this for standard household tools): the page calls www.policyengine.org's dashboard API through@policyengine/api-client, never api.policyengine.org (policyengine-api-access skill)custom-modal— only if other microsimulation output or structural reforms are needed
If the plan uses custom-modal without a clear justification, flag this to the user before proceeding.
Step 2: Create Project Structure
The repository already exists (created by /init-dashboard) and the current working directory is the repo root. Generate files directly here.
For API v2 Alpha pattern:
DASHBOARD_NAME/
├── .github/
│ └── workflows/
│ └── ci.yml
├── .claude/
│ └── settings.json
├── app/
│ ├── layout.tsx # Root layout — Inter font + globals.css
│ ├── page.tsx # Main dashboard page
│ ├── globals.css # @import "tailwindcss" + @import ui-kit theme
│ ├── providers.tsx # React Query provider (client component)
│ └── icon.svg # PE logo favicon (basePath-safe via App Router)
├── components/
│ └── (from plan.yaml components — only custom ones not in ui-kit)
├── lib/
│ ├── api/
│ │ ├── client.ts # API v2 alpha stub client
│ │ ├── types.ts # Request/response types from plan
│ │ └── fixtures.ts # Mock data for stubs
│ ├── embedding.ts
│ └── hooks/
│ └── useCalculation.ts
├── __tests__/
│ └── page.test.tsx
├── next.config.ts
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── plan.yaml # The approved plan
├── CLAUDE.md
├── README.md
├── Makefile
├── vercel.json
└── .gitignore
For Custom Backend pattern (adds):
DASHBOARD_NAME/
├── ... (same structure as above, including Makefile and app/icon.svg)
├── backend/
│ ├── _image_setup.py # Standalone snapshot function (no package imports)
│ ├── app.py # Modal worker app + function decorators (only `modal` at module level)
│ ├── modal_app.py # Lightweight gateway (FastAPI, no PE deps)
│ ├── simulation.py # Pure business logic (policyengine imports at module level, snapshotted)
│ └── tests/
│ └── test_simulation.py
└── ...
Step 3: Generate Core Files
CLAUDE.md
Generate a CLAUDE.md following the pattern from existing applets (givecalc, ctc-calculator):
# DASHBOARD_NAME
[Description from plan]
## Architecture
- Next.js App Router with Tailwind CSS v4 and @policyengine/ui-kit theme
- @policyengine/ui-kit for standard UI components
- [Backend description based on data pattern]
## Development
```bash
bun install
make dev # Full dev stack (Modal + frontend, port 4000-4100)
make dev-frontend # Frontend only
Testing
make test
Build
make build
Cloud costs (custom backend only)
- Load the policyengine-cloud-costs skill before changing
backend/decorators, deploy commands or workflows. - Defaults:
min_containers=0,max_containersset, CPU and memory from a measured run. Anything else gets a# cost-ok: <rule> <reason>; until <date>comment and a row in the README's "Running costs".
Design standards
- Uses Tailwind CSS v4 with @policyengine/ui-kit/theme.css (single import for all tokens)
- @policyengine/ui-kit for all standard UI components
- Primary teal:
bg-teal-500/text-teal-500 - Semantic colors:
bg-primary,text-foreground,text-muted-foreground - Font: Inter (via next/font/google)
- Sentence case for all headings
- Charts use
fill="var(--chart-1)"for series colors
#### package.json
Generate from the fixed tech stack, including:
- `next`, `react`, `react-dom` (^19)
- `tailwindcss` (^4)
- `@tailwindcss/postcss` (dev)
- `postcss` (dev)
- `@policyengine/ui-kit`
- `recharts` (if custom charts beyond ui-kit)
- `react-plotly.js` (if maps in plan)
- `@tanstack/react-query`
- `axios`
- Dev: `vitest`, `@vitejs/plugin-react`, `@testing-library/react`, `@testing-library/jest-dom`, `typescript`, `@types/react`, `@types/react-dom`, `@types/node`, `jsdom`
#### next.config.mjs (static export with multi-zone)
Dashboards use `output: 'export'` AND mount as multi-zones behind policyengine.org. This requires the three-piece static-export pattern (phase-gated `assetPrefix` + `vercel.json` self-rewrite + host asset rewrite) — see the `policyengine-tools` skill → "Multizone integration" (static-export zones) for full rationale.
The scaffolded `next.config.mjs` MUST:
1. Export a **function** that takes `phase` (so we can detect `next dev`)
2. Set `basePath` to `dashboard.zone_path` from the plan
3. Set `assetPrefix` conditionally: `undefined` in dev, `/_zones/<dashboard.name>` in builds
Replace `DASHBOARD_NAME` with `dashboard.name` and `ZONE_PATH` with `dashboard.zone_path`:
```js
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js';
/** @type {import('next').NextConfig} */
export default function nextConfig(phase) {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
return {
output: 'export',
basePath: 'ZONE_PATH',
// Phase-gated: undefined in `next dev` (local paths work),
// /_zones/DASHBOARD_NAME in builds (assets don't collide with host).
assetPrefix: isDev ? undefined : '/_zones/DASHBOARD_NAME',
trailingSlash: true,
images: { unoptimized: true },
};
}
Do NOT scaffold a plain object-form next.config.ts — the phase gate requires the function form.
vercel.json (static deploy + multi-zone self-rewrites)
Static-export dashboards deploy as plain static sites ("framework": null): Vercel silently IGNORES vercel.json rewrites under the Next.js framework preset, and next.config rewrites can't be used with output: 'export' — with the preset, the deployed zone 404s on every basePath route and asset. The rewrites map the basePath routes and prefixed assets onto the export root (static export writes pages at the out/ root; internal links carry the basePath). The catch-all must be a regex param :path(.*) — :path* doesn't match the trailing-slash URLs the app generates.
Replace DASHBOARD_NAME with dashboard.name and ZONE_PATH with dashboard.zone_path:
{
"framework": null,
"buildCommand": "bun run build",
"outputDirectory": "out",
"trailingSlash": true,
"rewrites": [
{ "source": "/_zones/DASHBOARD_NAME/_next/:path*", "destination": "/_next/:path*" },
{ "source": "ZONE_PATH", "destination": "/" },
{ "source": "ZONE_PATH/:path(.*)", "destination": "/:path" }
]
}
The host-side asset rewrite (policyengine-app-v2/website/next.config.ts) is added separately during deploy — see /deploy-dashboard.
postcss.config.mjs
Required for Tailwind v4. Without this file, @import "tailwindcss" in globals.css is never processed and no utility classes are generated.
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
app/globals.css
Generate the Tailwind v4 configuration with the ui-kit theme import:
@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, typography, and chart tokens as CSS variables that Tailwind 4 picks up automatically.
app/layout.tsx
The root layout imports globals.css and sets up Inter font:
import './globals.css'
import { Inter } from 'next/font/google'
import type { Metadata } from 'next'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'TITLE - PolicyEngine',
description: 'DESCRIPTION from plan',
// Favicon is picked up automatically from app/icon.svg (basePath-safe).
// Do NOT set icons: { icon: '/favicon.svg' } — that path is not basePath-prefixed
// and resolves to the host's favicon when the zone is served behind policyengine.org.
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
app/providers.tsx
Client component wrapping React Query:
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
API Client Stubs
Generate lib/api/types.ts with TypeScript types matching the plan's endpoint inputs/outputs. Declare anything passed to @policyengine/api-client as a type alias, not an interface: the client takes Record<string, unknown>, which an interface is not assignable to.
Generate lib/api/fixtures.ts with mock data from plan.yaml's stub_fixtures.
For policyengine-api or API v2 alpha patterns, generate lib/api/client.ts with synchronous fetch stubs that return fixture data. For policyengine-api, add @policyengine/api-client to the dependencies; the backend-builder replaces the stubs with calls through it (policyengine-api-access skill). Never generate a stub or constant that names api.policyengine.org.
For custom-modal pattern, generate lib/api/client.ts with the gateway polling pattern:
// client.ts - Gateway + Polling client
const API_URL = process.env.NEXT_PUBLIC_API_URL
|| 'https://policyengine--DASHBOARD_NAME-fastapi-app.modal.run';
interface JobResponse { job_id: string }
export interface StatusResponse {
status: 'computing' | 'ok' | 'error';
result?: unknown;
message?: string;
}
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: JobResponse = await res.json();
return data.job_id;
}
export async function pollStatus(jobId: string): Promise<StatusResponse> {
const res = await fetch(`${API_URL}/status/${jobId}`);
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
return res.json();
}
Generate lib/hooks/useCalculation.ts with the async polling hook:
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { submitJob, pollStatus } from '../api/client';
import type { StatusResponse } from '../api/client';
export function useAsyncCalculation<T>(
queryKey: unknown[],
endpoint: string,
params: unknown,
options?: { enabled?: boolean },
) {
const [jobId, setJobId] = useState<string | null>(null);
useEffect(() => { setJobId(null); }, [JSON.stringify(params)]);
const submit = useQuery({
queryKey: [...queryKey, 'submit'],
queryFn: async () => {
const id = await submitJob(endpoint, params);
setJobId(id);
return id;
},
enabled: options?.enabled ?? true,
});
const poll = useQuery<StatusResponse>({
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 as T) : undefined,
error: poll.data?.message || submit.error?.message,
};
}
.claude/settings.json
Skip this file if it already exists — /init-dashboard creates it with the correct plugin configuration.
If it does not exist, create it:
{
"plugins": {
"marketplaces": ["PolicyEngine/policyengine-claude"],
"auto_install": ["dashboard-builder@policyengine-claude"]
}
}
CI Workflow
Generate .github/workflows/ci.yml:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx vitest run
- run: bun run build
For the custom backend pattern, add this step after checkout (policyengine-cloud-costs skill). Replace CHECKER_COMMIT_SHA with the output of gh api repos/PolicyEngine/policyengine-skills/commits/main --jq .sha:
- name: Check deploy settings for cost
env:
# A policyengine-skills commit; move it forward on purpose (policyengine-cloud-costs skill, section 4)
CHECKER_COMMIT: CHECKER_COMMIT_SHA
run: |
curl -fsSL "https://raw.githubusercontent.com/PolicyEngine/policyengine-skills/${CHECKER_COMMIT}/skills/policyengine-cloud-costs/scripts/check_deploy_config.py" -o /tmp/check_deploy_config.py
python3 /tmp/check_deploy_config.py .
Also for the custom backend pattern:
- add the "Running costs" section from the policyengine-cloud-costs skill's
templates/running-costs.mdtoREADME.md, filled from the plan'scost_plan; - if the plan uses PR preview apps, add
templates/stop-preview-app.ymlas.github/workflows/stop-preview-app.yml; - make sure
.gitignorelists.env.
Makefile
Generate a Makefile that provides standard development targets. The Makefile content depends on the data_pattern from plan.yaml.
IMPORTANT: Makefile recipes must use literal tab characters for indentation, not spaces.
For all patterns: The dev and dev-frontend targets must try port 4000 first, then increment by 1 up to 4100, erroring out if no port in that range is available. Use this helper script embedded in the Makefile — copy it exactly:
# Port selection helper — finds the first available port in 4000-4100
define find_port
$$(python3 -c '\
import socket, sys;\
for p in range(4000, 4101):\
try:\
s = socket.socket(); s.bind(("", p)); s.close(); print(p); sys.exit(0)\
except OSError:\
continue\
print("ERROR: no free port in 4000-4100", file=sys.stderr); sys.exit(1)\
')
endef
For precomputed, policyengine-api, or precomputed-csv patterns:
.PHONY: dev dev-frontend
.PHONY: build test lint clean
# Port selection helper — finds the first available port in 4000-4100
define find_port
$$(python3 -c '\
import socket, sys;\
for p in range(4000, 4101):\
try:\
s = socket.socket(); s.bind(("", p)); s.close(); print(p); sys.exit(0)\
except OSError:\
continue\
print("ERROR: no free port in 4000-4100", file=sys.stderr); sys.exit(1)\
')
endef
# Start development server
dev: dev-frontend
# Frontend only (no backend for this data pattern)
dev-frontend:
@PORT=$(find_port); \
echo "Frontend: http://localhost:$$PORT"; \
PORT=$$PORT bun run dev
build:
bun run build
test:
bunx vitest run
lint:
bun run lint
clean:
rm -rf .next node_modules
For custom-modal pattern:
Replace DASHBOARD_NAME below with the actual dashboard.name value from plan.yaml.
The custom-modal pattern uses a gateway + worker architecture with frontend polling. The worker must be deployed first (it contains the heavy policyengine code), then the gateway is started in dev mode.
.PHONY: dev dev-frontend dev-backend deploy-worker
.PHONY: build test test-backend lint clean
# Port selection helper — finds the first available port in 4000-4100
define find_port
$$(python3 -c '\
import socket, sys;\
for p in range(4000, 4101):\
try:\
s = socket.socket(); s.bind(("", p)); s.close(); print(p); sys.exit(0)\
except OSError:\
continue\
print("ERROR: no free port in 4000-4100", file=sys.stderr); sys.exit(1)\
')
endef
# Deploy worker functions, then start gateway + frontend
dev:
@echo "Deploying worker functions..."
@unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET && modal deploy backend/app.py
@echo "Starting gateway (ephemeral)..."
@modal serve backend/modal_app.py & MODAL_PID=$$!; \
sleep 5; \
MODAL_URL="https://policyengine--DASHBOARD_NAME-fastapi-app-dev.modal.run"; \
PORT=$(find_port); \
echo "Gateway: $$MODAL_URL"; \
echo "Frontend: http://localhost:$$PORT"; \
NEXT_PUBLIC_API_URL=$$MODAL_URL PORT=$$PORT bun run dev; \
kill $$MODAL_PID 2>/dev/null
# Frontend only (uses production API or NEXT_PUBLIC_API_URL if set)
dev-frontend:
@PORT=$(find_port); \
echo "Frontend: http://localhost:$$PORT"; \
PORT=$$PORT bun run dev
# Backend only (gateway in dev mode — worker must already be deployed)
dev-backend:
modal serve backend/modal_app.py
# Deploy worker functions to Modal (required before gateway can spawn jobs)
deploy-worker:
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET && modal deploy backend/app.py
build:
bun run build
test:
bunx vitest run
test-backend:
cd backend && uv run pytest
lint:
bun run lint
clean:
rm -rf .next node_modules
Favicon
Copy the PolicyEngine logo into app/icon.svg — the App Router convention. Next.js emits it at <basePath>/icon.svg, so it resolves correctly whether the zone is hit directly or served behind the host.
cp node_modules/@policyengine/ui-kit/src/assets/logos/policyengine/teal-square.svg app/icon.svg
Do NOT use public/favicon.svg + icons: { icon: '/favicon.svg' } in metadata — that path is written verbatim into the HTML and is not basePath-prefixed. Behind the host, the browser will request it from policyengine.org/favicon.svg and get the host's favicon instead of the zone's.
Embedding Boilerplate
Generate country detection, hash sync, and share URL helpers in lib/embedding.ts:
export function getCountryFromHash(): string {
const params = new URLSearchParams(window.location.hash.slice(1));
return params.get("country") || "us";
}
export function isEmbedded(): boolean {
return window.self !== window.top;
}
export function updateHash(params: Record<string, string>, countryId: string) {
const p = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => p.set(k, v));
if (countryId !== "us" && !isEmbedded()) p.set("country", countryId);
const hash = `#${p.toString()}`;
window.history.replaceState(null, "", hash);
if (isEmbedded()) {
window.parent.postMessage({ type: "hashchange", hash }, "*");
}
}
export function getShareUrl(countryId: string, slug: string): string {
const hash = window.location.hash;
if (isEmbedded()) {
return `https://policyengine.org/${countryId}/${slug}${hash}`;
}
return window.location.href;
}
Initial Test File
Generate __tests__/page.test.tsx with a basic render test.
Step 4: Create Skeleton Components
For each component in plan.yaml, first check if @policyengine/ui-kit already provides it. Only create skeleton files for components NOT available in ui-kit.
Each custom skeleton should:
- Use Tailwind utility classes with semantic and brand tokens for styling
- Have the correct TypeScript props interface
- Include a
// TODO: Implementcomment where real logic goes - Export the component
Step 5: Commit and Create Feature Branch
The repository and remote already exist (created by /init-dashboard). Commit the scaffold and create a feature branch:
git add -A
git commit -m "Initial scaffold from dashboard plan"
git checkout -b feature/initial-implementation
git push -u origin feature/initial-implementation
Step 6: Verify
bun install
bun run build # Should succeed with skeleton components
bunx vitest run # Initial test should pass
If either fails, fix before proceeding.
Quality Checklist
-
plan.yamlis included in the repo -
dashboard.zone_pathfrom plan is written asbasePathinnext.config.mjs -
next.config.mjsexports a FUNCTION takingphase(not a plain object) -
assetPrefixis phase-gated:undefinedin dev,/_zones/<dashboard.name>in builds -
vercel.jsonhasframework: null,outputDirectory: out,trailingSlash: true, the asset self-rewrite (/_zones/<dashboard.name>/_next/:path*→/_next/:path*), and the basePath route rewrites (<zone_path>→/,<zone_path>/:path(.*)→/:path) -
CLAUDE.mdfollows existing applet patterns -
package.jsonhas all required dependencies (Next.js, Tailwind v4, ui-kit) -
globals.csshas@import "tailwindcss"+@import "@policyengine/ui-kit/theme.css" -
postcss.config.mjsexists with@tailwindcss/postcssplugin - No
tailwind.config.ts(Tailwind v4) - No CDN
<link>for design-system tokens (ui-kit theme provides everything) - Inter font is loaded via
next/font/google - Embedding boilerplate is in place
- API client stubs match the plan's endpoint signatures
- CI workflow is configured
-
.claude/settings.jsonauto-installs the dashboard-builder plugin -
vercel.jsonis configured for frontend deployment - Feature branch is created and pushed
-
app/icon.svgexists (PE logo, basePath-safe via App Router) -
layout.tsxmetadata does NOT seticons: { icon: '/favicon.svg' }(that path isn't basePath-prefixed and would collide with the host behind policyengine.org) - Header uses
logos.whiteWordmarkorlogos.tealWordmark(not text-only) -
Makefilehas correct targets for the data pattern -
make devuses port range 4000-4100 (not random, not hardcoded 3000) - If custom-modal:
make devdeploys worker, then starts gateway + frontend - If custom-modal: backend has 3-file structure (
_image_setup.py,app.py,simulation.py) - If custom-modal:
_image_setup.pyhas no package imports at module level - If custom-modal:
app.pyonly importsmodalat module level - If custom-modal:
simulation.pyhas policyengine imports at module level (snapshotted) - If custom-modal: image uses
.run_function(snapshot_models)for fast cold starts - If custom-modal: worker image
pip_installincludes"pydantic"(simulation.py uses it at module level) - If custom-modal: worker image uses
.add_local_file()forsimulation.py(not auto-mounted since it's imported inside function bodies) - If custom-modal: gateway is lightweight (no policyengine in its Modal image)
- If custom-modal: gateway image explicitly includes
pydantic - If custom-modal: workers use the plan's
timeout,cpu,memoryandmax_containers, keepmin_containers=0, and population workers keep their memory# cost-ok: RS1 TODOdeclaration until a measured run replaces it - If custom-modal: CI runs the cost check, and
README.mdhas the "Running costs" section filled from the plan'scost_plan - If custom-modal: frontend uses polling (
refetchInterval), not synchronous await - If custom-modal:
/statusendpoint returns{status, result, message} - Build passes on the scaffold
- Initial test passes
DO NOT
- Commit to main after the initial scaffold commit
- Deploy to Vercel or Modal (that's
/deploy-dashboard) - Implement real logic (that's Phase 3 agents)
- Skip the feature branch
- Create
tailwind.config.ts(Tailwind v4 uses@themein CSS) - Omit
postcss.config.mjs— it IS required for Tailwind v4 (the@tailwindcss/postcssplugin processes@import "tailwindcss") - Rebuild components that exist in
@policyengine/ui-kit - Load tokens via CDN
<link>(use@import "@policyengine/ui-kit/theme.css"instead) - Use
getCssVar()— it no longer exists. SVG acceptsvar()directly.
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- Which data pattern the plan specifies (precomputed, policyengine-api, or custom-modal)
- What endpoint interfaces or data files are needed
- How to type the API contract for the frontend
- What test coverage is appropriate
Backend Builder Agent
Builds the data layer for a dashboard based on the approved plan.yaml.
Skills Used
- policyengine-tools - Data patterns, API integration, and Modal backend architecture
- policyengine-api-access - The dashboard API for pages, and server credentials for a backend that calls the Public API (policyengine-api and custom-modal)
- policyengine-us or policyengine-uk - PolicyEngine variables
- policyengine - Python simulation mechanics, reform dictionaries, and parameter path syntax (custom-modal only)
- policyengine-cloud-costs - Modal defaults (scale to zero,
max_containers, measured sizes) and how to declare an exception (custom-modal only)
Backend Selection Priority
Always prefer simpler patterns. The plan's data_pattern comes from the four options in the policyengine-tools skill's "Data and backend patterns", checked in this order. Check it against them before building a custom backend:
- Are the inputs a small, fixed set known before deploying? → Use Pattern A/D (
precomputed/precomputed-csv) - Can the dashboard API give each result as it is? It calculates households (every charted input in
axesin the same call), stores a parameter reform and compares it with current law across the economy, and reads country metadata. → Use Pattern B (policyengine-api) - Does it need a Public API route outside those four, used unchanged? → Ask for a new dashboard operation (policyengine-api-access), and precompute until it ships
- Otherwise: outputs specific to this dashboard that the Public API's economy result doesn't include and precomputing can't cover, or a dataset the Public API doesn't serve. → Use Pattern C (
custom-modal) with gateway + polling
A structural reform goes into the country package as a contributed reform switched on by a gov.contrib.… parameter, and then uses Pattern B; it is not a reason for Pattern C.
The Public API's model version is a reason for custom-modal only while the policyengine package's model bundle isn't updated weekly; the plan says what the check in "Data and backend patterns" found and which release the dashboard needs. A custom-modal dashboard's presets come from its backend's pinned release, not the dashboard API, so the page shows one model release.
Not a reason for custom-modal: a feature coming close to a rate limit; keep the page within the budget in policyengine-api-access, and precompute what can't fit.
Pattern C is the most complex and should be the last resort. If the plan specifies custom-modal, the plan MUST include a reason explaining why Pattern B is insufficient.
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-api-access(if policyengine-api pattern, or a custom-modal backend that calls the Public API)Skill: policyengine-us(if US dashboard)Skill: policyengine-uk(if UK dashboard)Skill: policyengine(if custom-modal pattern — required for simulation mechanics and correct reform parameter paths)Skill: policyengine-cloud-costs(if custom-modal pattern)
Input
- A scaffolded repository with
plan.yamland skeleton API client - The plan specifies a
data_patternchosen from the patterns defined in thepolicyengine-toolsskill (loaded in the First step)
Output
- Typed API client or data loader matching the plan's data pattern
- React Query hooks for data fetching
- Tests appropriate to the pattern
- TypeScript types matching the API contract
Pattern A: Precomputed JSON (precomputed)
When data_pattern: precomputed, the dashboard ships static JSON files with pre-run results. No backend, no API calls at runtime.
Step 1: Create Data Files
Generate JSON files in public/data/ based on the plan's data requirements:
public/data/
results.json # or split by dimension:
results_by_state.json
results_by_year.json
Data should be structured for direct consumption by the frontend — no post-processing needed.
Step 2: Build the Data Loader
Create lib/api/client.ts:
// client.ts
export interface DashboardData {
// Types matching the JSON structure from plan.yaml
}
export async function loadData(): Promise<DashboardData> {
const res = await fetch('/data/results.json');
if (!res.ok) throw new Error(`Failed to load data: ${res.status}`);
return res.json();
}
Step 3: Build React Query Hooks
Create lib/hooks/useData.ts:
import { useQuery } from '@tanstack/react-query';
import { loadData } from '../api/client';
export function useDashboardData() {
return useQuery({
queryKey: ['dashboard-data'],
queryFn: loadData,
staleTime: Infinity, // Static data never goes stale
});
}
Step 4: Write Tests
Create lib/api/__tests__/client.test.ts:
- Test that JSON files parse correctly
- Test that data matches expected TypeScript types
- Test that all expected keys/dimensions are present
Pattern B: the dashboard API (policyengine-api)
When data_pattern: policyengine-api, the page calls www.policyengine.org's dashboard API through @policyengine/api-client. The website's server adds PolicyEngine's credential and forwards each operation to the Public API, so no custom backend is needed. The page never calls api.policyengine.org: the Public API is becoming callable only by PolicyEngine's servers. The policyengine-api-access skill has the four operations, polling, the origins the dashboard API accepts, and its errors.
Step 1: Install the client and define types
bun add @policyengine/api-client
Read the plan's endpoints and write the household and result types. Use type aliases: the client takes Record<string, unknown>, which an interface is not assignable to.
// types.ts
/**
* A household in PolicyEngine's situation format: values keyed by year, and null for
* each output to calculate. The group entities differ by country (US: tax_units,
* spm_units, families, marital_units, households; UK: benunits, households); read them
* from the country's metadata. `axes` varies one input over a range (integrator Step 6).
*/
export type Household = {
people: Record<string, Record<string, Record<string, number | boolean | string | null>>>;
axes?: Array<Array<{ name: string; period: string; min: number; max: number; count: number }>>;
[groupEntities: string]: unknown;
};
/** A calculated value: one value, or a list over the points when the household has axes */
export type CalculatedValue = number | boolean | string | null | Array<number | boolean | string | null>;
/** The calculated household: the request's shape, with the requested variables filled in */
export type CalculatedHousehold = Record<string, unknown>;
/**
* One variable from a calculated household, for example
* readVariable(result, 'households', 'household', 'household_net_income', '2026').
* Null when the household did not ask for it.
*/
export function readVariable(
result: CalculatedHousehold,
entityGroup: string,
entity: string,
variable: string,
year: string,
): CalculatedValue {
const entities = result[entityGroup] as Record<string, Record<string, unknown>> | undefined;
const values = entities?.[entity]?.[variable] as Record<string, CalculatedValue> | undefined;
return values?.[year] ?? null;
}
// Add dashboard-specific types for the variables in the plan
Step 2: Build the API client
Create lib/api/client.ts:
// client.ts
import { createPolicyEngineApiClient } from '@policyengine/api-client';
import type { CalculatedHousehold, Household } from './types';
// The default base URL, https://www.policyengine.org, works from the dashboard's
// www.policyengine.org path, its team previews (*-policy-engine.vercel.app), localhost, and
// its own *.vercel.app host once website/src/data/appZoneRoutes.ts names it.
const policyengine = createPolicyEngineApiClient();
export function calculate(
countryId: 'us' | 'uk',
household: Household,
signal?: AbortSignal,
): Promise<CalculatedHousehold> {
return policyengine.calculateHousehold<CalculatedHousehold>(
{ country: countryId, household },
{ signal },
);
}
For a reform, pass its parameter changes as policy to calculateHousehold. For an economy-wide comparison, store the reform with createPolicy and poll with compareEconomy (current law's policy ID is 2 for us, 1 for uk).
Step 3: Build React Query Hooks
Create lib/hooks/useCalculation.ts:
import { useMutation } from '@tanstack/react-query';
import { calculate } from '../api/client';
import type { Household } from '../api/types';
export function useCalculation(countryId: 'us' | 'uk') {
return useMutation({
mutationFn: (household: Household) => calculate(countryId, household),
});
}
Step 4: Write Tests
Create lib/api/__tests__/client.test.ts. client.ts builds its client when it loads, so mock the package (vi.mock('@policyengine/api-client', …)) before importing it; no test calls the network:
- Test that request bodies are correctly structured
- Test that
PolicyEngineApiErrorfrom a refused request reaches the UI's error state - Test type conformance of expected response shapes
Pattern C: Custom API on Modal (custom-modal) — Gateway + Polling
When data_pattern: custom-modal, build a two-layer architecture on Modal: a lightweight gateway that manages job submission/polling, and worker functions that run the heavy policyengine computations. This mirrors the pattern used by PolicyEngine API v2's simulation service.
Why not synchronous HTTP? Modal's dev gateway (modal serve) and production gateway have a ~150s timeout. US statewide microsimulations take 2-5+ minutes, causing HTTP 303 redirects that browser fetch() cannot follow for POST requests. The gateway + polling architecture avoids this entirely.
Backend structure: The backend uses a three-file structure to avoid 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 | top-level policyengine (population — managed path) + policyengine_us/_uk Simulation (household); captured in image snapshot |
backend/modal_app.py | Lightweight gateway (FastAPI) | modal, fastapi, pydantic |
Step 1: Look Up the Latest policyengine Version
Before writing any code, look up the latest version of the top-level policyengine package from PyPI. Do NOT guess or use a version from memory — it releases frequently and stale versions will have bugs.
pip index versions policyengine 2>/dev/null | head -1
Use the version number returned (must be >=5.0.1) in the pip_install() call below. Install policyengine[us] (or policyengine[uk]) rather than a bare country package: the extra pins an exactly-matched policyengine-us/policyengine-uk + policyengine-core and carries the certified data-bundle manifest, so population results are reproducible and their provenance is known.
Step 2: Create Image Setup
Generate backend/_image_setup.py. This is a standalone function with no package imports at module level — it runs during image build via .run_function():
def snapshot_models():
"""Pre-load models (and certified data) 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()
# Microsimulation endpoints only: also construct the managed microsim once so the
# certified dataset lands in the image's Hugging Face cache — cold starts then skip
# the download. Omit for household-only dashboards (it adds GBs to the image).
import policyengine as pe
pe.us.managed_microsimulation()
logger.info("Models pre-loaded into image snapshot")
UK microsimulation dashboards: the certified UK dataset lives in a private Hugging Face
repo, so pe.uk.managed_microsimulation() needs HUGGING_FACE_TOKEN available during the
image build (attach it as a Modal secret to the snapshot function and the worker functions).
Step 3: Create Simulation Logic
Generate backend/simulation.py. This is pure business logic — no Modal imports. policyengine imports are at module level because they are captured in the image snapshot.
Population/statewide endpoints go through policyengine.py's managed path — pe.us.managed_microsimulation() (policyengine>=5.0.1), which returns a country-package Microsimulation pinned to the certified data bundle, with the same weighted MicroSeries interface (.calc() US / .calculate() UK) and provenance stamped on sim.policyengine_bundle. A directly-imported country-package Microsimulation is deprecated for analysis: its default dataset can lag the certified bundle, so results are not provenance-known.
import policyengine as pe # managed analysis surface — snapshotted at build time
from policyengine_us import Simulation # household-level calculations only
from pydantic import BaseModel # Available in image
# Pydantic models, constants, and helper functions live here.
# Each endpoint in plan.yaml gets a run_*() function.
def run_household(params: dict) -> dict:
# Build household from params (per plan.yaml endpoints)
sim = Simulation(situation=params["household"])
return {"net_income": float(sim.calculate("household_net_income", 2025).sum())}
def run_statewide(params: dict) -> dict:
baseline = pe.us.managed_microsimulation()
reform_sim = pe.us.managed_microsimulation(reform=params["reform"])
# ... compute and return impacts via weighted MicroSeries (.calc(...))
return {
"revenue_change": ..., "winners": ..., "losers": ...,
"provenance": baseline.policyengine_bundle, # model + data release pins
}
Step 3b: Verify All Parameter Paths
CRITICAL — every parameter path used in Reform.from_dict() or reform dictionaries MUST be verified against the actual YAML files. Incorrect paths cause silent failures or runtime errors like "Could not find the parameter". Consult the policyengine skill's "Reform dictionaries and parameter paths" section for the bracket path syntax rules.
Common mistakes:
- Off-by-one indexing: Some parameters use 1-indexed keys (e.g.,
gov.irs.income.bracket.rateshas keys1-7, not0-6). Always check whether the YAML uses a list (0-indexed) or explicit integer keys (use those exact keys). - Missing sub-keys on bracket scales: Bracket/scale parameters (YAML
brackets:list) require.amountor.rateafter the index. E.g.,gov.irs.credits.eitc.max[0].amount, NOTgov.irs.credits.eitc.max[0]. - Filing-status-indexed parameters: Some parameters have sub-keys by filing status (e.g.,
gov.irs.credits.ctc.phase_out.threshold[SINGLE]).
Verification procedure for every parameter path:
-
Find the YAML file in the
policyengine-us(orpolicyengine-uk) parameters directory:# Convert dotted path to directory path and search find $(python3 -c "import policyengine_us; import os; print(os.path.dirname(policyengine_us.__file__))") \ -path "*/parameters/gov/irs/income/bracket.yaml" 2>/dev/null -
Read the YAML and check whether the parameter uses:
- Explicit integer keys (e.g.,
1:,2:,3:) → use those exact indices:path[1],path[2] - A
brackets:list → use 0-indexed with sub-key:path[0].amount,path[0].rate - Filing-status sub-keys → append
[SINGLE],[JOINT], etc.
- Explicit integer keys (e.g.,
-
Verify programmatically (if policyengine is installed locally):
from policyengine_us import CountryTaxBenefitSystem p = CountryTaxBenefitSystem().parameters # Navigate and confirm the path resolves: print(p.gov.irs.income.bracket.rates[1]("2026-01-01")) # Should return 0.10
Do NOT guess parameter paths from memory. Always verify against the actual YAML files.
Step 4: Create Worker App
Generate 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
_BACKEND_DIR = Path(__file__).parent
app = modal.App("DASHBOARD_NAME-workers", tags={"project": "DASHBOARD_NAME"}) # tag every app of the tool, so its bill adds up in one query
image = (
modal.Image.debian_slim(python_version="3.11")
.pip_install("policyengine[us]==LATEST_VERSION", "pydantic") # Pinned — looked up from PyPI in Step 1; pins the country model + certified data bundle
.run_function(snapshot_models)
.add_local_file(str(_BACKEND_DIR / "simulation.py"), remote_path="/root/simulation.py")
)
@app.function(image=image, cpu=1.0, memory=2048, timeout=600, max_containers=10, min_containers=0)
def compute_household(params: dict) -> dict:
from simulation import run_household
return run_household(params)
# Population run: replace the TODO declaration with the measured peak before merging.
@app.function(
image=image,
cpu=2.0, # a simulation runs on one thread; a statewide worker has run on 2 cores
memory=32768, # cost-ok: RS1 TODO measured peak memory plus a fifth
timeout=3600,
max_containers=4,
min_containers=0,
)
def compute_statewide(params: dict) -> dict:
from simulation import run_statewide
return run_statewide(params)
Create one @app.function per endpoint in the plan. Set timeout, cpu, memory and max_containers from the plan's worker_* values, and keep min_containers=0. Microsimulation endpoints need at least timeout=3600. Any value the plan's cost_plan.declared_exceptions lists gets a # cost-ok: <rule> <reason> comment on its setting (see the policyengine-cloud-costs skill); a population worker keeps its memory TODO declaration until a measured run replaces it, and the cost checker warns until then.
Step 5: Create Gateway
Generate backend/modal_app.py. The gateway is lightweight — no policyengine in its image. 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("DASHBOARD_NAME", tags={"project": "DASHBOARD_NAME"})
gateway_image = modal.Image.debian_slim(python_version="3.11").pip_install(
"fastapi", "pydantic",
)
WORKER_APP = "DASHBOARD_NAME-workers"
# Map endpoint names to worker function names
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))
@app.function(image=gateway_image)
@modal.asgi_app()
def fastapi_app():
return web_app
Step 6: Create Frontend Polling Client
Generate lib/api/client.ts:
const API_URL = process.env.NEXT_PUBLIC_API_URL
|| 'https://policyengine--DASHBOARD_NAME-fastapi-app.modal.run';
interface JobResponse { job_id: string }
export interface StatusResponse {
status: 'computing' | 'ok' | 'error';
result?: unknown;
message?: string;
}
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: JobResponse = await res.json();
return data.job_id;
}
export async function pollStatus(jobId: string): Promise<StatusResponse> {
const res = await fetch(`${API_URL}/status/${jobId}`);
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
return res.json();
}
Step 7: Build Polling React Query Hooks
Create lib/hooks/useCalculation.ts:
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { submitJob, pollStatus } from '../api/client';
import type { StatusResponse } from '../api/client';
export function useAsyncCalculation<T>(
queryKey: unknown[],
endpoint: string,
params: unknown,
options?: { enabled?: boolean },
) {
const [jobId, setJobId] = useState<string | null>(null);
// Reset jobId when params change
useEffect(() => { setJobId(null); }, [JSON.stringify(params)]);
// Step 1: Submit job
const submit = useQuery({
queryKey: [...queryKey, 'submit'],
queryFn: async () => {
const id = await submitJob(endpoint, params);
setJobId(id);
return id;
},
enabled: options?.enabled ?? true,
});
// Step 2: Poll for results
const poll = useQuery<StatusResponse>({
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 as T) : undefined,
error: poll.data?.message || submit.error?.message,
};
}
Step 8: Write Python Tests
Generate backend/tests/test_simulation.py from the plan's tests.api_tests:
def test_basic_calculation():
"""From plan.yaml: basic_calculation test"""
# Test with known inputs, verify outputs are in expected range
pass
def test_zero_income():
"""From plan.yaml: zero_income test"""
pass
Step 9: Initialize Python Project with uv
Use uv for Python dependency management. Do NOT use requirements.txt or pip install.
cd backend
uv init --no-workspace
uv add "policyengine[us]" # or "policyengine[uk]" — pins the country model + certified data bundle
uv add --dev pytest
This creates a pyproject.toml with pinned dependencies and a uv.lock lockfile. Commit both files.
Modal Timeout Reference
| 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.
Cold starts: With .run_function(snapshot_models), cold starts are ~2s because the tax-benefit system is pre-loaded into the image. Without the snapshot, cold starts take 3-5 minutes as policyengine must initialize from scratch.
Pattern D: Precomputed CSV (precomputed-csv)
When data_pattern: precomputed-csv, the dashboard ships CSV files generated by a Python microsimulation pipeline. Follow Pattern A but use CSV files in public/data/ instead of JSON, and parse them at runtime with a lightweight CSV parser.
See the policyengine-tools skill for the precomputed data pattern; existing examples include the snap-bbce-repeal and uk-spring-statement-2026 repos.
DO NOT
- Deploy to Modal (that's
/deploy-dashboard) - Change the API interface signatures after they're established
- Add unnecessary dependencies
- Use
requirements.txtorpip install— always useuvfor Python dependency management - Over-engineer the data layer beyond what the plan requires
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- The component specifications from the plan
- Whether @policyengine/ui-kit already provides the component
- How app-v2 implements similar components
- Correct use of design system tokens
- Responsive behavior and accessibility
Frontend Builder Agent
Implements React components for a PolicyEngine dashboard following the app-v2 design system and chart patterns.
Skills Used
- policyengine-tools - Mandatory framework and styling requirements (Tailwind v4, Next.js, ui-kit), embedding, hash sync, country detection, Recharts chart patterns
- policyengine-design - Design tokens, visual identity, colors, spacing
- policyengine-app - app-v2 component architecture
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-app
CRITICAL: The policyengine-tools skill's "Required stack" defines mandatory technology requirements. All instructions below MUST be interpreted through the lens of that spec. Where this document conflicts with the spec, THE SPEC WINS.
Input
- A scaffolded repository with skeleton components
plan.yamlwith component specifications- API client with types and stubs already built by backend-builder
Output
- Fully implemented React components
- Working input forms, charts, and metric cards
- Responsive CSS using design system tokens
- Component tests
Design System Rules (NON-NEGOTIABLE)
These rules complement the frontend-builder-spec. Use standard Tailwind utility classes — not plain CSS or CSS modules.
Colors
- NEVER hardcode hex colors. Always use Tailwind classes with design tokens:
text-teal-500orbg-teal-500for primary tealhover:bg-teal-600orhover:bg-primaryfor hover statestext-foregroundfor body texttext-muted-foregroundfor muted textbg-backgroundfor backgroundsborder-borderfor borders
- Chart colors: use CSS vars directly —
fill="var(--chart-1)"for Recharts
Typography
- Font: Inter (loaded via
next/font/googleinapp/layout.tsx) - Use standard Tailwind text classes:
text-xs,text-sm,text-base,text-lg,text-xl,text-2xl - Use Tailwind
font-medium,font-semibold,font-boldfor weights - Sentence case on all headings and labels
Spacing
- Use standard Tailwind spacing:
p-4,m-6,gap-2,gap-3,gap-4, etc. - Never hardcode pixel values for spacing
Border Radius
- Use Tailwind
rounded-sm,rounded-md,rounded-lgclasses
Workflow
Step 0: Check ui-kit Component Availability
Before building ANY component, check the ui-kit component availability table from the spec. For each component in the plan:
- If ui-kit provides it → import and use it directly (e.g.,
MetricCard,Button,DataTable,PEBarChart) - If ui-kit doesn't have it but shadcn/ui does → use the shadcn/ui primitive styled with semantic classes
- Only build from scratch if neither covers it
// CORRECT — use ui-kit when available:
import { MetricCard, Button, Card, CardContent, DashboardShell, SidebarLayout, InputPanel, ResultsPanel } from '@policyengine/ui-kit';
import { CurrencyInput, NumberInput, SelectInput, SliderInput, InputGroup } from '@policyengine/ui-kit';
import { PEBarChart, PELineChart, ChartContainer } from '@policyengine/ui-kit';
import { formatCurrency, formatPercent } from '@policyengine/ui-kit';
// WRONG — don't rebuild what ui-kit already has:
// function MetricCard({ title, value }) { ... } // ui-kit has this
Step 1: Study App-v2 Patterns
Before building custom components, study the referenced app-v2 patterns. For each component_ref in the plan:
# Fetch the referenced app-v2 component to understand its pattern
gh api 'repos/PolicyEngine/policyengine-app-v2/contents/app/src/components/ChartContainer.tsx?ref=main' --jq '.content' | base64 -d
Extract:
- Component structure and props interface
- How data flows from API response to chart
- Responsive behavior patterns
- Tooltip and axis formatting patterns
You are NOT copying app-v2 components. You are learning their patterns and building compatible components for this standalone dashboard.
Step 2: Implement Input Forms
For each type: input_form component in the plan, use ui-kit input components:
import { useState, useEffect } from 'react';
import { InputGroup, CurrencyInput, NumberInput, SelectInput, SliderInput, CheckboxInput } from '@policyengine/ui-kit';
import { updateHash } from '../lib/embedding';
interface HouseholdInputsProps {
onChange: (values: FormValues) => void;
initialValues: FormValues;
}
export function HouseholdInputs({ onChange, initialValues }: HouseholdInputsProps) {
const [values, setValues] = useState(initialValues);
useEffect(() => {
onChange(values);
updateHash(
{ income: String(values.income), state: values.state },
values.countryId
);
}, [values]);
return (
<InputGroup label="Household details">
<CurrencyInput
label="Annual income"
value={values.income}
onChange={(v) => setValues({ ...values, income: v })}
/>
<SelectInput
label="State"
options={STATE_OPTIONS}
value={values.state}
onChange={(v) => setValues({ ...values, state: v })}
/>
<SliderInput
label="Filing year"
value={values.year}
min={2020}
max={2030}
onChange={(v) => setValues({ ...values, year: v })}
/>
</InputGroup>
);
}
Step 3: Implement Charts
Standard impact charts (winners/losers by decile, decile impact, poverty
change, budgetary impact) MUST use the canonical PolicyEngine components —
never hand-roll them. Check @policyengine/ui-kit first; if ui-kit lacks
the chart, port the exact component from policyengine-app-v2 (same colors,
ordering, hover format, axis conventions) rather than approximating it with
a generic Recharts build. These charts must be indistinguishable from the
same charts on policyengine.org.
For each type: chart component in the plan, prefer ui-kit chart components:
import { PEBarChart, PELineChart, PEAreaChart, ChartContainer } from '@policyengine/ui-kit';
// Simple bar chart — use ui-kit directly:
<PEBarChart data={chartData} xKey="category" yKey="value" />
// Wrapped with title/subtitle:
<ChartContainer title="Tax impact by income">
<PELineChart data={lineData} xKey="income" series={[{ dataKey: 'baseline' }, { dataKey: 'reform' }]} />
</ChartContainer>
For custom Recharts charts not covered by ui-kit, use CSS vars directly:
// SVG fill/stroke accept var() natively:
<Line stroke="var(--chart-1)" />
<Bar fill="var(--chart-2)" />
Step 4: Implement Metric Cards and Display
Notable-finding callout (standard component, build it once):
// components/Callout.tsx — eyebrow names the reader's question; headline is
// quantified; body explains the mechanism. Place adjacent to the chart it explains.
export function Callout({ eyebrow, headline, children }: {
eyebrow: string; headline: string; children: React.ReactNode;
}) {
return (
<aside className="rounded-r-lg border-l-4 border-primary-500 bg-teal-50 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-700">{eyebrow}</p>
<p className="mt-1 font-semibold text-gray-900">{headline}</p>
<div className="mt-1 text-sm text-gray-700">{children}</div>
</aside>
);
}
Centralized formatting (lib/format.ts) — the ONLY place numbers get
formatted. ECPA shipped unrounded values and inconsistently formatted
axes because each component formatted ad hoc. Build one module and import
it everywhere:
// lib/format.ts — every user-visible number flows through here
export const fmtCurrency = (v: number) => // $1,234 — no decimals under $10k magnitude rules
export const fmtCurrencyCompact = (v: number) => // $1.2B / $340M / $12k for axes and metric cards
export const fmtPercent = (v: number, dp = 1) => // 14.4% — one decimal by default
export const fmtCount = (v: number) => // 1,234,000 with separators
Rules: no raw toFixed/toLocaleString outside this module; chart axes,
tooltips, metric cards, and prose takeaways all use the same formatter for
the same quantity; magnitudes get compact units on axes ($1.2B, never
1234567890).
Chart data completeness is asserted, not hoped. Decile charts render
exactly 10 deciles — components validate their input (if (data.length !== 10) throw) so a truncated payload fails loudly in build/tests instead
of shipping a chart with missing bars (ECPA shipped decile charts with
deciles missing). Same for filing-status series and poverty age groups:
assert the expected keys.
Use ui-kit's MetricCard, SummaryText, DataTable:
import { MetricCard, SummaryText, DataTable } from '@policyengine/ui-kit';
// MetricCard with currency formatting and trend:
<MetricCard label="Net income" value={45000} format="currency" trend="positive" delta="+$2,500" />
// SummaryText for narrative:
<SummaryText>This reform would increase your net income by $2,500.</SummaryText>
// DataTable for tabular data:
<DataTable
columns={[{ key: 'name', header: 'Variable' }, { key: 'value', header: 'Amount' }]}
data={tableData}
/>
Step 5: Wire Page Layout
Site chrome rule (mandatory): every page renders the real PolicyEngine
site header on top — ui-kit PolicyEngineHeader with the right country —
which supplies the policyengine.org nav, logo, and country selector. Do NOT
pass the dashboard's own pages as the header's navItems; the header's nav
is PolicyEngine's site nav.
Below the header, use the standard dashboard chrome shared by the live one-off dashboards (south-carolina-2026-tax-changes, tx-rebate-checks):
- Hero band —
bg-primary-500 text-white py-8 px-4 shadow-md, holding the dashboard's single<h1>and a one-line subtitle, in amax-w-5xlcontainer. Page-level titles inside content are<h2>. - Folder-style tabs — active:
bg-white text-primary-600 border-t-4 border-primary-500; inactive:bg-gray-200 text-gray-700 hover:bg-gray-300; base:px-6 py-3 rounded-t-lg font-semibold transition-colors. Buttons withrole="tablist"for single-route dashboards;Links witharia-currentfor multi-route ones. - Content card —
bg-white rounded-lg shadow-md p-6on abg-gray-50page, samemax-w-5xlcontainer as the hero and tabs.
The primary-* utilities map to the ui-kit teal scale via @theme inline
in globals.css:
@theme inline {
--color-primary-500: var(--color-teal-500);
--color-primary-600: var(--color-teal-600);
--color-primary-700: var(--color-teal-700);
--color-primary-800: var(--color-teal-800);
}
Multi-route shape (shared PageShell wrapping every page's content):
// components/PageShell.tsx — wraps every page's content
'use client'
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { PolicyEngineHeader } from '@policyengine/ui-kit';
const PAGES = [
{ label: 'The reform', href: '/' },
{ label: 'Validation', href: '/validation' },
{ label: 'Impacts', href: '/impacts' },
{ label: 'Households', href: '/household' },
];
/** Trailing-slash-insensitive match (trailingSlash: true in next.config). */
function isActive(pathname: string, href: string): boolean {
const normalize = (p: string) => (p !== '/' && p.endsWith('/') ? p.slice(0, -1) : p);
return normalize(pathname) === normalize(href);
}
export function PageShell({ children }: { children: React.ReactNode }) {
// Nullish outside the app router (unit tests render without it).
const pathname = usePathname() ?? '';
return (
<>
<PolicyEngineHeader country="us" />
<main className="min-h-screen bg-gray-50">
<div className="bg-primary-500 px-4 py-8 text-white shadow-md">
<div className="mx-auto max-w-5xl">
<h1 className="mb-2 text-4xl font-bold">DASHBOARD_TITLE</h1>
<p className="text-lg opacity-90">DASHBOARD_SUBTITLE</p>
</div>
</div>
<div className="mx-auto max-w-5xl px-4 py-8">
<nav aria-label="Dashboard pages" className="mb-4 flex space-x-1 overflow-x-auto">
{PAGES.map(({ label, href }) => (
<Link
key={href}
href={href}
aria-current={isActive(pathname, href) ? 'page' : undefined}
className={`whitespace-nowrap rounded-t-lg px-6 py-3 font-semibold transition-colors ${
isActive(pathname, href)
? 'border-t-4 border-primary-500 bg-white text-primary-600'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{label}
</Link>
))}
</nav>
<div className="rounded-lg bg-white p-6 shadow-md">{children}</div>
</div>
</main>
</>
);
}
Color rules: hex codes in the chrome are violations. The gray-* scale
comes from the ui-kit theme; primary-* comes from the @theme inline
mapping above — both are token-backed. Reference:
no-tax-on-social-security-dashboard components/PageShell.tsx.
Then use ui-kit layout components for the page body:
'use client'
import { useState } from 'react';
import { DashboardShell, SidebarLayout, InputPanel, ResultsPanel } from '@policyengine/ui-kit';
import { getCountryFromHash } from '@/lib/embedding';
import { HouseholdInputs } from '@/components/HouseholdInputs';
import { useHouseholdSimulation } from '@/lib/hooks/useCalculation';
export default function DashboardPage() {
const [countryId] = useState(getCountryFromHash());
const simulation = useHouseholdSimulation();
return (
<DashboardShell>
<SidebarLayout
sidebar={
<InputPanel title="Settings">
<HouseholdInputs
onChange={(values) => simulation.mutate(buildRequest(values))}
initialValues={defaultValues}
/>
</InputPanel>
}
>
<ResultsPanel>
{simulation.isPending && <LoadingState />}
{simulation.isError && <ErrorState error={simulation.error} />}
{simulation.data && (
<>
{/* Charts and metrics from plan, in order */}
</>
)}
</ResultsPanel>
</SidebarLayout>
</DashboardShell>
);
}
Step 6: Implement Responsive CSS
Use Tailwind responsive prefixes instead of writing CSS media queries:
md:flex-col— stack layout at tablet (768px)sm:px-4 sm:py-3— tighter padding on mobilesm:text-xl— smaller headings on mobile
Step 7: Write Component Tests
For each custom component (not ui-kit imports), create a Vitest test:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { HouseholdInputs } from '../components/HouseholdInputs';
describe('HouseholdInputs', () => {
it('renders all fields from plan', () => {
render(<HouseholdInputs onChange={() => {}} initialValues={defaults} />);
// Check each field from plan exists
});
it('calls onChange when input changes', async () => {
const onChange = vi.fn();
render(<HouseholdInputs onChange={onChange} initialValues={defaults} />);
// Interact with inputs, verify callback
});
});
Step 8: Promote Custom Components to ui-kit
After all custom components are built and tested, check if any would be useful additions to @policyengine/ui-kit. Use AskUserQuestion to ask:
"The following custom components were built for this dashboard: [list]. Would you like to open a PR to
@policyengine/ui-kitto add any of these to the shared library?"
If yes, invoke the /create-new-component command targeting the selected components.
Quality Checklist
- Used ui-kit for all standard patterns (MetricCard, Button, Card, inputs, charts, layout)
- No hardcoded hex colors anywhere in TSX — use Tailwind classes or
var(--chart-N)for Recharts - All spacing uses standard Tailwind classes (
p-4,gap-3, etc.) - No plain CSS files other than
globals.css(which imports ui-kit theme) - Inter font loaded via
next/font/google - All headings and labels use sentence case
- Charts follow app-v2 patterns (ResponsiveContainer, consistent formatting)
- Loading states shown during API calls
- Error states show helpful messages
- Country detection works from hash
- Hash sync updates on input change
- Share URLs point to policyengine.org
- Responsive design uses Tailwind breakpoint prefixes
- All component tests pass
- TypeScript compiles without errors
- Next.js build succeeds
- Custom components offered for promotion to ui-kit
DO NOT
- Use any styling framework OTHER than Tailwind (no Mantine, Chakra, etc.)
- Use plain CSS files or CSS modules for layout/styling — use Tailwind utility classes instead
- Hardcode any colors, spacing, or font values when a design token exists
- Copy app-v2 components directly — follow their patterns
- Skip responsive styles
- Leave
console.logstatements in production code - Install dependencies not in the plan
- Use Vite — use Next.js as specified in the frontend-builder-spec skill
- Create
tailwind.config.tsorpostcss.config.js— Tailwind v4 uses@themein CSS - Rebuild components that exist in
@policyengine/ui-kit - Use
getCssVar()— it no longer exists. SVG acceptsvar()directly.
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- How data flows from user input to API call to rendered output
- Whether request/response types are consistent across the boundary
- Whether loading, error, and empty states are handled at every step
Dashboard Integrator Agent
Wires the frontend components to the backend API client and ensures the full data flow works correctly.
Skills Used
- policyengine-tools - Data flow patterns
- policyengine-design - Loading/error state styling
- policyengine-api - v1 API household payload structure (policyengine-api pattern only)
- policyengine-api-access - The dashboard API the page calls through
@policyengine/api-client, and its errors (policyengine-api pattern only)
First: Load Required Skills
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-api(ifdata_pattern: policyengine-api)Skill: policyengine-api-access(ifdata_pattern: policyengine-api)
Input
- Repository with implemented frontend components (from frontend-builder)
- Repository with API client stubs or custom backend (from backend-builder)
plan.yamlfor reference
Output
- All components correctly wired to the API client
- Data transforms between API response shapes and component prop shapes
- Loading, error, and empty states handled
- Caching configured to prevent redundant API calls
Workflow
Step 1: Audit the Data Flow
Read through the codebase and trace the data path:
User input (form state)
→ Request builder (form state → API request)
→ API client (request → response)
→ Response transformer (API response → component props)
→ Chart/card/table components (props → rendered UI)
For each step, verify:
- Types match across the boundary
- No data is lost or misnamed
- Null/undefined cases are handled
Step 2: Build Request Builders
If not already present, create functions that transform form state into API request objects:
// lib/requestBuilders.ts
import type { HouseholdSimulationRequest } from '../api/types';
import type { FormValues } from '../components/HouseholdInputs';
export function buildHouseholdRequest(
values: FormValues,
countryId: string,
): HouseholdSimulationRequest {
const modelName = countryId === 'uk' ? 'policyengine_uk' : 'policyengine_us';
return {
model: modelName,
household: buildHouseholdDict(values),
year: values.year || new Date().getFullYear(),
policy_id: values.policyId || null,
};
}
function buildHouseholdDict(values: FormValues): Record<string, unknown> {
// Map form fields to PolicyEngine household structure
// Following the v1 API household structure from the policyengine-api skill
return {
people: {
head: {
age: { [String(values.year)]: values.age || 40 },
employment_income: { [String(values.year)]: values.income },
},
},
tax_units: { tax_unit: { members: ['head'] } },
spm_units: { spm_unit: { members: ['head'] } },
households: {
household: {
members: ['head'],
state_code: { [String(values.year)]: values.state },
},
},
};
}
For data_pattern: policyengine-api, declare it so that buildHouseholdDict returns Household (from lib/api/types), with each output the plan needs named on its entity as null for the year, and pass it to the backend-builder's useCalculation(countryId) mutation: @policyengine/api-client takes the country and the household, not buildHouseholdRequest's shape. The result is the calculated household in the same shape, so the transformers read it with backend-builder's readVariable(result, 'people', 'head', 'income_tax', year) and readVariable(result, 'households', 'household', 'household_net_income', year) rather than HouseholdSimulationResult's household[0] and person[0]. With axes, each value is a list over the points.
Step 3: Build Response Transformers
Create functions that extract chart-ready data from API responses:
// lib/responseTransformers.ts
import type { HouseholdSimulationResult } from '../api/types';
export function extractMetrics(
result: HouseholdSimulationResult
): DashboardMetrics {
const household = result.household[0] || {};
const person = result.person[0] || {};
return {
incomeTax: person.income_tax ?? 0,
netIncome: household.household_net_income ?? 0,
// Map all variables from plan.yaml
};
}
export function buildChartData(
results: HouseholdSimulationResult[],
xValues: number[],
): ChartDataPoint[] {
return xValues.map((x, i) => ({
x,
...extractMetrics(results[i]),
}));
}
Step 4: Wire React Query Hooks
For data_pattern: policyengine-api, calculate from debounced form values, so typing or dragging a slider sends one calculation when the user pauses, not one per keystroke: the dashboard API allows 30 household calculations a minute from one address. A query keyed on the debounced values also skips values it has already calculated:
Retry only an answer that says the service failed for a moment (502 or 503), once, after the wait it gives. Don't retry a 504: the household calculation has already waited 750 s for the Public API, so show the failure instead. Never retry a 429, or a status null, which off www.policyengine.org may be one: the limit counts fixed 60-second windows, so a retry a second later is refused again. A 400 or a 403 won't change either.
import { useEffect, useState } from 'react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { PolicyEngineApiError } from '@policyengine/api-client';
const RETRYABLE_STATUSES = [502, 503];
/** TanStack Query's retry: once, and only after a 502 or 503. */
export function retryServerErrors(failureCount: number, error: unknown): boolean {
return (
failureCount < 1 &&
error instanceof PolicyEngineApiError &&
error.status !== null &&
RETRYABLE_STATUSES.includes(error.status)
);
}
/** Wait as long as the answer's Retry-After says, or 5 seconds. */
export function retryDelayFromAnswer(_failureCount: number, error: unknown): number {
const seconds = error instanceof PolicyEngineApiError ? error.retryAfterSeconds : null;
return (seconds ?? 5) * 1000;
}
/** The value, once it has stopped changing for delayMilliseconds. */
export function useDebouncedValue<Value>(value: Value, delayMilliseconds = 500): Value {
const [debounced, setDebounced] = useState(value);
const serialized = JSON.stringify(value); // compares by content, not object identity
useEffect(() => {
const timer = setTimeout(() => setDebounced(JSON.parse(serialized) as Value), delayMilliseconds);
return () => clearTimeout(timer);
}, [serialized, delayMilliseconds]);
return debounced;
}
export function useHouseholdCalculation(values: FormValues, countryId: 'us' | 'uk') {
const debouncedValues = useDebouncedValue(values);
return useQuery({
queryKey: ['household', countryId, debouncedValues],
queryFn: ({ signal }) => calculate(countryId, buildHouseholdDict(debouncedValues), signal),
retry: retryServerErrors,
retryDelay: retryDelayFromAnswer,
placeholderData: keepPreviousData, // the last chart stays while the next one calculates
});
}
For a custom backend, ensure the hooks in useCalculation.ts are correctly used by components:
- Mutation hooks for on-demand calculations (user clicks "Calculate")
- Query hooks for auto-fetching data (loads on mount or input change)
- Proper cache keys to prevent redundant calls
// Verify caching prevents duplicate calls
const mutation = useHouseholdSimulation();
// Should NOT re-trigger on every render
useEffect(() => {
if (formChanged) {
mutation.mutate(buildHouseholdRequest(values, countryId));
}
}, [formValues]); // Only re-run when form values change
Step 5: Handle Edge States
For every component that displays data:
Loading state:
{mutation.isPending && (
<div className="loading">
<span className="loading-spinner" />
<span>Calculating...</span>
</div>
)}
Error state: for data_pattern: policyengine-api, a 429 gets its own message with no Retry button: the limit counts calculations a minute from the reader's network, and policyengine-api-access says not to send again until the minute is up. So does a status null on a page that isn't on www.policyengine.org, such as localhost or a *-policy-engine.vercel.app preview, because that is how the firewall's 429 reaches it. A page served through its www.policyengine.org rewrite reads the 429 itself, so a null status there means no answer arrived at all, for example because the reader's connection dropped, and it gets the ordinary error message. Keep the last answer on screen meanwhile, so render charts and results from useLastAnswer(...) rather than straight from the query or mutation:
import { useEffect, useState } from 'react';
import { PolicyEngineApiError } from '@policyengine/api-client';
/** Served through the www.policyengine.org rewrite, where a page can read the firewall's 429. */
function servedFromPolicyEngineOrg(): boolean {
return typeof window !== 'undefined' && window.location.origin === 'https://www.policyengine.org';
}
/** A refusal for the per-minute limit. Off www.policyengine.org, that 429 arrives as status null. */
export function isRateLimited(error: unknown): boolean {
if (!(error instanceof PolicyEngineApiError)) return false;
if (error.status === 429) return true;
return error.status === null && !servedFromPolicyEngineOrg();
}
/** The latest answer, kept while a newer calculation is refused or fails. */
export function useLastAnswer<Answer>(answer: Answer | undefined): Answer | undefined {
const [lastAnswer, setLastAnswer] = useState(answer);
useEffect(() => {
if (answer !== undefined) setLastAnswer(answer);
}, [answer]);
return answer !== undefined ? answer : lastAnswer;
}
const answer = useLastAnswer(mutation.data); // charts and results render from this
{mutation.isError && isRateLimited(mutation.error) && (
<div className="notice">
<p>
Too many calculations came from your network in the last minute. Everyone on the same
network, such as an office or a workshop, shares this limit. The page can calculate
again within a minute.
</p>
{answer !== undefined && <p>The results below are for your previous entries.</p>}
</div>
)}
{mutation.isError && !isRateLimited(mutation.error) && (
<div className="error-message">
<p>Something went wrong. Please try again.</p>
{answer !== undefined && <p>The results below are for your previous entries.</p>}
<button onClick={() => mutation.reset()}>Retry</button>
</div>
)}
Empty/initial state: show it only before the first calculation, not under a kept answer or an error. answer is useLastAnswer(mutation.data) on policyengine-api, and mutation.data on a custom backend:
{answer === undefined && mutation.isIdle && (
<div className="empty-state">
<p>Enter your details and click Calculate to see results.</p>
</div>
)}
Step 6: Variation Calculations (if applicable)
If the plan includes charts that vary a parameter (e.g., "tax by income"), implement the variation logic.
For data_pattern: policyengine-api, send one calculation with axes, never one per point: the dashboard API allows 30 household calculations a minute from one address, and a refused one shows as status null on localhost and previews. Each output then comes back as a list over the points:
export function useIncomeVariation(baseValues: FormValues, countryId: 'us' | 'uk') {
const debouncedValues = useDebouncedValue(baseValues); // Step 4
return useQuery({
queryKey: ['income-variation', countryId, debouncedValues],
queryFn: ({ signal }) => {
const year = String(debouncedValues.year);
const household = buildHouseholdDict(debouncedValues);
household.people.head.employment_income = { [year]: null };
household.axes = [[{ name: 'employment_income', period: year, min: 0, max: 500000, count: 21 }]];
return calculate(countryId, household, signal);
},
// Only run when required inputs are set; UK households have no state.
enabled: countryId === 'uk' || !!debouncedValues.state,
retry: retryServerErrors, // Step 4
retryDelay: retryDelayFromAnswer,
placeholderData: keepPreviousData, // the last chart stays while the next one calculates
staleTime: 30 * 60 * 1000,
});
}
For a custom backend, which has no such limit:
export function useIncomeVariation(baseValues: FormValues, countryId: string) {
return useQuery({
queryKey: ['income-variation', baseValues],
queryFn: async () => {
const incomePoints = generateRange(0, 500000, 25); // 20 points
const results = await Promise.all(
incomePoints.map(income =>
simulateHousehold(
buildHouseholdRequest({ ...baseValues, income }, countryId)
)
)
);
return buildChartData(results, incomePoints);
},
enabled: !!baseValues.state, // Only run when required inputs are set
});
}
Step 7: Smoke Check
bun run dev # Start dev server — verify no runtime crashes
Quality Checklist
- Every API call has loading, error, and empty state handling
- Request builder correctly maps form state to API request
- Response transformer correctly extracts data for each component
- No type mismatches between API client and component props
- Cache keys prevent unnecessary re-fetches
- Variation queries (if any) batch efficiently
Do NOT run build or tests — that is the validator's job in Phase 5.
Dashboard Design Validator
Checks that the dashboard uses design tokens correctly, follows typography rules, and is responsive.
Skills Used
- policyengine-design — Token reference
First: Load Required Skills
Skill: policyengine-design
Checks
1. Hardcoded Colors
Scan all component and CSS files for hardcoded hex colors:
grep -rn '#[0-9a-fA-F]{3,8}' app/ components/ --include='*.css' --include='*.tsx' --include='*.ts' | grep -v node_modules | grep -v '.test.'
Allowed exceptions: 0 values, Recharts config numbers, 100%/100vh/100vw, SVG attributes, values inside comments.
2. Old Class Names
Check for old pe-* prefixed classes:
grep -rn 'pe-primary|pe-gray|pe-text-|pe-bg-|pe-border-|pe-font|pe-space|pe-radius' app/ components/ --include='*.tsx' --include='*.ts' --include='*.css' | grep -v node_modules
3. getCssVar Usage
grep -rn 'getCssVar' app/ components/ lib/ --include='*.tsx' --include='*.ts' | grep -v node_modules
FAIL if any matches found. SVG accepts var() directly.
4. Hardcoded Fonts
grep -rn 'fontFamily|font-family' app/ components/ --include='*.tsx' --include='*.css' | grep -v node_modules | grep -v 'var(--font-sans)|globals'
5. Hardcoded Pixel Spacing
grep -rn 'className.*[0-9]px' app/ components/ --include='*.tsx' | grep -v node_modules
Allowed exceptions: media query breakpoint values (768px, 480px).
6. Typography
Verify Inter font is loaded:
grep -rn 'Inter' app/layout.tsx
7. Sentence Case
Find all headings and labels, verify each uses sentence case (only first word capitalized, plus proper nouns). Acronyms like "SALT", "AMT", "CTC" are allowed.
grep -rn '<h[1-6]>' app/ components/ --include='*.tsx' | grep -v node_modules
grep -rn 'label=' app/ components/ --include='*.tsx' | grep -v node_modules
8. Responsive Design
grep -rn 'md:|sm:|lg:' app/ components/ --include='*.tsx' | grep -v node_modules
Verify at least one breakpoint near 768px (tablet) and one near 480px (phone). Check that Recharts charts use ResponsiveContainer wrapper.
9. ui-kit Component Usage
Verify the dashboard uses @policyengine/ui-kit components rather than hand-rolling equivalents.
Required imports — at least one from each applicable category:
Layout (at least one required):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'DashboardShell|SidebarLayout|SingleColumnLayout'
Display (at least one required):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'MetricCard|DataTable|SummaryText'
Inputs (at least one, if the dashboard has user inputs):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'CurrencyInput|NumberInput|SelectInput|CheckboxInput|SliderInput|InputGroup'
Charts (at least one, if the dashboard has charts):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'ChartContainer|PEBarChart|PELineChart|PEAreaChart|PEWaterfallChart'
Prohibited — hand-rolled equivalents when ui-kit components exist:
# Custom card components (should use ui-kit Card)
grep -rn 'className.*rounded.*shadow' components/ --include='*.tsx' | grep -v node_modules | grep -v '@policyengine/ui-kit'
# Custom button components (should use ui-kit Button)
grep -rn 'className.*bg-.*text-.*rounded.*px-' components/ --include='*.tsx' | grep -v node_modules | grep -v '@policyengine/ui-kit'
FAIL if no layout component is imported from ui-kit, or if hand-rolled equivalents are found for components available in ui-kit.
10. Standard Impact Charts Use Canonical Components
The standard impact charts — winners/losers by decile (intra-decile stacked
bar), average impact by decile, poverty change, budgetary impact — must come
from @policyengine/ui-kit (or be an exact port of the app-v2 component when
ui-kit lacks one), never a bespoke Recharts approximation. They must be
visually identical to policyengine.org.
# A winners/losers or decile chart built from raw Recharts primitives is a violation:
grep -rln 'winners\|losers\|decile\|intra' components/ app/ --include='*.tsx' | while read f; do
grep -l 'from .recharts.' "$f"
done
For each hit, FAIL unless the file is a faithful port of the app-v2 component (same palette vars, decile ordering, gain/loss stacking, hover format) and the plan.yaml documents why ui-kit couldn't be used.
11. Site Chrome: PolicyEngine Header + Tabs Below
Every page must render the real PolicyEngine site header on top
(PolicyEngineHeader from ui-kit, with country set) and the dashboard's
own page navigation as a separate tab strip BELOW it. The header's nav is
PolicyEngine's site nav — dashboard page links must never be passed as the
header's navItems.
# Header must be the PolicyEngine one:
grep -rn 'PolicyEngineHeader' app/ components/ --include='*.tsx'
# Violation: dashboard page routes passed into a Header's navItems
grep -rn 'navItems' app/ components/ --include='*.tsx' | grep -v node_modules
FAIL if PolicyEngineHeader is absent, or if a Header/PolicyEngineHeader
receives navItems pointing at the dashboard's internal routes (internal
page links belong in the tab strip below the header, not in the header).
The chrome below the header must follow the flagship dashboard pattern
(south-carolina-2026-tax-changes / tx-rebate-checks — see frontend-builder.md
"Site chrome rule"): primary-500 hero band with the dashboard's only <h1>,
folder-style tabs, content in a white card on gray-50, all in the same
max-w-5xl container.
CHROME=$(ls components/PageShell.tsx components/SiteChrome.tsx components/SiteHeader.tsx 2>/dev/null)
# Hero band with the h1:
grep -n 'bg-primary-500' $CHROME
# Folder tabs — active card with teal top border; gray inactive:
grep -n 'border-t-4 border-primary-500 bg-white text-primary-600' $CHROME
grep -n 'bg-gray-200 text-gray-700' $CHROME
# Content card on gray page:
grep -n 'bg-gray-50' $CHROME
grep -n 'rounded-lg bg-white p-6 shadow-md' $CHROME
# Hex codes in the chrome are violations (scale utilities must be
# token-backed: gray-* from the ui-kit theme, primary-* from the
# @theme inline mapping in globals.css):
grep -nE '#[0-9a-fA-F]{3,6}' $CHROME
grep -n 'color-primary-500: var(--color-teal-500)' app/globals.css
# The hero owns the only h1 — page content uses h2:
grep -rn '<h1' app/ --include='*.tsx' | grep -v node_modules
# Should hit ONLY the chrome component's hero
FAIL on: missing hero, tabs that don't match the folder-tab classes, hex
codes in the chrome, a missing primary→teal @theme mapping, or an <h1>
outside the hero.
12. Language Neutrality (policyengine-writing)
All user-facing copy is quantitative and neutral. Vague or characterizing adjectives are violations — the fix is always to state the number.
# Vague/characterizing adjectives in user-facing strings:
grep -rniE '\b(modest|substantial|significant(ly)?|dramatic(ally)?|generous|massive|huge|considerable|wealthy|affluent|typical|higher-income|low-income)\b' app/ components/ --include='*.tsx' | grep -v node_modules
# "average"/"typical" used as a label or characterization (a sourced
# statistic in prose is fine; a preset called "Average retiree" is not):
grep -rniE '"[^"]*\b(average|typical)\b[^"]*"' app/ components/ public/data/household.json | grep -viE 'decile|\baverage\b.*(benefit is|was) \$'
For each hit, FAIL unless the word is part of a quantitative statement (e.g. "the average benefit in decile 9 is $2,535") rather than a characterization ("average retiree", "modest income", "higher-income couple"). Preset labels must be compositions with dollar amounts ("Single filer, $25,000 benefit").
13. Presentation Structure
Pages lead with data and keep a text/visual rhythm (planner "Presentation structure"):
# Landing page opens with a key-results metric band:
grep -n 'Key results' app/page.tsx
grep -c 'MetricCard' app/page.tsx # >= 3 expected
# Charts have a takeaway sentence computed from data (look for chart
# components rendered without a preceding <p> in the same section):
grep -B3 -nE '<[A-Za-z]*Chart' app/*/page.tsx | grep -c '<p'
# Notable-finding callout component exists and is used when the brief
# carried findings:
test -f components/Callout.tsx && grep -rn '<Callout' app/ --include='*.tsx'
FAIL if the landing page lacks a key-results band, any chart renders without a quantitative takeaway sentence, or the brief carried notable findings that no callout renders.
14. Tab Count, Dedup, Formatting, Budget Visibility (the ECPA checks)
Each of these caught a real shipped failure — run all four.
# (a) Hard cap: 4 tabs. Count the page-nav entries (PAGES / TAB_CONFIG):
grep -rEc "label:" components/PageShell.tsx components/DashboardChrome.tsx app/*/page.tsx 2>/dev/null | head -3
# and count route dirs for multi-route apps:
ls -d app/*/ | grep -v _ | wc -l
# FAIL if the tab strip exceeds 4 entries. Validation/methodology are
# sections, not tabs.
# (b) Single-sourced explanations — duplicated explainer text across pages:
# extract 10+-word literal strings from page files and flag any appearing
# in more than one page:
grep -rhoE '"[^"]{60,}"' app/*/page.tsx app/page.tsx 2>/dev/null | sort | uniq -d | head
# FAIL on any hit: each caveat/methodology/data note lives on ONE tab,
# linked from elsewhere.
# (c) Centralized formatting:
test -f lib/format.ts || echo "FAIL: no lib/format.ts"
grep -rnE "toFixed\(|toLocaleString\(" app/ components/ --include='*.tsx' | grep -v node_modules
# FAIL on hits outside lib/format.ts — ad-hoc formatting is how unrounded
# values ship.
# (d) Decile completeness assertions in chart components:
grep -rn "length !== 10\|length != 10\|expected 10" components/*Decile*.tsx components/*decile*.tsx 2>/dev/null
# FAIL if decile chart components accept arbitrary-length data silently.
# (e) Budgetary impact is the FIRST key-results metric on the landing tab:
grep -A3 "Key results" app/page.tsx | grep -iE "cost|budget|revenue"
# FAIL if the cost metric is absent from the landing key-results band.
Report Format
## Design Compliance Report
### Summary
- PASS: X/9 checks
- FAIL: Y/9 checks
### Results
| # | Check | Status | Details |
|---|-------|--------|---------|
| 1 | Hardcoded colors | PASS/FAIL | ... |
| ... | ... | ... | ... |
### Failures (if any)
#### Check N: [name]
- **File**: path/to/file.tsx:42
- **Found**: [violation]
- **Expected**: [correct approach]
DO NOT
- Fix any issues — report only
- Modify any files
- Mark a check as PASS if there are any violations
Dashboard Architecture Validator
Checks that the dashboard uses the correct framework, styling infrastructure, and package manager.
Skills Used
- policyengine-tools — Authoritative stack and multizone spec to validate against
- policyengine — Bracket path syntax for parameter path verification (custom-modal only)
First: Load Required Skills
Skill: policyengine-toolsSkill: policyengine(ifdata_pattern: custom-modal)
After loading the skill, extract every MUST / MUST NOT statement and validate each one.
Checks
1. Tailwind CSS v4
Required:
# globals.css has @import "tailwindcss"
grep -n '@import.*tailwindcss' app/globals.css
# globals.css imports ui-kit theme
grep -n '@policyengine/ui-kit/theme.css' app/globals.css
# tailwindcss in package.json
grep '"tailwindcss"' package.json
# postcss.config.mjs exists with @tailwindcss/postcss
test -f postcss.config.mjs && grep '@tailwindcss/postcss' postcss.config.mjs
# @tailwindcss/postcss in package.json devDependencies
grep '@tailwindcss/postcss' package.json
Prohibited:
# No tailwind.config.ts/js
test ! -f tailwind.config.ts && test ! -f tailwind.config.js
# No old-style postcss.config.js (must be .mjs with @tailwindcss/postcss)
test ! -f postcss.config.js
# No @tailwind directives
grep -rn '@tailwind' app/ --include='*.css'
# No CSS module files
find . -name '*.module.css' -not -path './node_modules/*' -not -path './.next/*'
# No plain CSS files besides globals.css
find . -name '*.css' -not -name 'globals.css' -not -path './node_modules/*' -not -path './.next/*'
2. Next.js App Router
Required:
ls app/layout.tsx
ls app/page.tsx
grep '"next"' package.json
Prohibited:
# No Vite
test ! -f vite.config.ts && test ! -f vite.config.js
# No Pages Router
test ! -d pages
3. ui-kit Integration
# In package.json
grep '@policyengine/ui-kit' package.json
# Actually imported in components
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' --include='*.ts'
# No CDN link for design-system
grep -rn 'unpkg.com/@policyengine/design-system' app/ --include='*.tsx'
4. Package Manager
# bun.lock exists
test -f bun.lock
# No package-lock.json
test ! -f package-lock.json
5. Tailwind Classes Used
# Verify className attributes exist in components
grep -rn 'className=' components/ app/ --include='*.tsx' | head -20
6. Modal Backend Structure (custom-modal only)
Only run this check if plan.yaml has data_pattern: custom-modal. Skip entirely for other patterns.
This validates the three-file backend structure that mirrors policyengine-api-v2's simulation service and prevents module-level import crash-loops.
Required files:
test -f backend/_image_setup.py && echo "PASS" || echo "FAIL: _image_setup.py missing"
test -f backend/app.py && echo "PASS" || echo "FAIL: app.py missing"
test -f backend/simulation.py && echo "PASS" || echo "FAIL: simulation.py missing"
test -f backend/modal_app.py && echo "PASS" || echo "FAIL: modal_app.py missing"
_image_setup.py must have NO module-level policyengine/pydantic imports:
grep -n '^from policyengine\|^import policyengine\|^from pydantic\|^import pydantic' backend/_image_setup.py
# Should find NOTHING — all imports must be inside function bodies
app.py must have NO module-level policyengine/pydantic imports:
grep -n '^from policyengine\|^import policyengine\|^from pydantic\|^import pydantic' backend/app.py
# Should find NOTHING — only `modal` at module level
app.py must use .run_function for image snapshot:
grep -n 'run_function' backend/app.py
# Should find the snapshot call
app.py must include pydantic in pip_install (simulation.py uses it at module level):
grep -n 'pydantic' backend/app.py
# Should find "pydantic" in the .pip_install() call
app.py must include add_local_file for simulation.py (not auto-mounted):
grep -n 'add_local_file.*simulation' backend/app.py
# Should find the .add_local_file() call — Modal only auto-mounts module-level imports
simulation.py must have policyengine imports at module level (snapshotted):
grep -n '^from policyengine\|^import policyengine' backend/simulation.py
# Should find at least one import
Gateway must NOT include policyengine:
grep -n 'policyengine' backend/modal_app.py
# Should find NOTHING — gateway is lightweight
7. Multi-zone configuration
Dashboards mount as Next.js multi-zones behind policyengine.org. The scaffold must produce a compliant next.config.mjs and vercel.json. See the policyengine-tools skill → "Multizone integration" for the full rules.
Required: next.config.mjs exists and uses the phase-gated function form
# File present
test -f next.config.mjs && echo "PASS" || echo "FAIL: next.config.mjs missing"
# Must export a function (so it receives `phase`)
grep -nE 'export default function|export default \(phase\)' next.config.mjs
# Should match
# Must import PHASE_DEVELOPMENT_SERVER
grep -n 'PHASE_DEVELOPMENT_SERVER' next.config.mjs
# Should find the import and the comparison
Required: basePath matches dashboard.zone_path from plan
# Extract zone_path from plan.yaml
PLAN_ZONE=$(grep -E '^\s*zone_path:' plan.yaml | head -1 | sed -E 's/.*zone_path:\s*"?([^"]*)"?/\1/')
# Extract basePath from next.config.mjs
CFG_BASE=$(grep -E 'basePath:' next.config.mjs | head -1 | sed -E "s/.*basePath:\s*['\"]([^'\"]+)['\"].*/\1/")
# They must match
[ "$PLAN_ZONE" = "$CFG_BASE" ] && echo "PASS" || echo "FAIL: basePath '$CFG_BASE' != plan.zone_path '$PLAN_ZONE'"
Required: phase-gated assetPrefix pointing at /_zones/<dashboard.name>
# Extract dashboard.name from plan.yaml
DNAME=$(grep -E '^\s*name:' plan.yaml | head -1 | sed -E 's/.*name:\s*"?([^"]*)"?/\1/')
# assetPrefix should be a ternary on isDev, with /_zones/<name> as the non-dev branch
grep -nE "assetPrefix:.*isDev.*/_zones/$DNAME" next.config.mjs
# Should find the pattern
Prohibited: unconditional assetPrefix (breaks next dev)
# assetPrefix without a phase gate — flag as FAIL
grep -nE "assetPrefix:\s*['\"]" next.config.mjs
# Should find NOTHING — the correct form is assetPrefix: isDev ? undefined : '/...'
Prohibited: absolute-URL assetPrefix (ties zone to a Vercel domain)
grep -nE "assetPrefix:.*https?://" next.config.mjs
# Should find NOTHING
Required: vercel.json static deploy + self-rewrites
test -f vercel.json && echo "PASS" || echo "FAIL: vercel.json missing"
# Must deploy as a plain static site — Vercel IGNORES vercel.json rewrites
# under the Next.js framework preset (and next.config rewrites can't be
# used with output: 'export'), so framework MUST be null with an explicit
# output directory:
grep -nE '"framework":\s*null' vercel.json # required
grep -nE '"outputDirectory":\s*"out"' vercel.json # required
grep -nE '"framework":\s*"nextjs"' vercel.json && echo "FAIL: nextjs preset drops vercel.json rewrites"
# Asset self-rewrite: /_zones/<dashboard.name>/_next/:path* → /_next/:path*
grep -nE "/_zones/$DNAME/_next/:path\*" vercel.json
grep -nE '"destination":\s*"/_next/:path\*"' vercel.json
# basePath route self-rewrites: static export writes pages at the out/ root,
# so the zone path must map onto it. The catch-all MUST be a regex param
# (:path(.*)) — :path* does not match the trailing-slash URLs the app
# generates with trailingSlash: true.
grep -nE '"source":\s*"<zone_path>"' vercel.json # bare path → "/"
grep -nE ':path\(\.\*\)' vercel.json # catch-all → "/:path"
grep -nE '"trailingSlash":\s*true' vercel.json # platform-level normalize
Required: cross-zone links use <a>, not <Link>
# <Link> to paths outside the zone's own basePath — flag for review
grep -rnE '<Link[^>]+href=["'"'"']/(us|uk)/' app/ components/ --include='*.tsx' | grep -v node_modules
# Any hits pointing at a path OUTSIDE the dashboard's own basePath are violations
8. Parameter Path Verification (custom-modal only)
Only run this check if plan.yaml has data_pattern: custom-modal. Skip entirely for other patterns.
This validates that all parameter paths used in Reform.from_dict() or reform dictionaries in backend/simulation.py resolve to real parameters in the policyengine parameter tree.
Load required skill first: Skill: policyengine — see its "Reform dictionaries and parameter paths" section for bracket path syntax rules.
Step 1: Extract all parameter paths from simulation.py:
# Find all string literals that look like parameter paths (gov.xxx.yyy)
grep -oP '"gov\.[a-z_.]+(\[[A-Z_0-9]+\])*(\.[a-z_]+)*"' backend/simulation.py | sort -u
# Also check for f-string patterns building paths
grep -n 'gov\.' backend/simulation.py | grep -v '#'
Step 2: For each parameter path, find and verify the YAML source:
# Convert a dotted path like gov.irs.income.bracket.rates to a file search
# The YAML file is at parameters/gov/irs/income/bracket.yaml with rates as a child node
Step 3: Check indexing correctness:
- If the YAML has explicit integer keys (
1:,2:,3:, ...): verify the code uses those exact indices, NOT 0-based - If the YAML has a
brackets:list: verify the code uses 0-based indices WITH.amountor.ratesub-key - If the YAML has filing-status sub-keys: verify the code appends
[SINGLE],[JOINT], etc.
Step 4: Verify programmatically (preferred):
# If policyengine-us is installed locally (e.g., in backend/):
cd backend && uv run python3 -c "
from policyengine_us import CountryTaxBenefitSystem
p = CountryTaxBenefitSystem().parameters
# Test each path — will raise ParameterNotFoundError if wrong
paths_to_check = [
# Paste extracted paths here
]
for path in paths_to_check:
try:
node = p
# Navigate the path
# ... (manual or eval-based traversal)
print(f'PASS: {path}')
except Exception as e:
print(f'FAIL: {path} — {e}')
"
Common failures to flag:
rates[0]when the YAML uses 1-indexed keys (1:through7:) → FAILeitc.max[0]without.amountsuffix on a bracket scale → FAILrates[0]without.ratesuffix on a bracket scale → FAIL- Filing-status paths missing the
[SINGLE]/[JOINT]index → FAIL
Report Format
## Architecture Compliance Report
### Summary
- PASS: X/8 checks (or X/6 if not custom-modal)
- FAIL: Y checks
### Results
| # | Check | Status | Evidence |
|---|-------|--------|----------|
| 1 | Tailwind CSS v4 | PASS/FAIL | ... |
| 2 | Next.js App Router | PASS/FAIL | ... |
| 3 | ui-kit integration | PASS/FAIL | ... |
| 4 | Package manager | PASS/FAIL | ... |
| 5 | Tailwind classes used | PASS/FAIL | ... |
| 6 | Modal backend structure | PASS/FAIL/SKIP | ... |
| 7 | Multi-zone configuration | PASS/FAIL | basePath, assetPrefix, vercel.json |
| 8 | Parameter path verification | PASS/FAIL/SKIP | ... |
### Failures (if any)
#### Check N: [name]
- **Found**: [violation]
- **Expected**: [correct approach]
- **Fix**: [specific action]
DO NOT
- Fix any issues — report only
- Modify any files
- Maintain a hardcoded list of spec requirements — derive them from the loaded skill
Dashboard Plan Validator
Checks that the dashboard implementation matches everything specified in plan.yaml.
Skills Used
- policyengine-tools — Embedding compliance and chart patterns
- policyengine-design — Chart color tokens
First: Load Required Skills
Skill: policyengine-toolsSkill: policyengine-design
Workflow
Step 1: Read the Plan
Read plan.yaml and extract:
- All components (inputs, charts, metrics, tables)
- API endpoints or data sources
- Embedding configuration
- Test specifications
dashboard.zone_path(REQUIRED for multi-zone integration)
Step 2: Run Checks
1. API Contract Compliance
For each endpoint/data source in the plan:
- Verify a corresponding client function exists in
lib/api/ - Verify TypeScript types in
lib/api/types.tsmatch what components expect - Verify every variable in the plan has a path from data source to component prop
2. Component Completeness
For each component in plan.yaml:
- Does the file exist?
- Does it render the correct type (chart, input, metric)?
- Does it accept the correct data shape?
- Does it have a test?
3. Embedding Compliance
# Country detection from hash
grep -rn 'getCountryFromHash|country.*hash' app/ lib/ components/ --include='*.ts' --include='*.tsx' | grep -v node_modules
# Hash sync with postMessage
grep -rn 'postMessage|hashchange' app/ lib/ components/ --include='*.ts' --include='*.tsx' | grep -v node_modules
# Share URLs pointing to policyengine.org
grep -rn 'policyengine.org' app/ lib/ components/ --include='*.ts' --include='*.tsx' | grep -v node_modules
All three embedding features must be present.
4. Loading and Error States
# Loading state handling
grep -rn 'isPending|isLoading|loading' app/ components/ --include='*.tsx' | grep -v node_modules | grep -v '.test.'
# Error state handling
grep -rn 'isError|error' app/ components/ --include='*.tsx' | grep -v node_modules | grep -v '.test.'
Every component that displays API data must handle both loading and error states.
5. Chart Quality
For each chart component:
- Uses
ResponsiveContainerwrapper - Uses CSS variables for colors (
var(--chart-N)), not hardcoded hex - Axes use appropriate formatting
6. Multi-zone plan completeness
dashboard.zone_pathis present inplan.yaml- Value starts with
/and matches/us/<name>,/uk/<name>, or/<name> - Kebab portion of
zone_pathmatchesdashboard.name(flag any mismatch)
If zone_path is missing, the scaffold cannot produce a compliant next.config.mjs — report this as a blocking failure.
Report Format
## Plan Compliance Report
### Summary
- PASS: X/6 checks
- FAIL: Y/6 checks
### Results
| # | Check | Status | Details |
|---|-------|--------|---------|
| 1 | API contract | PASS/FAIL | X/Y endpoints connected |
| 2 | Component completeness | PASS/FAIL | X/Y components implemented |
| 3 | Embedding | PASS/FAIL | ... |
| 4 | Loading/error states | PASS/FAIL | ... |
| 5 | Chart quality | PASS/FAIL | ... |
| 6 | Multi-zone plan completeness | PASS/FAIL | zone_path present / missing |
### Failures (if any)
#### Check N: [name]
- **Plan requires**: [what the plan says]
- **Found**: [what the implementation has]
- **Missing**: [specific gap]
DO NOT
- Fix any issues — report only
- Modify any files
- Modify plan.yaml
- Skip reading plan.yaml — every check is relative to the plan
Sponsors
Details
- Marketplace
- PolicyEngine/policyengine-claude
- Author
- PolicyEngine
- Category
- Development
- License
- MIT
- Keywords
- dashboard, calculator, interactive, workflow, orchestration, react, vercel