Orchestrated AI workflow for creating PolicyEngine dashboards from natural-language descriptions

version
4.1.1
GitHub stars
31

Install

/plugin install dashboard-builder@policyengine-claude

Run 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 (default 1). 10 adds 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 NAME is REQUIRED with --auto-confirm — it names the desired repo. If not found locally and gh repo view PolicyEngine/{NAME} 404s, create PolicyEngine/{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-init ambiguity, 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

FileWriterReaderSize
{REPO_PATH}/plan.yamlplanner agentAll subsequent agentsFull
{PREFIX}-plan-summary.mdplanner agentOrchestratorShort ≤20
{PREFIX}-design-report.mddesign-validatorOrchestratorShort ≤15
{PREFIX}-arch-report.mdarch-validatorOrchestratorShort ≤15
{PREFIX}-plan-report.mdplan-validatorOrchestratorShort ≤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:

  1. If it's an absolute path → use directly
  2. If it contains slashes → resolve relative to cwd
  3. Otherwise search: sibling of cwd → ~/Documents/PolicyEngine/{NAME} → ~/PolicyEngine/{NAME} → ~/{NAME}
  4. Set REPO_PATH to 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.

  1. 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.

  1. 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.

  1. Create the repo:
gh repo create PolicyEngine/{DASHBOARD_NAME} --public --description "PolicyEngine {DASHBOARD_NAME} dashboard"
  1. 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"
  1. 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
  1. 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):

ValidatorAgent fileReport file
designdashboard-design-validator.mddesign-report.md
architecturedashboard-architecture-validator.mdarch-report.md
plandashboard-plan-validator.mdplan-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 ValidatorFailure containsRoute to builder
design(any failure)frontend-builder.md
architecture"tailwind", "next.js", "package manager"dashboard-scaffold.md
architecture"ui-kit", or otherfrontend-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

CategoryExampleAction
GitHub auth failuregh api user failsTell user to run gh auth login, STOP
Repo already existsgh repo create says existsTell user to use --repo NAME, STOP
Build failurebun run build failsRe-run scaffold with error context (max 2 retries)
Test failurebunx vitest run failsRe-run scaffold with error context (max 2 retries)
Validation failureValidator reports FAILRoute to builder, re-validate (max 3 cycles)
Agent failureAgent errors or doesn't completeReport to user, suggest re-running that phase
User rejects plan"Reject" at plan approvalSTOP

Escalation Path

  1. Agent encounters error → Attempt fix via re-spawning agent
  2. Fix fails after max retries → Report to user with AskUserQuestion
  3. User chooses to stop → STOP cleanly
  4. Never proceed past a human gate without approval

Execution Instructions

YOUR ROLE: You are an orchestrator ONLY. You must:

  1. Invoke agents using the Task tool with subagent_type: "general-purpose"
  2. Wait for agent completion (or use run_in_background: true for parallel)
  3. Read ONLY short summary/report files (≤20 lines)
  4. Run quality gates via Bash
  5. Present checkpoints to user via AskUserQuestion
  6. 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):

  1. Phase 0: Parse args, init repo (or use existing)
  2. Phase 1: Plan → HUMAN APPROVAL (approve/modify/reject)
  3. Phase 2: Scaffold → quality gates (build + test, max 2 retries)
  4. Phase 3: Backend + Frontend (PARALLEL — spawn both in one message)
  5. Phase 4: Integrate
  6. Phase 5: Validate (4 validators parallel, fix routing, max 3 cycles) — skip if --skip-validate
  7. Phase 6: Review → HUMAN APPROVAL → commit and push
  8. 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-modal pattern)
  • policyengine-cloud-costs — What may stay running after the deploy, and the declared exceptions to report (only if custom-modal pattern)

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 into main first:

git checkout main
git merge {branch}
git push

Then run /deploy-dashboard again.

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 names
  • dashboard.zone_path — for multi-zone host rewrite verification
  • data_pattern — determines if Modal deploy is needed (custom-modal needs it; precomputed, precomputed-csv, and policyengine-api do not)
  • tech_stack.framework — should be react-nextjs (env var prefix: NEXT_PUBLIC_*)
  • embedding.register_in_apps_json — determines if apps.json update is needed
  • embedding.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 policyengine workspace.

Please run:

modal token new --profile policyengine
modal profile activate policyengine

If 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:

  1. Host rewrites — required for every multi-zone dashboard. These make the zone reachable at policyengine.org/<zone_path>.
  2. 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):

  1. Direct zone URL: Visit https://VERCEL_PRODUCTION_URL/<zone_path>, verify the dashboard loads
  2. Hash sync: Test that URL parameters work (add #income=50000 etc.) and survive refresh
  3. Country detection (if supported): Test with #country=uk

After Step 5 PR merges:

  1. Public path via host: Verify the dashboard loads at policyengine.org/COUNTRY/SLUG (served through host rewrites, not an iframe)
  2. 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.
  3. 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

IssueFixReference
Vercel deploy failsCheck vercel.json config, ensure project buildspolicyengine-tools
Modal deploy failsCheck Python deps, Modal auth, function timeoutspolicyengine-tools
Wrong Modal workspacemodal profile activate policyenginepolicyengine-tools
404 on Vercel URLWait 30s for propagation, check Vercel dashboardpolicyengine-tools
API returns errorsCheck Modal logs: modal app logs DASHBOARD_NAMEpolicyengine-tools
Hash sync brokenCheck postMessage calls in embedding.tspolicyengine-tools

Agents 8

Thinking Mode

IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:

  1. What the dashboard needs to do
  2. Which PolicyEngine variables and API endpoints are relevant
  3. What components and charts are needed
  4. 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:

  1. Skill: policyengine-tools
  2. Skill: policyengine-design
  3. Skill: policyengine-us (if US dashboard)
  4. Skill: policyengine-uk (if UK dashboard)
  5. Skill: policyengine-api
  6. Skill: policyengine-api-access
  7. Skill: 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:

  1. 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.

  2. 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.
  3. 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.

  4. 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 by axes in that same call. A Modal backend calculates them only when Step 4 gives custom-modal. Presets come from the same place as the custom inputs, so both use one model release. On policyengine-api, the presets' precompute script calls the dashboard API's household calculation (policyengine-api-access, "From a dashboard's precompute script"). On custom-modal, it runs the backend's pinned policyengine release 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 the precomputed data 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 in axes, 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

  1. precomputed / precomputed-csv — the inputs are a small, fixed set known before deploying
  2. policyengine-api — the dashboard API's four operations give each result as they are: household calculations (every charted input in axes in 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)
  3. 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 precomputed until it ships
  4. 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 patterndata_pattern value
Pattern A: Precomputed JSONprecomputed
Pattern A: Precomputed CSVprecomputed-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.0 and worker_memory: 2048 (a US household calculation peaked at 1.4 GB);
  • population endpoints keep worker_memory_measured: false until a measured run sets the size;
  • every endpoint has a worker_max_containers, and cost_plan.worst_hour is 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_exceptions with 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 ChartContainer patterns 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:

  1. Dashboard overview - name, purpose, audience
  2. Zone path - where it mounts on policyengine.org
  3. Data pattern - which pattern and why
  4. Components - list of inputs, charts, and metrics
  5. API endpoints - what data is needed
  6. Test plan - key acceptance criteria
  7. 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_path is set and follows /us/<name> | /uk/<name> | /<name> convention
  • Every chart has a component_ref pointing 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 reason explains why policyengine-api is insufficient
  • If custom-modal, architecture: gateway-polling is set
  • If custom-modal, backend_files section lists all 4 files (_image_setup, app, simulation, gateway)
  • If custom-modal, each endpoint has long_running, worker_timeout, worker_memory, worker_cpu and worker_max_containers
  • If custom-modal, cost_plan answers 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:

  1. The approved plan's requirements
  2. The correct project structure for the chosen data pattern
  3. What files need to be created and in what order
  4. 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:

  1. Skill: policyengine-tools
  2. Skill: policyengine-design
  3. Skill: policyengine-standards
  4. Skill: policyengine-api-access (if data_pattern: policyengine-api, or a custom backend that calls the Public API)
  5. 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.yaml file 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 name
  • dashboard.country - determines which PE packages to use
  • dashboard.zone_path - REQUIRED. Drives basePath in next.config.ts and host rewrites. If missing, STOP and ask the user — don't guess
  • data_pattern - determines backend structure
  • tech_stack - confirms fixed stack choices
  • components - 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:

  1. precomputed / precomputed-csv — if parameter space is finite
  2. policyengine-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)
  3. 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_containers set, 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.md to README.md, filled from the plan's cost_plan;
  • if the plan uses PR preview apps, add templates/stop-preview-app.yml as .github/workflows/stop-preview-app.yml;
  • make sure .gitignore lists .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: Implement comment 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.yaml is included in the repo
  • dashboard.zone_path from plan is written as basePath in next.config.mjs
  • next.config.mjs exports a FUNCTION taking phase (not a plain object)
  • assetPrefix is phase-gated: undefined in dev, /_zones/<dashboard.name> in builds
  • vercel.json has framework: 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.md follows existing applet patterns
  • package.json has all required dependencies (Next.js, Tailwind v4, ui-kit)
  • globals.css has @import "tailwindcss" + @import "@policyengine/ui-kit/theme.css"
  • postcss.config.mjs exists with @tailwindcss/postcss plugin
  • 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.json auto-installs the dashboard-builder plugin
  • vercel.json is configured for frontend deployment
  • Feature branch is created and pushed
  • app/icon.svg exists (PE logo, basePath-safe via App Router)
  • layout.tsx metadata does NOT set icons: { icon: '/favicon.svg' } (that path isn't basePath-prefixed and would collide with the host behind policyengine.org)
  • Header uses logos.whiteWordmark or logos.tealWordmark (not text-only)
  • Makefile has correct targets for the data pattern
  • make dev uses port range 4000-4100 (not random, not hardcoded 3000)
  • If custom-modal: make dev deploys 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.py has no package imports at module level
  • If custom-modal: app.py only imports modal at module level
  • If custom-modal: simulation.py has 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_install includes "pydantic" (simulation.py uses it at module level)
  • If custom-modal: worker image uses .add_local_file() for simulation.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, memory and max_containers, keep min_containers=0, and population workers keep their memory # cost-ok: RS1 TODO declaration until a measured run replaces it
  • If custom-modal: CI runs the cost check, and README.md has the "Running costs" section filled from the plan's cost_plan
  • If custom-modal: frontend uses polling (refetchInterval), not synchronous await
  • If custom-modal: /status endpoint 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 @theme in CSS)
  • Omit postcss.config.mjs — it IS required for Tailwind v4 (the @tailwindcss/postcss plugin 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 accepts var() directly.

Thinking Mode

IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:

  1. Which data pattern the plan specifies (precomputed, policyengine-api, or custom-modal)
  2. What endpoint interfaces or data files are needed
  3. How to type the API contract for the frontend
  4. 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:

  1. Are the inputs a small, fixed set known before deploying? → Use Pattern A/D (precomputed / precomputed-csv)
  2. Can the dashboard API give each result as it is? It calculates households (every charted input in axes in 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)
  3. 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
  4. 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:

  1. Skill: policyengine-tools
  2. Skill: policyengine-api-access (if policyengine-api pattern, or a custom-modal backend that calls the Public API)
  3. Skill: policyengine-us (if US dashboard)
  4. Skill: policyengine-uk (if UK dashboard)
  5. Skill: policyengine (if custom-modal pattern — required for simulation mechanics and correct reform parameter paths)
  6. Skill: policyengine-cloud-costs (if custom-modal pattern)

Input

  • A scaffolded repository with plan.yaml and skeleton API client
  • The plan specifies a data_pattern chosen from the patterns defined in the policyengine-tools skill (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 PolicyEngineApiError from 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.

FilePurposeModule-level imports
backend/_image_setup.pyStandalone snapshot function — runs during image buildNone (all inside function body)
backend/app.pyModal app + function decoratorsOnly modal
backend/simulation.pyPure business logictop-level policyengine (population — managed path) + policyengine_us/_uk Simulation (household); captured in image snapshot
backend/modal_app.pyLightweight 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.rates has keys 1-7, not 0-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 .amount or .rate after the index. E.g., gov.irs.credits.eitc.max[0].amount, NOT gov.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:

  1. Find the YAML file in the policyengine-us (or policyengine-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
    
  2. 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.
  3. 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

ContextDefault timeoutMax timeoutNotes
@app.function(timeout=...)300s86,400s (24h)Set per-function
modal serve dev gateway~150sNot configurableReturns HTTP 303 on timeout
modal deploy prod gateway~150sNot configurableReturns 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.txt or pip install — always use uv for 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:

  1. The component specifications from the plan
  2. Whether @policyengine/ui-kit already provides the component
  3. How app-v2 implements similar components
  4. Correct use of design system tokens
  5. 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:

  1. Skill: policyengine-tools
  2. Skill: policyengine-design
  3. Skill: 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.yaml with 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-500 or bg-teal-500 for primary teal
    • hover:bg-teal-600 or hover:bg-primary for hover states
    • text-foreground for body text
    • text-muted-foreground for muted text
    • bg-background for backgrounds
    • border-border for borders
  • Chart colors: use CSS vars directly — fill="var(--chart-1)" for Recharts

Typography

  • Font: Inter (loaded via next/font/google in app/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-bold for 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-lg classes

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:

  1. If ui-kit provides it → import and use it directly (e.g., MetricCard, Button, DataTable, PEBarChart)
  2. If ui-kit doesn't have it but shadcn/ui does → use the shadcn/ui primitive styled with semantic classes
  3. 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):

  1. 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 a max-w-5xl container. Page-level titles inside content are <h2>.
  2. 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 with role="tablist" for single-route dashboards; Links with aria-current for multi-route ones.
  3. Content card — bg-white rounded-lg shadow-md p-6 on a bg-gray-50 page, same max-w-5xl container 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 mobile
  • sm: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-kit to 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.log statements 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.ts or postcss.config.js — Tailwind v4 uses @theme in CSS
  • Rebuild components that exist in @policyengine/ui-kit
  • Use getCssVar() — it no longer exists. SVG accepts var() directly.

Thinking Mode

IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:

  1. How data flows from user input to API call to rendered output
  2. Whether request/response types are consistent across the boundary
  3. 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

  1. Skill: policyengine-tools
  2. Skill: policyengine-design
  3. Skill: policyengine-api (if data_pattern: policyengine-api)
  4. Skill: policyengine-api-access (if data_pattern: policyengine-api)

Input

  • Repository with implemented frontend components (from frontend-builder)
  • Repository with API client stubs or custom backend (from backend-builder)
  • plan.yaml for 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:

  1. Mutation hooks for on-demand calculations (user clicks "Calculate")
  2. Query hooks for auto-fetching data (loads on mount or input change)
  3. 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

  1. 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

  1. Skill: policyengine-tools
  2. Skill: policyengine (if data_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 .amount or .rate sub-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: through 7:) → FAIL
  • eitc.max[0] without .amount suffix on a bracket scale → FAIL
  • rates[0] without .rate suffix 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

  1. Skill: policyengine-tools
  2. Skill: 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.ts match 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 ResponsiveContainer wrapper
  • Uses CSS variables for colors (var(--chart-N)), not hardcoded hex
  • Axes use appropriate formatting
6. Multi-zone plan completeness
  • dashboard.zone_path is present in plan.yaml
  • Value starts with / and matches /us/<name>, /uk/<name>, or /<name>
  • Kebab portion of zone_path matches dashboard.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

More from PolicyEngine/policyengine-claude

All 9