
analysis-tools
Policy analysis and research - impact studies, dashboards, notebooks, and visualizations
- version
- 4.1.1
- GitHub stars
- 31
Install
/plugin install analysis-tools@policyengine-claudeRun it inside Claude Code after adding the marketplace with /plugin marketplace add PolicyEngine/policyengine-claude.
Contents
Commands 9
Analyze Policy
End-to-end policy analysis pipeline. Takes a reform (bill number, URL, or description), classifies whether it's modelable, finds prior PE scores, runs the microsim, compares to priors, and — if the run mismatches — diagnoses which calibration target is likely driving the gap.
The structured analogue of /encode-policy-v2 for the analyst persona: less about implementing new programs, more about running impact analysis on a proposed reform and validating it against PolicyEngine's published baseline.
Arguments
$ARGUMENTS— One of:- State + bill number:
UT SB60,RI H7127 - Federal bill:
US HR1234orUS S5678 - URL: a direct bill/proposal/order URL
- Description: natural-language reform (
"ARPA-style federal CTC expansion: $3,600/$3,000, full refundability")
- State + bill number:
Flags:
--country {us|uk|ca}(defaultus)--horizon <spec>— time horizon for the microsim. When omitted, the pipeline prompts the analyst at the start of Phase 4. Accepts:1— single-year (default entry: current year), fastest, one API call. Uses/economy/{policy}/over/{baseline}?time_period=YYYY.10— full 10-year (2026-2035 for the US; a UK window ends by 2032, the last year/uk/metadatalists). Uses the native/economy/{policy}/over/{baseline}/budget-window?start_year=YYYY&window_size=Nendpoint, and microsim-runner falls back to single-year calls if the batch fails or keeps computing past 45 minutes.custom:YYYY[,YYYY...]— specific years (e.g.custom:2026,2029,2030,2035) — submitted as N single-year calls.custom:YYYY-YYYY— year range (e.g.custom:2026-2028) — mapped tobudget-windowwithstart_year+window_size.
--year YYYY(only used with--horizon 1; default current year)--mode {api|local}(defaultapifor microsim execution)--dataset <name>: run on a named dataset from policyengine.py's bundle, such aspopulace_us_2024_acs_localorenhanced_frs_2024_25. The API selects data by region and takes no dataset override, so microsim-runner takes the local path for any explicitly named dataset whatever--modesays. Omit the flag to let the API select the region's data.--skip-microsim(process-test mode — stops at Stage 5, predicts from prior anchor)--auto-investigate(if Stage 5 returns INVESTIGATE, auto-run top calibration hypothesis)--write-report PATH(default/tmp/analyze-policy-{policy_id}.md)--log-to <dest>[,<dest>...](override auto-routing; see Phase 8). Examples:--log-to archive,--log-to "archive,issue:microcosm",--log-to draft:policyengine-app-v2/app/src/data/posts/articles/arpa-ctc.md. Without it, report-logger files US and UK data issues inPolicyEngine/microcosm, or a UK run's inPolicyEngine/policyengine-uk-datawhen the run named an Enhanced FRS or FRS file.--no-log(skip Phase 8 entirely — write the/tmpreport only)--auto-confirm(skip confirmation prompts before opening GitHub issues; only honor in non-interactive contexts)
Phase 0 — Horizon prompt (when --horizon not passed)
Before Phase 4 (microsim), if the analyst didn't pass --horizon, prompt:
How many years should the microsim cover?
[x] Single year — fastest, one API call, yields yr-1 cost only.
[ ] Full 10 years — native budget-window endpoint, server-side queued, yields real 10yr cost.
[ ] Custom — comma-separated years or a range (e.g. "2026,2029,2030,2035" or "2026-2028").
Choice (default = single year):
The prompt is skipped in headless / --auto-confirm contexts (default = single year in those).
Critical: with --horizon 1, the archive reports yr-1 cost only. Do NOT compute a naive yr1 × 10 10-year cost. Naive extrapolation across regime shifts (2030 OBBBA snap-backs, inflation-indexed baselines, phase-out threshold changes) is misleading — the analyst asked for one year, give them one year. A 10-year cost is only valid when the microsim actually ran all 10 years.
With --horizon 10 or a custom year range, microsim-runner scores the years in one budget-window batch, or in single-year calls if the batch fails or stalls. A custom list of separate years gets one single-year call per year. The report shows the actual per-year cost table plus the summed multi-year cost.
Workflow
┌──────────────────────────────────────────────────────────────────────┐
│ /analyze-policy <reform> │
└──────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────┐ Stage 1: Understand
│ policy-text- │ ───► Fetch text, extract provisions
│ researcher │
└──────────────────┘
│ provisions[]
▼
┌──────────────────┐ Stage 2a: Map provisions to parameters
│ parameter- │ ───► YAML paths + reform-dict snippets + verdicts
│ locator │
└──────────────────┘
│ verdicts[]
▼
┌──────────────────┐ Stage 2b: Classify
│ reform- │ ───► parametric | structural | not-possible
│ classifier │
└──────────────────┘
│
├── structural ──► write backlog issue, STOP
├── not-possible ──► write rationale, STOP
│
▼ parametric
┌──────────────────┐ Stage 3: Find anchors
│ prior-scores- │ ───► PE prior scores + fiscal notes + think-tanks
│ finder │
└──────────────────┘
│ anchors[]
▼
┌──────────────────┐ Stage 4: Run microsim
│ microsim-runner │ ───► Budget, poverty, distribution, geography
└──────────────────┘
│ result (or skip if --skip-microsim, predict from anchor)
▼
┌──────────────────┐ Stage 5: Compare
│ reform- │ ───► PASS | PASS-WITH-NOTES | PASS-WITH-CORROBORATION | INVESTIGATE | BLOCKED
│ comparator │
└──────────────────┘
│
├── PASS ──► write report, EXIT
│
├── PASS-WITH-NOTES (or near-INVESTIGATE on external benchmarks)
│ │
│ ▼ trigger Stage 5.5
│ ┌──────────────────┐ Stage 5.5: Corroborate
│ │ model- │ ───► Run external mirror-shape reforms
│ │ corroborator │ through our model; check agreement
│ └──────────────────┘
│ │
│ ├── CORROBORATED ──► upgrade verdict to PASS-WITH-CORROBORATION
│ ├── PARTIAL ─────► stay at PASS-WITH-NOTES
│ └── FAILED ──────► escalate to INVESTIGATE
│
▼ INVESTIGATE
┌──────────────────┐ Stage 6: Diagnose
│ calibration- │ ───► Ranked hypotheses + tests to run
│ diagnostics │
└──────────────────┘
│
▼
┌──────────────────┐
│ reform-describer │ ───► Mechanical write-up (provisions table)
└──────────────────┘
│
▼ final report at --write-report path
Phase 1 — Understand the policy
Parse $ARGUMENTS to determine input type. Invoke policy-text-researcher:
policy-text-researcher input={
state: <state>, bill_number: <bill>, |
bill_url: <url>, |
description: <description>
}
country=<country>
Returns provisions[]. If empty (couldn't fetch text), STOP and report.
Phase 2 — Classify modelability
For each provision in parallel, invoke parameter-locator. Collect verdicts. Then invoke reform-classifier:
reform-classifier
provisions=<provisions>
parameter_locator_verdicts=<verdicts>
jurisdiction=<jurisdiction>
Branch on classification:
not-possible— write/tmp/{policy_id}-not-modelable.mdwith rationale. EXIT.structural— write/tmp/{policy_id}-structural-backlog.mdwith the model-change estimate. Optionally open a GitHub issue in the country's model repo (PolicyEngine/policyengine-us,-ukor-canada) if--open-issue. EXIT — and surface the unblock path:/implement-structural <issue>implements the model extension (baseline change for enacted law, gov/contrib for proposals) and records the re-run that completes this analysis.parametric— proceed.
Capture reform_dict from the classifier output.
Phase 2c — Enactment × baseline reconciliation (required, evidenced)
Getting this wrong silently corrupts everything downstream — the sign of the score, the publication framing, and dedup. Establish BOTH facts independently, with evidence, and never infer either from news coverage:
1. Legal status, from the primary source only. The bill page /
session-law record, not articles: enacted (act number + signing date),
proposed (chamber status), ballot (election date), or hypothetical.
News framing misleads systematically — HI Act 24 was covered as news in
July, eight weeks after signing; a "new tax" headline says nothing about
enactment. Record per-provision effective dates: enacted-but-not-yet-
effective (Act 24's TY2027 tables) is still ENACTED.
2. Model status, probed empirically per provision. Read the deployed API's parameter values at the effective date (metadata endpoint) and master. Do not assume from the model's version date.
The intersection sets the run configuration:
| Legal × model | Consequence |
|---|---|
| enacted + value in deployed baseline | Score the PRIOR-LAW COUNTERFACTUAL and negate signs. Tracker publication REQUIRES baseline_json. (GA HB463) |
| enacted + on master only | deployed-model-lag — existing exit; wait for release. |
| enacted + absent everywhere | Model is behind enacted law → /implement-structural (schema gap) or a baseline value-update PR (schema fits, value missing). Never score around it. |
| proposed/ballot + parametric | Standard reform-vs-current-law. If the probe finds the "proposal" already in the baseline, your legal status is wrong — go back to step 1. |
| any + prior law schedules changes for the scoring year | The baseline for marginal effect is the SCHEDULE, not today's visible rate (GA: 5.09% scheduled, not 5.19% current). State both framings when external scores use the other one. |
3. Version dedup. If a prior analysis or tracker entry covers an earlier VERSION of the bill (a committee draft, a pre-enactment score), the current version supersedes it — record the superseded entry id so publication replaces rather than duplicates (HI: the stale HD1 draft).
Record the block in the report frontmatter:
enactment_reconciliation:
legal_status: enacted # + act number, signed date, source URL
effective: {rates: 2027-01-01}
per_provision:
- {provision: top_bracket, deployed: absent, master: absent, evidence: "live /calculate probe rejected rates.single[12]"}
scoring_frame: counterfactual-negated | reform-vs-current-law
publication_requirements: {baseline_json: required | none, supersedes: <entry-id> | none}
Phase 3 — Find anchor scores
Invoke prior-scores-finder (loads the policyengine-prior-scores skill internally):
prior-scores-finder
reform=<provisions+description>
jurisdiction=<jurisdiction>
Returns anchors[]. If empty, surface "novel reform — no PE prior found" in the final report. The comparison will rely on fiscal notes / think-tank scores.
Pre-registration rule (hard ordering constraint): Phase 3 MUST complete before Phase 4 starts — comparisons are found first, then the score is run, never the other way around. Phase 3's output is the frozen benchmark registry: the complete set of sources, each with its extracted magnitude, recorded with a freeze timestamp before any PE number for this reform exists. After Phase 4 begins:
- No agent may add, drop, reweight, or reinterpret registry sources with knowledge of our result. The comparator compares against the registry as frozen; model-corroborator picks mirror candidates only from it.
- The ONLY way to expand the registry (e.g. a BLOCKED verdict from
incomplete tier coverage) is a blind re-run of
prior-scores-finder: invoke it with the same Phase 1/2 inputs it would have received originally — never the microsim result, the comparator's deltas, or any hint of our number. Merge its output into the registry and mark those entriesregistered_post_score_blind: truein the report. - The report's anchors section is titled "Prior anchors (pre-registered before scoring)" and carries the freeze timestamp, so a reader can verify the benchmarks were not selected to fit the score.
Phase 3b — Blind outcome prediction (parallel with Phase 3)
Invoke outcome-predictor in predict mode, in parallel with
prior-scores-finder — it MUST complete on provisions alone, before any
microsim result or anchor score exists:
outcome-predictor mode=predict
provisions=<provisions>
jurisdiction=<jurisdiction>
year=<year>
Pass ONLY those inputs — no anchors, no archive hits, no scores. Hold the returned predictions for Phase 5.7. Never re-run predict mode after results exist.
Phase 4 — Run the microsim
If --skip-microsim is set, skip this phase — Stage 5 will use the anchor as a predicted result. Otherwise invoke microsim-runner:
microsim-runner
reform_dict=<reform_dict>
jurisdiction=<jurisdiction>
year=<year>
mode=<mode>
dataset=<--dataset value, or omitted for the certified default>
Returns result. If mode=local, this may take 5-10 minutes per run.
Phase 5 — Compare to anchor
Invoke reform-comparator:
reform-comparator
microsim_result=<result> // or null if --skip-microsim
prior_anchor=<anchors[anchors.preferred_anchor_index]>
Returns verdict:
PASS/PASS-WITH-NOTES— proceed to write-up.INVESTIGATE— invokecalibration-diagnosticswith the deviation signature.
The comparator itself may invoke model-corroborator (Stage 5.5) at the end of its Step 2b if external benchmarks don't directly anchor the reform — see Phase 5.5 below.
Phase 5.5 (conditional) — Model corroboration
Triggered automatically by reform-comparator Step 2c when Step 2b yields fewer than 2 external sources within ±25%. Invoke model-corroborator:
model-corroborator
our_reform_dict=<reform_dict>
our_jurisdiction=<jurisdiction>
benchmark_sources=<thinktank_scores from prior-scores-finder>
our_microsim_result=<result>
our_baseline_policy_id=<typically 2 for US>
The corroborator picks 1-2 closest-shape externally-scored reforms, builds mirror reform-dicts (and a baseline policy if the external source uses a different baseline schedule like TCJA-extension), submits to the PE API, polls for completion (~6-10 min per mirror in parallel), and reports per-candidate agreement.
Outcomes:
CORROBORATED(≥2 candidates within ±25%) — comparator upgrades verdict toPASS-WITH-CORROBORATION.PARTIAL-CORROBORATION(1 corroborated with explainable drift on others) — stay at PASS-WITH-NOTES.CORROBORATION-FAILED(0 candidates corroborated) — force escalation to INVESTIGATE.NO-CORROBORATION-POSSIBLE(no suitable candidates) — fall back to Step 2b verdict.
Skipped when --skip-microsim is set (no original-reform microsim to anchor against).
Phase 5.6 — Data calibration check (always, cheap)
Every parametric run verifies that the underlying microdata is well-calibrated for the variables the reform touches, using the live Microcosm calibration dashboard API (no auth, reads the current release from Hugging Face):
BASE = https://calibration-diagnostics.vercel.app/calibration/dashboard/api/microcosm
GET {BASE}/target-diagnostics?source=<source> # per-target fit
GET {BASE}/target-investigation?target=<id> # drill one bad target
Check three rings, from the reform's variable outward. Checking only ring 1 is the classic miss: a reform's cost usually depends on the JOINT distribution of its variable with other income, and the primary marginal can calibrate perfectly while the interaction is off.
-
Ring 1 — the primary variable's marginals. Map the reform's domain to calibration sources (check the run's
sourceslist for what's available): Social Security →ssa; Medicaid/ACA/Medicare →cms_medicaid/cms_aca/cms_medicare; TANF →hhs_acf_tanf; income tax / credits →irs_soi,jct,cbo; state income tax →state_income_*. -
Ring 2 — the mechanism's other inputs. List every variable that enters the formula the reform changes, and check their targets too. SS-benefit taxation depends on combined income, so dividends, taxable interest, and pensions (
irs_soi) are load-bearing; a CTC phase-in depends on earnings; a SNAP change on rents and deductions. These are usuallyirs_soitable targets, including by state and AGI band. -
Ring 3 — the interacted quantity itself (best single check). Search the targets for the DOWNSTREAM outcome the reform directly reprices — e.g.
taxable_social_security_amountfor SS-taxation reforms,eitc_amountfor EITC reforms, itemizer counts for deduction caps. When such a target exists, it validates the joint distribution end-to-end through the actual formula, and its relative error bounds the data-side bias of the score — weight it above every marginal. Worked example: for HR 904,ssatotal benefits calibrate to −0.2% (ring 1 ✅) whileirs_soi ... taxable_social_security_amountruns −10.9% (ring 3 ⚠) — the score's operative base is ~11% low in the data even though the headline marginal is nearly exact. -
Fetch the matching targets and summarize per ring: count checked, share within tolerance, and the worst
relative_errorvalues with target names. When no ring-3 target exists, SAY SO — "marginals within tolerance; joint distribution untargeted" is materially weaker evidence than a passing ring-3 check, and the note must not imply otherwise. -
Add a
calibration_checkblock to the frontmatter:calibration_check: release_id: populace-us-2024-... sources_checked: [ssa, irs_soi] rings: primary: {targets: 6, worst: -0.002} mechanism_inputs: {targets: 14, worst: {name: "...ordinary_dividends_amount@2024", relative_error: -0.189}} interacted: {name: "irs_soi...taxable_social_security_amount@2024", relative_error: -0.109} note: "Benefits marginal near-exact; taxable-SS (the repriced base) runs 10.9% low — score biased low on the data side by up to ~11%." -
Interpretation is INFORMATIVE, not a gate: a poorly calibrated but reform-relevant target does not change the comparator verdict on its own, but the report's Comparison section MUST mention it (it often explains drift vs external anchors), and the reform-describer threads the note into the write-up. Escalation: if a directly-load-bearing target — the primary variable OR the ring-3 interacted quantity — is off by |relative_error| > 25%, recommend INVESTIGATE treatment in the discussion even when headline numbers match anchors. A ring-3 error in the 10-25% band belongs in the report's uncertainty discussion as an explicit data-side bias bound on the score.
API unreachable → record calibration_check: {status: unavailable} and move
on; never block the run on this.
Phase 5.7 — Independent interrogation (always)
Invoke outcome-predictor in interrogate mode with its own Phase 3b
predictions plus the actual results:
outcome-predictor mode=interrogate
predictions=<phase 3b output>
microsim_result=<result>
comparator_verdict=<verdict>
provisions=<provisions>
Returns {confirmed, notable_findings, challenges, red_flags_fired}.
notable_findings— counterintuitive-but-correct results (e.g. a senior tax cut that leaves senior poverty unchanged because the taxation thresholds sit above poverty-level incomes). These flow into the report (Phase 7), the archive entry, and any downstream publication brief — they are typically the most publishable part of the analysis.challenges/red_flags_fired— divergences the interrogation could not explain from statute. Add them to the INVESTIGATE hypothesis list. If the comparator said PASS but a challenge is material to a headline number, apply the agent'sverdict_recommendation(usually PASS → PASS-WITH-NOTES) and record why.
Phase 6 (conditional) — Calibration diagnosis
Only if Stage 5 returned INVESTIGATE. Invoke calibration-diagnostics:
calibration-diagnostics
deviation_signature=<comparator.deviation_signature>
reform=<provisions+reform_dict>
jurisdiction=<jurisdiction>
anchor=<chosen anchor>
dataset=<microsim result's dataset>
Returns ranked hypothesis list + recommended next test.
If --auto-investigate is set, automatically run the top hypothesis (e.g., perturb takeup rate, rerun microsim, recompute comparison). Otherwise just append to the report.
Phase 8 — Log the report (NEW)
After Phase 7 has assembled the report at --write-report PATH, invoke report-logger:
report-logger
report_path=<write-report path>
frontmatter=<structured metadata from prior stages>
log_to=<--log-to value, or empty for auto-routing>
no_log=<--no-log flag>
command_args=<original $ARGUMENTS>
The frontmatter metadata must include the publication inputs description
(reform-describer's 1-paragraph summary) and reform_dict (the exact validated
JSON from Phase 2, omitted for structural/not-possible verdicts) — downstream
automation (the CRM publication router) parses these from the archived report.
It also carries dataset_at_run, the dataset from microsim-runner's result:
report-logger reads it to pick the repo for a UK data issue.
Destination routing. report-logger owns the runtime prompt, the pre-selected defaults by verdict, the context-aware shortlist (which options to surface based on which repo the analyst is in), the body preview flow, and archive path resolution. See report-logger.md sections "Routing — runtime prompt, context-aware" and "Path resolution" for the source of truth. This document only summarizes the outputs the analyst sees:
The Phase 8 step also prints the destination summary to the user as the final output:
[/analyze-policy] Run complete.
Archive: ~/.policyengine/analyses/2026-06-19-us-arpa-ctc-restoration.md
Issues opened: (none — verdict was PASS-WITH-NOTES)
Run-id: 97759
Phase 7 — Write report
Invoke reform-describer for the mechanical provisions write-up. Assemble the final report at --write-report PATH:
# Analysis: <reform title>
## Reform
<reform-describer output: description + provisions table>
## Classification
**Verdict:** parametric (high confidence)
**Reform dict:** ```json {reform_dict} ```
## Prior anchors (pre-registered before scoring)
*Registry frozen: <timestamp> — before any PE number for this reform existed.*
| Prior | Year | Framing | Cost | Poverty Δ | URL |
|---|---|---|---|---|---|
| Restoration of ARPA CTC | 2023 | current-law baseline, single-year, static | $100B/yr | -37% child | ... |
## Our microsim result
| Metric | Value |
|---|---|
| 10yr cost | $1,450B |
| Child poverty Δ | -34.1% |
| ... | ... |
## Comparison
**Verdict:** PASS
Headline metrics within tolerance band. Every benchmark row states its
framing and commensurability; each agreement carries a one-line "why this
agreement is meaningful" (matched frame/year/scope, or the normalization
applied — e.g. a separate framing-matched counterfactual run).
Incommensurable sources appear with the reason and are excluded from the
agreement count in both directions.
## Independent review (blind prediction vs result)
**Predicted before results:** budget ~-$100B/yr; gains concentrated in
deciles 6-10; senior poverty ~unchanged (thresholds sit above
poverty-level incomes).
**Notable findings** (counterintuitive but correct — publication assets):
- <headline>: <mechanism explanation> (<evidence>)
**Challenges:** none | <expected vs actual, hypotheses, verdict
recommendation>
## (if INVESTIGATE) Calibration diagnosis
**Top hypothesis:** Non-filer CTC takeup
**Test:** Set takeup=0.95 and rerun
**Open issues:** ...
## Methodology
- Microsim mode: api
- Year: 2026
- Dataset: <the microsim result's dataset and data_version>
- Static analysis (no behavioral response)
Stages can run independently
For debugging or partial-run scenarios:
--only-classify— runs Phases 1-2 only.--from-anchor— skip Phases 1-2, accept a pre-builtreform_dict, start at Phase 3.--no-write-report— return the structured JSON instead of writing markdown.
Hand-off to the legislative tracker
The state-legislative-tracker's /encode-bill command is a superset of /analyze-policy that ALSO writes results to Supabase. Use /encode-bill instead when working inside that repo.
Related commands
/encode-policy-v2— implements a NEW benefit program (orchestrates rules-engineer, test-creator, etc.)./analyze-policyanalyzes an existing-or-near-existing reform./review-program— audits an existing PR./score-bill— the legislative-tracker variant focused on bills.
Skills loaded
This command loads (with their plugin paths for clarity):
- the country model skill, mapped from
--country:usloadspolicyengine-usatskills/policyengine-us/,ukloadspolicyengine-ukatskills/policyengine-uk/,caloadspolicyengine-canadaatskills/policyengine-canada/ policyengine-prior-scoresatskills/policyengine-prior-scores/— anchor indexpolicyengine-calibration-diagnosticsatskills/policyengine-calibration-diagnostics/— sensitivity registrypolicyengineatskills/policyengine/— microsimulation execution patternspolicyengine-research-lookupatskills/policyengine-research-lookup/— broader research discoverypolicyengine-writingatskills/policyengine-writing/— style for the final report
Agents invoked
policy-text-researcherparameter-locator(per-provision, parallel)reform-classifierprior-scores-findermicrosim-runner(unless--skip-microsim)reform-comparatormodel-corroborator(Stage 5.5 — only when no exact-shape external comparator exists; skipped on--skip-microsim)calibration-diagnostics(only if Stage 5 INVESTIGATE)reform-describerreport-logger(unless--no-log)
Reform Pipeline: $ARGUMENTS
The single-command version of the CRM research pipeline: discover → score → verify → publish, with confirmation gates inline instead of the Command Center. Use this when you want to drive the whole chain yourself for a topic ("healthcare", "child tax credit", "Utah income tax") rather than waiting for the newsroom's scheduled discovery.
Arguments
$ARGUMENTS should contain:
- Topic or reform (required) — a policy area to scan for ("healthcare", "social security taxation"), OR a concrete reform/bill id ("UT SB60") to skip discovery and go straight to scoring.
- Options:
--country {us|uk|ca}(defaultus)--horizon {1|10}(default1; 10-year adds 15-25 min of API compute)--publish {auto|tracker|dashboard|none}(defaultauto— route by the standard rules;nonestops after the verified analysis)--auto-confirm— skip the confirmation gates (headless / CI use)--dry-run— publication workflows run withdry_run=true(compute + validate, no Supabase writes / PRs / pushes)
Examples:
/reform-pipeline healthcare
/reform-pipeline "social security taxation" --publish dashboard --horizon 10
/reform-pipeline "UT SB60" --publish tracker --dry-run
Phase 0 — Clarify (skipped with --auto-confirm or when fully specified)
Before scanning anything, resolve what's underspecified with ONE AskUserQuestion round (ask only the questions the arguments left open — a concrete bill id leaves nothing to ask):
- Sources (multi-select): where should discovery look?
- CRM newsroom (needs
CRM_API_URL+CRM_API_TOKEN; only offer when reachable) - Web news search (last 14 days)
- Bill trackers (congress.gov federal search; state bills named in coverage)
- Default: all available.
- CRM newsroom (needs
- Scope (when the topic doesn't imply it): federal, a specific state, or either — this changes both where to look and the eventual publication route (state → tracker, federal → dashboard).
- Publication intent (when
--publishwasn't passed): auto-route / bill tracker / dashboard / analysis only. Sets the--publishvalue. - Recency window only if the user's phrasing suggests older material ("that bill from March") — default 14 days otherwise, don't ask.
With --auto-confirm: all available sources, either scope, auto-route,
14 days.
Phase 1 — Discover (skipped when a concrete bill/reform was given)
Find the single most prominent, concrete, MODELABLE reform in the topic:
- Scan the sources chosen in Phase 0 (parallel):
- WebSearch: recent news (window from Phase 0) on
<topic> bill OR reform OR tax OR benefit <country>— prefer coverage that names a specific bill. - Recency means COVERAGE recency, ranked newest-first. Verify each candidate has coverage dated inside the window, and prefer items covered in the last 72 hours. Search engines surface evergreen pages about months-old enacted bills — an enactment date outside the window is fine (an old bill back in this week's news is timely; see HI SB3125, signed in May, covered heavily in July), but a candidate whose most recent coverage is weeks old is NOT a discovery, it is archaeology. Include the newest coverage date next to each candidate at Gate 1.
- For US topics, also check bill trackers: congress.gov search for the topic (federal), and note any state bills the coverage names.
- If the CRM newsroom API is reachable (
CRM_API_URL+CRM_API_TOKENenv set), query it for recent high-relevance items on the topic and merge them into the pool. Optional — absence is normal outside the CRM environment.
- WebSearch: recent news (window from Phase 0) on
- Extract the reform using the same conservatism as the CRM's discovery:
a concrete parametric change (rates, thresholds, credit amounts,
eligibility limits). Prefer a bill identifier (
US HR904,UT SB60) over a prose description — the scoring stage fetches bill text itself. Structural proposals (brand-new programs, novel administrative rules) are NOT modelable — say so and stop rather than burning a scoring run. - Dedup before proposing (both checks):
/prior-analysis <reform>— did an /analyze-policy run already score this?- The public bill tracker (read-only): does
state-legislative-tracker'sresearchtable already cover it? (Query the tracker Supabase anon endpoint or check policyengine.org/us/bill-tracker.) Already covered → report where, and stop.
Gate 1 (skip with --auto-confirm): present the discovered reform —
title, the exact scoring command, source links — via AskUserQuestion:
proceed / pick a different candidate / stop.
Phase 2 — Score & verify
Run the full scoring pipeline inline:
/analysis-tools:analyze-policy "<reform>" --country <country> --horizon <horizon> --auto-confirm
This covers: bill text → provisions → parameter mapping → classification → prior scores + blind outcome prediction (the independent reviewer predicts expected outcomes from the reform text alone, before any numbers exist) → microsim → comparison against external anchors → the Phase 5.6 data-calibration check (is the Microcosm release well-calibrated for this reform's variables?) → the Phase 5.7 interrogation (predictions vs results: counterintuitive-but-correct findings become publication assets; unexplained divergences become INVESTIGATE leads) → report + archive.
Branch on the verdict:
- PASS / PASS-WITH-NOTES / PASS-WITH-CORROBORATION → continue to Phase 3.
- INVESTIGATE → report the calibration diagnosis and STOP — unverified
numbers never publish. Follow up in the data issue report-logger files (
usanduk→PolicyEngine/microcosm; aukrun on a named Enhanced FRS or FRS file →PolicyEngine/policyengine-uk-data; none forca, which has no microdata). - structural → report why and STOP publication. Offer the unblock:
/implement-structural <backlog issue>opens the policyengine-{country} model PR; once merged AND deployed, re-run this pipeline — the frozen benchmark registry from this run carries over. - not-possible → report why and STOP.
Phase 3 — Route & publish
Apply the standard routing rules (identical to the CRM publication router):
- non-US, federal US, missing reform_dict, |10yr| ≥ $50B, |yr1| ≥ $10B, or heavy coverage → dashboard
- otherwise (routine state bill) → bill tracker
--publish tracker|dashboardoverrides;--publish nonestops here.
Publication preconditions (from Phase 2's enactment_reconciliation):
consult the block before dispatching — it says whether baseline_json is
required, and whether this publication SUPERSEDES an earlier entry (a
pre-enactment draft score); superseding means the old entry/PR gets closed
with a pointer, never left as a duplicate.
Gate 2 (skip with --auto-confirm): show the route, the literal workflow
inputs, and what "publish" means for that route (tracker: hidden in_review
entry + a bill-review PR whose merge publishes; dashboard: a new repo is
built and pushed, deploy stays manual). Confirm before dispatching.
Dispatch (requires gh with repo write access):
# tracker route
gh workflow run publish-reform.yml --repo PolicyEngine/state-legislative-tracker \
-f id=<slug> -f state=<state> -f label="<label>" -f reform_json='<reform_dict>' \
-f baseline_json='<prior-law counterfactual — REQUIRED whenever the analysis
frontmatter has enactment_reconciliation.publication_requirements.baseline_json:
required (enacted + already in the model baseline), so impacts carry the
bill's true effect and signs; omit for not-yet-law reforms>' \
-f title="<title>" -f description="<provisions summary>" -f tags="<tags>" \
-f source_url="<primary source>" \
-f review_notes="<markdown decision log + validation record — see below>" \
-f dry_run=<--dry-run?>
review_notes is not optional in practice — the bill-review PR is the
human approval gate, and the reviewer must be able to approve from the PR
alone. Compose it from the analysis you just ran:
- Decision log (table): baseline framing (and why, if
baseline_jsonwas used), headline framing choice, what was scored vs held out of scope, model version, route choice. - Validation record: the /analyze-policy verdict with a link to the archive entry; the benchmark table (each pre-registered external source, its framing, estimate, and delta vs ours); this run's computed number vs the analysis number with the dataset difference explained; the dry-run rehearsal link; known limitations (e.g. unscored provisions and why).
- Notable findings from the Phase 5.7 interrogation, when any exist.
Example: state-legislative-tracker PR #215 (GA HB 463).
# dashboard route
gh workflow run create-dashboard.yml --repo PolicyEngine/policyengine-skills \
-f brief="<brief + validation context (verdict, costs, anchors) + notable findings from the Phase 5.7 interrogation>" \
-f repo_name=<slug>-dashboard -f reform_json='<reform_dict>' \
-f horizon=<horizon> -f dry_run=<--dry-run?>
Then watch to completion and report the outcome links:
gh run watch --repo <repo> --exit-status
gh run download <run-id> --name publication-result --dir /tmp/pub-result
Final output
A summary the analyst can forward: the reform, the verdict with headline
numbers, the calibration-check note, what was published where (PR link /
repo link), and the remaining human step (merge the bill-review PR, or
/deploy-dashboard).
Boundaries
- One reform per run — the highest-confidence candidate. Re-run for more.
- Never publishes anything that didn't PASS verification.
- The CRM's Command Center remains the preferred entry for newsroom-discovered reforms (its dedup and audit trail persist in the manager_actions table); this command is for analyst-driven, on-demand runs.
Implement structural: $ARGUMENTS
When /analyze-policy classifies a reform as STRUCTURAL, the pipeline
stops with a backlog issue. This command picks that issue up and implements
the model extension, so the original analysis can re-run and publish.
Arguments
$ARGUMENTS should contain ONE of:
- A structural backlog issue —
policyengine-us#9051or the issue URL - A structural-backlog report path —
/tmp/<id>-structural-backlog.md - The archived structural analysis —
analyses/<date>-<id>-structural.md
Options:
--draft— open the PR as draft--no-rerun— skip recording the pending re-run command
Phase 1 — Read the spec and choose the mode
Read the backlog issue/report. It carries the classifier's findings: which parameters/variables are missing, the empirical verification, and the size estimate. Then choose:
- Enacted law → BASELINE change. The statute is current law; the model
is simply behind. Edit
policyengine_us/parameters/...(and variables if needed) directly — e.g. HI SB3125/Act 24: append bracket[12]@ 13% to the fivegov.states.hi.tax.income.rates.*scales, inert before 2027-01-01. NEVER implement enacted law undergov/contrib/. - Proposal → CONTRIB reform. Not yet law; implement as a
gov/contrib/factory reform with anin_effecttoggle — delegate to/encode-reform, which owns that pattern, and stop here.
Phase 2 — Implement (baseline mode)
Invoke the structural-implementer agent — it owns this whole phase
(statute verification, baseline edits, inertness + boundary tests, draft
PR). Its non-negotiables include re-deriving the spec from the ENROLLED
statute: backlog issues can be materially wrong (HI SB3125's issue said
"append one bracket"; the act replaced the whole rate table). For complex
multi-variable changes it may in turn use the country-models agents:
rules-engineer— the parameter-schema change (exact YAML edits, statutory references on every value: bill section + effective date, and inert defaults for periods before the effective date — a new bracket's pre-effective threshold is.infor its rate matches the bracket below, whichever the surrounding schema convention uses) AND any variable/formula changes. A pure bracket append usually needs no formula edit; verify the rate scale is consumed generically before assuming.test-creator— boundary tests proving: (a) pre-effective years are bit-identical to the old schema (the inertness test), (b) the new structure binds correctly in the effective year at, just below, and above each new threshold, per filing status.- Load
policyengine-standards: changelog entry, formatting (make format), naming conventions.
Phase 3 — PR and CI
Use the /create-pr flow (draft → CI green → ready): title names the
statute, body links the structural backlog issue (Fixes policyengine-{country}#NNN), quotes the classifier's empirical
verification, and states the inertness guarantee. ci-fixer handles
iteration if CI fails.
Phase 4 — Record the re-run
Unless --no-rerun: comment on the backlog issue with the exact re-run
command from the structural report (e.g. /analyze-policy "US HI SB3125" --year 2027) and the condition ("after this PR is merged AND released to
the deployed API — check /us/metadata version"). The original run's
pre-registered benchmark registry stays frozen in the archive entry; the
re-run inherits it rather than re-searching.
Boundaries
- Model PRs are reviewed by the country-model team — this command opens the PR; it does not merge it. The analysis stays blocked until the change reaches the DEPLOYED API, not just master.
- Scope is the structural gap named in the issue — no opportunistic refactors of surrounding parameters.
- If the gap turns out to be a formula rewrite rather than a schema
extension (> ~2 days), stop and report — that is program-implementation
work for
/encode-policy-v2, not a structural patch.
Household calculation
Answer "what would this specific household get?" questions. Standalone entry for household-level microsimulation — not population-level (/analyze-policy handles that).
When to use
- "What would a single parent making $35K with two kids get from SNAP + CTC in Texas?"
- "How does a married couple filing jointly in California with $200K W-2 income + $30K SALT compare under the current cap vs $60K cap?"
- "What's the effective marginal tax rate for a childless adult earning $18/hr in New York?"
Do NOT use for population-level questions ("how many households benefit"; "what's the aggregate cost") — those need /analyze-policy or the microsimulation skill.
Arguments
$ARGUMENTS — natural-language household description + question. Include:
- Country (
US,UK,CA) and state / province if relevant - Household composition (single, married, dependents with ages)
- Income sources ($ amounts for wages, SS, unemployment, etc.)
- The question — baseline benefits? A specific reform? A marginal-rate calculation?
Flags:
--year YYYY(default: current tax/benefit year)--reform <preset-name-or-dict>— apply a preset reform (seepresets/reforms/) or a raw reform-dict JSON before calculating--reform-file PATH— load reform-dict from a file
What this command does
- Loads the appropriate country skill (
policyengine-us/policyengine-uk/policyengine-canada) — these carry the correct API patterns for the current package version. - Constructs a
Simulation(household-level, notMicrosimulation) using the parsed household description. - Calculates the variables the user asked about (net income, specific benefit, tax owed, marginal rate).
- Reports baseline values, reform values (if
--reform), and the delta. - Optionally cites the parameter paths that drove each result so the analyst can trace numbers back to statute.
Related
/analyze-policy— population-level version. Use when the question is about aggregate cost or distribution, not a single household./prior-scores— consult external benchmark cluster for the reform without running any microsim.policyengine-usskill — the underlying API pattern reference.
Prior scores lookup
Standalone entry for external-benchmark research. Runs prior-scores-finder (Stage 3 of /analyze-policy) without the microsim or comparison stages.
When to use
- "What has JCT / CBO scored on this reform shape?"
- "Have any think-tanks published estimates for a similar SALT cap change?"
- "I already have a rough sense of my number; I just want to see whose external estimates I should benchmark against."
Do NOT use this to substitute for a microsim — this just aggregates what OTHER organizations have scored. /analyze-policy compares PE's own model against them.
Arguments
$ARGUMENTS — a reform description or bill reference. Same argument shape as /analyze-policy.
Flags:
--country {us|uk|ca}— determines which scorekeepers to consult (frompresets/scorekeepers.yaml)--tier {2|3|all}— Tier 2 = official fiscal offices (JCT, CBO, OBR, PBO); Tier 3 = think-tanks (CRFB, TPC, IFS, etc.);all= both. Defaultall.--domain <tag>— restrict to scorekeepers marked as covering this domain (tax,benefits,healthcare,distributional, etc.)
What this command does
- Loads the scorekeepers registry (
presets/scorekeepers.yaml) filtered by country + tier + domain. - Also consults Tier 0 (local
analyses/archive) viascripts/analyses_kb.py— surfaces prior PE runs on the same parameter family. - Also consults Tier 1 (PolicyEngine published research) via the
policyengine-prior-scoresskill. - Iterates through each scorekeeper's
search_hints, runs WebSearch queries, and extracts headline magnitudes. - Returns a structured benchmark cluster ready to feed into a research writeup — same shape as
benchmark_sources[]in an archived analysis.
Output
Ranked list of external scores with:
- Source name + URL
- Their estimate (10-year cost, per-year cost, poverty change, or whatever they published)
- Reform shape they scored (may differ from yours — note structural distance)
- Methodology notes (static / dynamic, dataset, baseline)
Related
/analyze-policy— the full pipeline invokes this in Stage 3, then compares PE's microsim against the results.presets/scorekeepers.yaml— the registry. Add a new scorekeeper by appending to this file; no code change required.scripts/scorekeepers.py list --country {us|uk|ca}— CLI equivalent.
Prior analysis lookup
Search the analyses/ archive without invoking the full pipeline. Answers "have we already analyzed this?" and "what did we find?"
When to use
- Before running
/analyze-policy— check whether this reform has been scored recently. Datasets and models evolve, but if a near-identical analysis exists from days ago the archived numbers may be reusable. - After deploying a policy change to PE-US: find every archived analysis that touched the parameter family, so the team can flag which ones need re-running.
- Exploring "what does PE think about SALT reforms?" as a research question.
Arguments
$ARGUMENTS — a natural-language query. The command parses it into filters for scripts/analyses_kb.py search.
Flags:
--country {us|uk|ca}— filter by jurisdiction--state <code>— filter by US state / UK country--family <tag>— filter by parameter-family tag (salt,ctc,eitc,cdcc,standard-deduction, etc.)--verdict <verdict>— filter by verdict (PASS,PASS-WITH-NOTES,PASS-WITH-CORROBORATION,INVESTIGATE,structural,not-possible,deployed-model-lag,BLOCKED)--similar-to <archive-file>— find analyses similar to an existing archive (jaccard over tags + jurisdiction)--duplicates— surface near-duplicate archived runs
What this command does
Under the hood: python3 scripts/analyses_kb.py search|similar|duplicates with the parsed filter.
For each hit, surfaces:
- Date, verdict, title
- File path (relative to repo)
- Headline numbers if the frontmatter has them (year-1 cost, 10-year cost, poverty change, Gini change)
- Any GitHub issues opened by the analysis (
issues_openedin frontmatter)
Output
Ranked list of matching archived analyses. For each, a link to the file + a one-line summary. If --duplicates, pairs with jaccard scores.
Related
/analyze-policy— run a NEW analysis. This command is the "check first" step you should reflex to before running one.scripts/analyses_kb.py— the underlying CLI. This command is a thin wrapper so it appears in the slash-command catalog.analyses/README.md— canonical frontmatter schema.
Text to reform-dict
Standalone entry for bill → reform-dict translation. Runs the first two stages of /analyze-policy (understand + classify) and stops before the microsim.
When to use
- "I have a bill (or link, or description) — what parameters does it touch?"
- "Would this even be modelable in PolicyEngine, before I invest in scoring it?"
- "Can I get a validated reform-dict I can then submit to
api.policyengine.orgmyself?"
Do NOT use if you want the impact numbers — that's /analyze-policy end-to-end.
Arguments
Same argument shape as /analyze-policy:
- State + bill number:
UT SB60,RI H7127 - Federal bill:
US HR1234,US S5678 - URL: a bill / proposal / order URL
- Description:
"ARPA-style federal CTC expansion: $3,600 ages 0-5, $3,000 ages 6-17, fully refundable" - Preset:
preset:<name>— load frompresets/reforms/<name>.yaml
Flags:
--country {us|uk|ca}(defaultus)
What this command does
policy-text-researcherfetches the bill/URL/description and extracts structured provisions with baseline_value / reform_value pairs.parameter-locator(per provision, in parallel) maps each provision to PolicyEngine YAML paths. Runs the 5 pre-flight checks: master existence → deployed API existence → date coverage → formula liveness → reform-family toggles → per-year row coverage.reform-classifieraggregates the per-provision verdicts and returns one of:parametric— emits a full reform-dict + confidencedeployed-model-lag— parameter exists on master but not yet on deployed API; includes fallback advicestructural— needs model extension; emits parameter family + formula edit estimatenot-possible— outside PE's scope
Output
The final reform-dict (JSON) ready to submit to POST /{country}/policy on the PE API. Plus the pre-flight check log so the analyst can see what was verified.
If the verdict is structural or not-possible, the output is the classification rationale rather than a reform-dict.
Related
/analyze-policy— the full pipeline. Runs this and then microsim + comparison + write-up./prior-scores— parallel entry to consult external scorekeepers for the same reform.presets/reforms/— the preset library. If a reform is already in there, you don't need this command.
Creating Dashboard: $ARGUMENTS
Coordinate a multi-agent workflow to plan, scaffold, implement, validate, and commit a production-ready PolicyEngine dashboard application.
When to use vs /new-tool
/create-dashboard— multi-page policy analysis apps with plan/scaffold/implement/validate phases. Think "child poverty rates by state under different CTC reform scenarios" — multiple views, filters, embedded charts, backend calls./new-tool— bootstrapper for a single-purpose interactive calculator (e.g., "SALTernative", "ACA-calc"). Blank canvas + design tokens; you implement the specific tool logic yourself.
Arguments
$ARGUMENTS should contain:
- Dashboard description (required) — a natural-language description of the desired dashboard
- Options:
--repo NAME_OR_PATH— use an existing repo (searches sibling of cwd,~/Documents/PolicyEngine/,~/PolicyEngine/,~/)--skip-init— use current working directory as the repo (no repo creation or cloning)--skip-validate— skip the Phase 5 validation loop--auto-confirm— headless mode: no AskUserQuestion gates (see "Headless mode" below)--reform-json PATH— path to a validated reform_dict JSON file; the planner must treat it as the canonical reform definition (typically produced by a prior /analyze-policy run)--dry-run— with--auto-confirm: run Phase 0 argument parsing + Phase 1 planning only; no repo creation, no build, no push--horizon {1|10}— impact time horizon (default1).10adds 10-year series via the API's budget-window compute (~15-25 min extra) — opt in only when the dashboard needs it--no-household— omit the household example page (included by default)
Standard pages: every policy-analysis dashboard includes a policy
explanation page (provisions + source links + parameter table) and a
validation page (results vs external benchmarks, versions, methodology) in
addition to the impacts pages — see dashboard-planner.md "Step 1b". The
household page is standard unless --no-household; its inputs must mirror
the reform's levers (CTC → children/ages; SS taxation → benefit + filing
status + other income) with 3-5 representative precomputed preset cases —
see dashboard-planner.md Step 1b item 4.
Standard chrome: every page keeps the real PolicyEngine site header
(ui-kit PolicyEngineHeader) on top — site nav, logo, country selector —
with the flagship dashboard chrome below it (hero band + folder-style page
tabs + white content card on gray, as on south-carolina-2026-tax-changes).
Never swap the dashboard's page links into the header's nav (see
dashboard-planner.md "Standard chrome" and frontend-builder.md Step 5).
Examples:
/create-dashboard A dashboard showing child poverty rates by state under different CTC reform scenarios
/create-dashboard --repo child-poverty-dashboard "Add a comparison mode showing baseline vs reform"
/create-dashboard --skip-init "Build a SNAP eligibility calculator for a single household"
/create-dashboard --skip-validate "A simple income tax calculator using the PolicyEngine API"
/create-dashboard --repo ctc-restoration-dashboard --auto-confirm --reform-json /tmp/reform.json "Dashboard analyzing the ARPA CTC restoration: budgetary cost, child poverty impact, distributional effects by decile and state"
Headless mode (--auto-confirm)
Used by CI (.github/workflows/create-dashboard.yml, dispatched from the CRM
publication router). Every human gate gets a deterministic resolution:
- Phase 0 (repo name / clone path):
--repo NAMEis REQUIRED with--auto-confirm— it names the desired repo. If not found locally andgh repo view PolicyEngine/{NAME}404s, createPolicyEngine/{NAME}and clone to the sibling of cwd. No questions. - Phase 1 (plan approval): auto-approve the planner's plan.yaml.
- Phase 5 (validation loop): if validators still fail after max fix cycles, CONTINUE with warnings (record them in the result file) rather than stopping.
- Phase 6 (commit gate): auto commit + push.
- Any error that would otherwise AskUserQuestion (auth failure, repo
collision with
--skip-initambiguity, etc.): STOP and write the result file with"status": "failed"and the reason.
CI compute constraint (CRITICAL in headless mode): hosted runners have
16GB RAM — a policyengine-us national microsimulation OOM-kills the runner
(verified 2026-07-13: exit 143 mid-build). Agents must NEVER run local
microsimulations in headless mode. For precomputed data patterns, fetch
impacts from www.policyengine.org's dashboard API instead, which needs no
credential: create the policy with POST https://www.policyengine.org/api/policies,
then read GET https://www.policyengine.org/api/economy/compare (query
country, policy, baseline, region, year) with the polling rules in
policyengine-api-access, "From a dashboard's precompute script": every 5
seconds until its status is ok, giving up after 30 minutes, waiting 60
seconds after a 429, and stopping at once on a failed comparison. Record a
failed or timed-out comparison in the result file's "warnings". The
Public API computes it server-side. Scope the
plan's metrics to what that comparison provides (budget, decile, poverty,
winners/losers).
Pass this constraint into the planner and backend-builder prompts verbatim.
If a required metric genuinely cannot come from the API, drop it and record
the omission in the result file's "warnings" rather than computing locally.
Result file contract (headless runs MUST write this, success or failure):
// /tmp/create-dashboard-result.json
{
"status": "pushed", // pushed | dry-run | failed
"repo_url": "https://github.com/PolicyEngine/ctc-restoration-dashboard",
"dashboard_name": "ctc-restoration-dashboard",
"warnings": [],
"error": null
}
With --dry-run, stop after Phase 1: no repo creation, no build, no push;
write the result file with "status": "dry-run" and the plan summary path in
"warnings". Deployment (/deploy-dashboard) stays a separate, human-run
step in both modes.
Constants
DASHBOARD_NAME = derived from description or user input
REPO_PATH = absolute path to the dashboard repository
PREFIX = /tmp/dashboard-{DASHBOARD_NAME}
PLUGIN_ROOT = ${CLAUDE_PLUGIN_ROOT} (plugin root dir Claude Code injects for this plugin)
YOUR ROLE: ORCHESTRATOR ONLY
CRITICAL — Context Window Protection:
- You are an orchestrator. You do NOT write code, build components, or implement features.
- ALL implementation work is delegated to agents via the Task tool.
- You only read files marked "Short" in the handoff table (max 20 lines each).
- When spawning agents, point them to files on disk — do NOT paste file contents into prompts.
You DO:
- Parse arguments
- Create repos and clone (Phase 0)
- Spawn agents (in parallel where possible)
- Run quality gates via Bash (build, test)
- Read SHORT summary files (≤20 lines)
- Present checkpoints to user via AskUserQuestion
- Commit and push (Phase 6)
You MUST NOT:
- Write any code yourself
- Read full implementation files (plan.yaml, component files, etc.)
- Fix any issues manually — delegate to agents
Context Window Protection
Disk File Handoff Table
| File | Writer | Reader | Size |
|---|---|---|---|
{REPO_PATH}/plan.yaml | planner agent | All subsequent agents | Full |
{PREFIX}-plan-summary.md | planner agent | Orchestrator | Short ≤20 |
{PREFIX}-design-report.md | design-validator | Orchestrator | Short ≤15 |
{PREFIX}-arch-report.md | arch-validator | Orchestrator | Short ≤15 |
{PREFIX}-plan-report.md | plan-validator | Orchestrator | Short ≤15 |
Phase 0: Parse Arguments & Initialize Repository
Step 0A: Parse Arguments & Clean Up
Parse $ARGUMENTS:
- DESCRIPTION: the natural-language dashboard description
- OPTIONS: --repo, --skip-init, --skip-validate
Clean up leftover files from previous runs:
rm -f /tmp/dashboard-*-plan-summary.md /tmp/dashboard-*-build-report.md /tmp/dashboard-*-design-report.md /tmp/dashboard-*-arch-report.md /tmp/dashboard-*-plan-report.md
Resolve PLUGIN_ROOT from the environment variable Claude Code injects for the running plugin:
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT}"
Step 0B: Determine Repository Path
If --skip-init: Set REPO_PATH to the current working directory. Skip to Phase 1.
If --repo NAME_OR_PATH: Search for the repo:
- If it's an absolute path → use directly
- If it contains slashes → resolve relative to cwd
- Otherwise search: sibling of cwd →
~/Documents/PolicyEngine/{NAME}→~/PolicyEngine/{NAME}→~/{NAME} - Set
REPO_PATHto the found path. Skip to Phase 1.
Otherwise: Create a new repository:
If --auto-confirm: a bare description with no --repo is an error — STOP and write the result file with "status": "failed" (headless runs must name the repo). With --repo NAME that wasn't found locally: if gh repo view PolicyEngine/{NAME} 404s, run steps 2-3 below with DASHBOARD_NAME = NAME, clone to the sibling of cwd, and skip both AskUserQuestion gates.
- Derive a dashboard name from the description (kebab-case, 2-4 words), then ask user:
AskUserQuestion:
Question: "What should the GitHub repository be named?"
Options:
- "{auto-derived-name}" (Recommended)
- "Let me type a name"
Set DASHBOARD_NAME from the answer.
- Check GitHub auth and org membership:
gh api user --jq '.login'
If this fails, tell the user to run gh auth login and STOP.
gh api orgs/PolicyEngine/memberships/{username} --jq '.role'
If not admin or member, report and STOP.
- Create the repo:
gh repo create PolicyEngine/{DASHBOARD_NAME} --public --description "PolicyEngine {DASHBOARD_NAME} dashboard"
- Ask where to clone:
AskUserQuestion:
Question: "Where should I clone the repository?"
Options:
- "{sibling-of-cwd}/{DASHBOARD_NAME}" (Recommended)
- "~/Documents/PolicyEngine/{DASHBOARD_NAME}"
- "Let me specify a path"
- Clone and initial commit:
gh repo clone PolicyEngine/{DASHBOARD_NAME} "{CLONE_PATH}"
cd {CLONE_PATH} && git add -A && git commit --allow-empty -m "Initialize dashboard repository" && git push -u origin main
- Set
REPO_PATH = {CLONE_PATH}.
Set PREFIX = /tmp/dashboard-{DASHBOARD_NAME}.
Phase 1: Plan (Human Gate)
Step 1A: Spawn planner agent
Spawn a general-purpose agent ("dashboard-planner"):
subagent_type: "general-purpose"
"You are the dashboard-planner agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
DASHBOARD DESCRIPTION:
{DESCRIPTION}
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/dashboard-planner.md
ADDITIONAL REQUIREMENTS:
- Write plan.yaml to {REPO_PATH}/plan.yaml
- Also write a SHORT summary (≤20 lines) to {PREFIX}-plan-summary.md containing:
- Dashboard name and purpose
- Data pattern chosen and why
- Number and types of components
- Key API endpoints or data sources
- Any questions or concerns about the description"
Step 1B: Plan approval
Read {PREFIX}-plan-summary.md (SHORT file). Present the summary to the user:
AskUserQuestion:
Question: "Here's the dashboard plan. How should we proceed?"
Options:
- "Approve — start building" (Recommended)
- "Modify — I have feedback"
- "Reject — start over"
If --auto-confirm: auto-approve (skip the question). If --dry-run is also set, STOP here and write /tmp/create-dashboard-result.json with "status": "dry-run".
If "Approve": Continue to Phase 2.
If "Modify": Ask follow-up question for feedback. Then re-spawn planner agent with extra context:
"User feedback on your previous plan:
{USER_FEEDBACK}
Read the existing plan.yaml in {REPO_PATH} and update it based on this feedback.
Write updated plan.yaml and updated {PREFIX}-plan-summary.md."
Then repeat Step 1B (approval loop).
If "Reject": STOP.
Phase 2: Scaffold (Quality Gates)
Step 2A: Spawn scaffold agent
Spawn a general-purpose agent ("dashboard-scaffold"):
subagent_type: "general-purpose"
"You are the dashboard-scaffold agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/dashboard-scaffold.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Step 2B: Quality gates
After the agent completes, run quality gates:
cd {REPO_PATH} && bun run build 2>&1 | tail -50
cd {REPO_PATH} && bunx vitest run 2>&1 | tail -50
If both pass (exit code 0): Continue to Phase 3.
If either fails: Re-spawn scaffold agent with error context:
"Quality gate failed. Fix the issues:
Command: {FAILED_COMMAND}
Exit code: {EXIT_CODE}
Output:
{STDERR_AND_STDOUT}
Fix the issues in {REPO_PATH} and ensure both build and tests pass."
Max 2 retries. If still failing after retries:
AskUserQuestion:
Question: "Build/tests still failing after 2 fix attempts. What should we do?"
Options:
- "Continue anyway — I'll fix manually later"
- "Stop"
Phase 3: Backend + Frontend (PARALLEL)
Spawn both agents in a single message so they run concurrently:
Agent 1: backend-builder
subagent_type: "general-purpose"
run_in_background: true
"You are the backend-builder agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/backend-builder.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Agent 2: frontend-builder
subagent_type: "general-purpose"
run_in_background: true
"You are the frontend-builder agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/frontend-builder.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Wait for both agents to complete before proceeding.
Phase 4: Integrate
Spawn a general-purpose agent ("dashboard-integrator"):
subagent_type: "general-purpose"
"You are the dashboard-integrator agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/dashboard-integrator.md
The approved plan.yaml is at: {REPO_PATH}/plan.yaml"
Phase 5: Validation (3 Parallel Validators + Fix Routing)
Skip this phase if --skip-validate flag is set.
Validation Loop (max 3 cycles)
ROUND = 1
MAX_ROUNDS = 3
PENDING_VALIDATORS = [design, architecture, plan] # all 3 initially (build/tests gated directly)
Step 5A: Run validators
Spawn all pending validators in a single message, each with run_in_background: true.
For each validator, use this prompt template:
subagent_type: "general-purpose"
run_in_background: true
"You are the {VALIDATOR_NAME} agent for the PolicyEngine dashboard builder.
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
Read and follow the complete instructions in:
{PLUGIN_ROOT}/agents/dashboard/{VALIDATOR_FILE}
The plan.yaml is at: {REPO_PATH}/plan.yaml
ADDITIONAL: Write your report to {PREFIX}-{REPORT_FILE} in addition to returning it."
Validator details (build and tests are gated directly by the orchestrator via bun run build + bunx vitest run — see Phase 3 and Error Handling — not by a subagent):
| Validator | Agent file | Report file |
|---|---|---|
| design | dashboard-design-validator.md | design-report.md |
| architecture | dashboard-architecture-validator.md | arch-report.md |
| plan | dashboard-plan-validator.md | plan-report.md |
Wait for all validators to complete.
Step 5B: Parse results
Read each validator's SHORT report. Look for PASS or FAIL.
If all pass: Exit validation loop, continue to Phase 6.
If any fail: Collect the failure details from each failed validator.
Step 5C: Route failures to builders
Use this routing table to determine which builder agent should fix each failure:
| Failed Validator | Failure contains | Route to builder |
|---|---|---|
| design | (any failure) | frontend-builder.md |
| architecture | "tailwind", "next.js", "package manager" | dashboard-scaffold.md |
| architecture | "ui-kit", or other | frontend-builder.md |
| plan | "api contract" | backend-builder.md |
| plan | "component", "chart" | frontend-builder.md |
| plan | "embedding", "loading", "error" | dashboard-integrator.md |
Group failures by target builder. For each builder that needs to fix issues, spawn it:
subagent_type: "general-purpose"
"You are the {BUILDER_NAME} agent. Fix the following validation failures:
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths. Prefix Bash with: cd {REPO_PATH} &&
Read the full instructions in:
{PLUGIN_ROOT}/agents/dashboard/{BUILDER_FILE}
VALIDATION FAILURES TO FIX:
{LIST_OF_FAILURES_FROM_REPORTS}
Fix each issue, ensuring the build and tests still pass.
Run: cd {REPO_PATH} && bun run build && bunx vitest run"
Step 5D: Re-validate
After fixes, set PENDING_VALIDATORS to only the validators that failed (don't re-run passed ones).
Increment ROUND. If ROUND <= MAX_ROUNDS, go back to Step 5A.
If ROUND > MAX_ROUNDS and failures remain:
AskUserQuestion:
Question: "Validation found {N} remaining issues after {MAX_ROUNDS} fix rounds. What should we do?"
Options:
- "Accept as-is — I'll fix manually"
- "Keep trying one more round"
- "Stop — don't commit"
If --auto-confirm: accept as-is (skip the question) and record the remaining issues in the result file's "warnings".
If "Accept": Continue to Phase 6. If "Keep trying": Increment MAX_ROUNDS by 1, go back to Step 5A. If "Stop": STOP.
Phase 6: Review & Commit (Human Gate)
Present a summary to the user:
## Dashboard Build Complete
**Repository**: {REPO_PATH}
**Phases completed**: plan → scaffold → backend + frontend → integrate → validate
### How to run locally
cd {REPO_PATH}
make dev # Start full dev stack
make dev-frontend # Frontend only
make test # Run tests
make build # Production build
### Next steps
After reviewing locally, commit and push. Then use /deploy-dashboard to deploy.
AskUserQuestion:
Question: "Ready to commit and push to GitHub?"
Options:
- "Commit and push" (Recommended)
- "Stop — I want to review locally first"
If --auto-confirm: commit and push without asking, then write /tmp/create-dashboard-result.json with "status": "pushed" and the repo URL.
If "Commit and push":
cd {REPO_PATH} && git add -A && git commit -m "Implement dashboard from plan" && git push origin HEAD
Report the result to the user with the repo URL.
If "Stop": Report that code is ready locally at {REPO_PATH} and the user can commit when ready.
Error Handling
| Category | Example | Action |
|---|---|---|
| GitHub auth failure | gh api user fails | Tell user to run gh auth login, STOP |
| Repo already exists | gh repo create says exists | Tell user to use --repo NAME, STOP |
| Build failure | bun run build fails | Re-run scaffold with error context (max 2 retries) |
| Test failure | bunx vitest run fails | Re-run scaffold with error context (max 2 retries) |
| Validation failure | Validator reports FAIL | Route to builder, re-validate (max 3 cycles) |
| Agent failure | Agent errors or doesn't complete | Report to user, suggest re-running that phase |
| User rejects plan | "Reject" at plan approval | STOP |
Escalation Path
- Agent encounters error → Attempt fix via re-spawning agent
- Fix fails after max retries → Report to user with AskUserQuestion
- User chooses to stop → STOP cleanly
- Never proceed past a human gate without approval
Execution Instructions
YOUR ROLE: You are an orchestrator ONLY. You must:
- Invoke agents using the Task tool with
subagent_type: "general-purpose" - Wait for agent completion (or use
run_in_background: truefor parallel) - Read ONLY short summary/report files (≤20 lines)
- Run quality gates via Bash
- Present checkpoints to user via AskUserQuestion
- Proceed to the next phase after approval
YOU MUST NOT:
- Write any code yourself
- Fix any issues manually
- Read full implementation files
- Skip human gates
IMPORTANT — Agent Working Directory: Every agent prompt MUST include:
WORKING DIRECTORY: {REPO_PATH}
Use absolute paths for all Read/Write/Edit/Glob/Grep operations.
Prefix all Bash commands with: cd {REPO_PATH} &&
This ensures agents work in the correct directory regardless of Claude Code's cwd.
Execution Flow (CONTINUOUS):
Execute all phases sequentially without stopping (unless a STOP condition is hit):
- Phase 0: Parse args, init repo (or use existing)
- Phase 1: Plan → HUMAN APPROVAL (approve/modify/reject)
- Phase 2: Scaffold → quality gates (build + test, max 2 retries)
- Phase 3: Backend + Frontend (PARALLEL — spawn both in one message)
- Phase 4: Integrate
- Phase 5: Validate (4 validators parallel, fix routing, max 3 cycles) — skip if
--skip-validate - Phase 6: Review → HUMAN APPROVAL → commit and push
- Phase 7: Overview update (silent, background)
Deploying dashboard: $ARGUMENTS
Deploy a completed PolicyEngine dashboard to production. Run this AFTER merging your feature branch into main.
Precondition: The user should be on the main branch with a clean working tree and the dashboard code merged.
Skills Used
- policyengine-tools — Vercel frontend deployment (all dashboards) and Modal backend deployment (only if
custom-modalpattern) - policyengine-cloud-costs — What may stay running after the deploy, and the declared exceptions to report (only if
custom-modalpattern)
Step 1: Verify Prerequisites
# Check we're on main
git branch --show-current
# Check for clean working tree
git status
# Verify build passes
bun install --frozen-lockfile && bun run build && bunx vitest run
If not on main: Tell the user to merge their feature branch first:
You're currently on branch
{branch}. Please merge intomainfirst:git checkout main git merge {branch} git pushThen run
/deploy-dashboardagain.
If build fails: Report the error and STOP. Do not deploy broken code.
Step 2: Read the Plan
cat plan.yaml
Extract:
dashboard.name— for Vercel project and Modal app namesdashboard.zone_path— for multi-zone host rewrite verificationdata_pattern— determines if Modal deploy is needed (custom-modalneeds it;precomputed,precomputed-csv, andpolicyengine-apido not)tech_stack.framework— should bereact-nextjs(env var prefix:NEXT_PUBLIC_*)embedding.register_in_apps_json— determines if apps.json update is neededembedding.slug— the URL slug for policyengine.org
2a. Pre-flight: check for existing host rewrites (informational)
If this dashboard has deployed before, the host (policyengine-app-v2/website/next.config.ts) may already have rewrites pointing at it. Check so we know whether Step 5 needs to add them or they're already in place.
# Clone or pull the host repo
if [ ! -d /tmp/policyengine-app-v2 ]; then
gh repo clone PolicyEngine/policyengine-app-v2 /tmp/policyengine-app-v2
fi
(cd /tmp/policyengine-app-v2 && git checkout main && git pull)
# Invoke the multi-zone validator in host-only mode to check all rewrite structure at once
# (beforeFiles placement, route + asset rewrite counts, destination format)
Spawn the multizone-validator agent with TARGET_PATH=. (current dashboard repo) and HOST_CONFIG_PATH=/tmp/policyengine-app-v2/website/next.config.ts. Record which host-check items PASS / FAIL — these feed into Step 5 below.
Do NOT stop if host rewrites are missing. For a brand-new dashboard, the host can't have rewrites yet because Vercel hasn't assigned a production URL. Step 5 handles adding them after the first deploy captures the URL.
Step 3: Deploy Backend (if custom-modal)
Only if data_pattern: custom-modal. For every other pattern, skip to Step 4.
See the policyengine-tools skill (Pattern C: Modal gateway + worker + polling) for the full Modal deployment reference.
3a. Authentication check (human gate)
modal token info
modal profile list
Present the output to the user. Verify:
- Active profile is
policyengine - Workspace is
policyengine
If authentication fails or shows wrong workspace: Stop and display instructions:
Modal authentication required. Your CLI is not configured for the
policyengineworkspace.Please run:
modal token new --profile policyengine modal profile activate policyengineIf you don't have access, ask a PolicyEngine workspace owner for an invite.
Do NOT proceed until modal token info shows Workspace: policyengine.
If authentication succeeds, use AskUserQuestion to confirm before proceeding:
question: "Modal is authenticated to the policyengine workspace. Proceed with deployment?"
header: "Modal auth"
options:
- label: "Proceed"
description: "Continue to environment selection and deploy"
- label: "Cancel"
description: "Stop deployment"
3b. Environment selection (human gate)
Use AskUserQuestion to select the Modal environment:
question: "Which Modal environment should this deploy to?"
header: "Environment"
options:
- label: "main (Recommended)"
description: "Production — policyengine--app-func.modal.run"
- label: "staging"
description: "Pre-production testing — policyengine-staging--app-func.modal.run"
- label: "testing"
description: "Development/CI — policyengine-testing--app-func.modal.run"
3c. Deploy
# Guard against env var override
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET
# Deploy to the selected environment
modal deploy modal_app.py --env SELECTED_ENV
3d. Verify endpoint
Construct the URL from the app name and function name in modal_app.py:
- Pattern:
https://policyengine--APP_NAME-FUNCTION_NAME.modal.run - With non-main environment:
https://policyengine-ENV--APP_NAME-FUNCTION_NAME.modal.run
# Health check (if endpoint exists)
curl -s -w "\n%{http_code}" https://policyengine--DASHBOARD_NAME-health.modal.run
# Test the calculation endpoint
curl -s -X POST https://policyengine--DASHBOARD_NAME-calculate.modal.run \
-H "Content-Type: application/json" \
-d '{"test": true}'
If deploy fails: Report error and STOP. See the policyengine-tools skill's Modal deployment guidance.
3e. Set API URL in Vercel
After successful Modal deploy, set the API URL as a Vercel environment variable.
vercel env add NEXT_PUBLIC_API_URL production
# Enter: https://policyengine--DASHBOARD_NAME-calculate.modal.run
Step 4: Deploy Frontend to Vercel
See the policyengine-tools skill ("Deployment gotchas") for the full Vercel deployment reference.
# Link to Vercel under PolicyEngine team (if not already linked)
vercel link --scope policy-engine
# Deploy to production
vercel --prod --yes --scope policy-engine
If a Modal backend was deployed in Step 3, force-rebuild to pick up the new env var:
vercel --prod --force --yes --scope policy-engine
Capture the production URL from the output.
Verify the deployment:
curl -s -o /dev/null -w "%{http_code}" https://VERCEL_PRODUCTION_URL/
IMPORTANT: Use the auto-assigned Vercel production URL, not a custom alias. Custom aliases may have deployment protection issues.
Step 5: Wire the zone into the host (policyengine-app-v2)
After the Vercel deploy in Step 4, we have a production URL. Now wire the zone into policyengine-app-v2 so it's reachable at policyengine.org/<zone_path>. This is a PR to PolicyEngine/policyengine-app-v2.
Two parts, both in the same PR:
- Host rewrites — required for every multi-zone dashboard. These make the zone reachable at
policyengine.org/<zone_path>. - apps.json entry — optional, only if
embedding.register_in_apps_json: true. This is research-listing metadata; it does NOT affect routing.
5a. Create the PR branch
# Clone app-v2 if not already available
if [ ! -d /tmp/policyengine-app-v2 ]; then
gh repo clone PolicyEngine/policyengine-app-v2 /tmp/policyengine-app-v2
fi
cd /tmp/policyengine-app-v2
git checkout main
git pull
git checkout -b wire-DASHBOARD_NAME-zone
5b. Add host rewrites to website/next.config.ts
Add these entries to rewrites().beforeFiles in website/next.config.ts (use VERCEL_PRODUCTION_URL captured from Step 4 — e.g. my-dashboard-policy-engine.vercel.app):
// Path-mounted zone: two route rewrites preserve the basePath in the destination
{ source: '<zone_path>', destination: 'https://<VERCEL_PRODUCTION_URL><zone_path>' },
{ source: '<zone_path>/:path*', destination: 'https://<VERCEL_PRODUCTION_URL><zone_path>/:path*' },
// Asset rewrite (required for zones using assetPrefix: '/_zones/<dashboard.name>')
{ source: '/_zones/<dashboard.name>/:path*', destination: 'https://<VERCEL_PRODUCTION_URL>/_zones/<dashboard.name>/:path*' },
Skip the asset rewrite only if the zone does not set assetPrefix. For root-served zones (no basePath), keep the route rewrites but map destinations to the zone root instead: <zone_path> → https://<VERCEL_PRODUCTION_URL> and <zone_path>/:path* → https://<VERCEL_PRODUCTION_URL>/:path*. See the policyengine-tools skill for the two valid patterns: path-mounted and root-served.
5c. Add apps.json entry (only if embedding.register_in_apps_json: true)
This is research-listing metadata. The dashboard is reachable at policyengine.org/<zone_path> from Step 5b alone; this entry adds it to the research/interactives listing.
Add to app/src/data/apps/apps.json:
{
"slug": "SLUG",
"title": "TITLE",
"description": "DESCRIPTION",
"path": "/COUNTRY/SLUG",
"tags": ["COUNTRY", "policy", "interactives"],
"countryId": "COUNTRY",
"displayWithResearch": true,
"image": "SLUG-cover.png",
"date": "CURRENT_DATE 12:00:00",
"authors": ["AUTHOR_SLUG"]
}
The path field is the multi-zone route on policyengine.org (the host rewrites from 5b make it reachable). Do not use "type": "iframe" — that's the legacy embedding model and does not apply to multi-zone deployments.
Use AskUserQuestion to gather required metadata:
question: "What is the author slug for the apps.json entry? (Check existing entries in apps.json for format, e.g., 'max-ghenis')"
header: "Author"
options: [] (free text — let the user type via "Other")
If displayWithResearch: true, also ask:
question: "Do you have a cover image for the apps.json listing?"
header: "Cover image"
options:
- label: "I'll provide one"
description: "You'll give me the image file or path"
- label: "Skip for now"
description: "Use a placeholder — you can add a cover image later"
5d. Commit and open the PR
git add website/next.config.ts app/src/data/apps/apps.json # second path only if 5c ran
git commit -m "Wire DASHBOARD_NAME zone"
git push -u origin wire-DASHBOARD_NAME-zone
gh pr create --repo PolicyEngine/policyengine-app-v2 \
--title "Wire DASHBOARD_NAME zone" \
--body "Adds host rewrites (and apps.json entry, if applicable) so DASHBOARD_NAME is reachable at policyengine.org/COUNTRY/SLUG.
Zone URL: VERCEL_PRODUCTION_URL
Public path: /COUNTRY/SLUG"
Step 6: Smoke Test
Direct-URL checks can run immediately. Public-path checks require the Step 5 PR to merge first.
Immediate (after Step 4):
- Direct zone URL: Visit
https://VERCEL_PRODUCTION_URL/<zone_path>, verify the dashboard loads - Hash sync: Test that URL parameters work (add
#income=50000etc.) and survive refresh - Country detection (if supported): Test with
#country=uk
After Step 5 PR merges:
- Public path via host: Verify the dashboard loads at
policyengine.org/COUNTRY/SLUG(served through host rewrites, not an iframe) - Assets load: Open DevTools → Network, refresh, confirm all
_next/static/*assets return 200. For static-export zones, verify/_zones/<dashboard.name>/*requests reach the zone. - Shared chrome (if using ui-kit): Verify Header/Footer render consistently with the rest of
policyengine.org
Step 7: Report
Present deployment summary to the user:
Dashboard deployed
- Zone URL: VERCEL_PRODUCTION_URL
- Public path (after host PR merges): policyengine.org/COUNTRY/SLUG
- Vercel project: DASHBOARD_NAME [If custom backend:]
- API endpoint: https://policyengine--DASHBOARD_NAME-calculate.modal.run
- Modal environment: SELECTED_ENV
- Running while idle: nothing, or each declared exception with its cost (from the README's "Running costs")
- Host-wiring PR: PR_URL (adds rewrites, and apps.json entry if applicable)
Verify
- Dashboard loads at the zone URL
- Calculations work (or stubs respond correctly)
- Hash parameters are preserved on refresh
- After host-wiring PR merges, dashboard loads at policyengine.org/COUNTRY/SLUG
- After host-wiring PR merges, assets (
_next/static/*) load with 200 status
Error Recovery
| Issue | Fix | Reference |
|---|---|---|
| Vercel deploy fails | Check vercel.json config, ensure project builds | policyengine-tools |
| Modal deploy fails | Check Python deps, Modal auth, function timeouts | policyengine-tools |
| Wrong Modal workspace | modal profile activate policyengine | policyengine-tools |
| 404 on Vercel URL | Wait 30s for propagation, check Vercel dashboard | policyengine-tools |
| API returns errors | Check Modal logs: modal app logs DASHBOARD_NAME | policyengine-tools |
| Hash sync broken | Check postMessage calls in embedding.ts | policyengine-tools |
Agents 20
Calibration Diagnostics
It runs only when reform-comparator returns INVESTIGATE, and hypothesizes which calibration targets or imputed variables in the run's dataset drive the mismatch. Microcosm (PolicyEngine/microcosm) builds the US and UK defaults, and policyengine-uk-data builds the Enhanced FRS and FRS files a UK run can name in place of the UK default. The microsim result's dataset names the one the run used.
Loads the policyengine-calibration-diagnostics skill for the full sensitivity registry.
Inputs
deviation_signature(fromreform-comparator)reform(provisions + reform-dict)jurisdictionanchor(the prior-scores anchor for context)dataset(the microsim result'sdataset: the name of the dataset the run loaded, the certified default unless a local run named another)
Process
Step 1: Match the program family to known sensitivities
Programs differ in which calibration inputs matter most. Use the policyengine-calibration-diagnostics skill index. Typical sensitivities:
| Program | High-sensitivity inputs |
|---|---|
| EITC | Takeup by family-size; childless adult earnings distribution; tax-unit definition; eligible age cohort |
| CTC | Non-filer share + non-filer takeup; imputed child age distribution; SPM threshold; refundability mechanic |
| SNAP | Eligible-unit takeup (~80%); deductions stack; categorical eligibility; state options |
| SALT cap | Itemizer share (post-TCJA ~10%); state income/property tax imputation; top-1% AGI calibration; AMT interaction |
| State income tax | State weights from CPS (small-state variance); state-AGI tail imputation; conformity rules |
| Refundable credits broadly | Non-filer share (CPS undercounts non-filers); takeup distribution by income |
Step 2: Apply the deviation signature
Cross-reference the signature with the sensitivity table. Examples:
Signature: "cost roughly right, poverty impact understates by 50%" (e.g., CTC case)
- Hypothesis 1: CTC takeup for non-filers — if takeup is set too low for the population that benefits most from refundability, dollars flow but don't lift households out.
- Hypothesis 2: Baseline child poverty rate — if the calibrated baseline is too low, the percentage reduction looks compressed.
- Hypothesis 3: SPM unit definition — if related individuals are split into separate SPM units, the per-unit benefit is too small to clear thresholds.
Signature: "cost too high, benefit distributed too evenly across deciles" (e.g., SALT case)
- Hypothesis 1: Itemizer share over-states — too many filers in the dataset itemize, so cap removal benefits cascade down.
- Hypothesis 2: State income tax imputation flat — if state tax is imputed at the federal income level rather than state-specific marginal rates, the regressive signature disappears.
- Hypothesis 3: Top-1% AGI calibration — under-weighted top tail compresses the upper-decile concentration.
Signature: "small-state cost very volatile vs prior"
- Hypothesis 1: CPS state sample size — RI has ~3-4k person-records; calibration target uncertainty is high.
- Hypothesis 2: State weight target mismatch — ACS marginals are calibrated, but the joint distribution (state × AGI × kids) may not be.
- Hypothesis 3: Conformity assumption — state-federal interaction parameter may have changed since the prior was scored.
Step 3: Generate the diagnostic checklist
Required citations. Every hypothesis MUST cite a specific file and (where possible) a specific line/function/parameter path. "loss.py" alone is not enough — quote the actual target name or line range. Use gh api repos/PolicyEngine/microcosm/contents/{path} if you need to open the file before writing the hypothesis. For a UK run on the default, cite Microcosm's UK target files in packages/microcosm-build/src/microcosm/build/uk/ (uk_population_targets.json and the national target_references.json that Microcosm generates from it) or a build stage in uk_runtime/ beside that folder. For a UK run on an Enhanced FRS or FRS file, open PolicyEngine/policyengine-uk-data instead.
Expertise tagging. Mark each hypothesis with expertise_required: non-expert (can be tested by reading docs), analyst (needs PolicyEngine workflow familiarity), or pe-internal (needs core-team knowledge of the calibration pipeline). The SKILL claims "non-expert users can at least understand candidate causes" — that's only credible if we mark the bar honestly.
{
"ranked_hypotheses": [
{
"rank": 1,
"calibration_input": "ctc_non_filer_takeup",
"file_citation": "policyengine-us/policyengine_us/parameters/gov/irs/credits/ctc/takeup.yaml (if exists) OR the calibration target referencing `ctc_takeup` in PolicyEngine/microcosm (microcosm-calibrate package)",
"current_value_quoted": "0.75 (assumed uniform — verify by opening the file)",
"expected_direction_of_effect": "raising takeup increases poverty reduction proportionally",
"test_to_run": "Set gov.irs.credits.ctc.takeup to 0.95 in the reform-dict alongside the existing reform; rerun; if child poverty impact closes 80%+ of the gap toward the anchor (-37%), this is the cause.",
"expertise_required": "non-expert",
"related_issues": ["https://github.com/PolicyEngine/policyengine-us/issues/4276 (EITC takeup, related case)"]
},
{
"rank": 2,
"calibration_input": "imputed_child_age_distribution",
"file_citation": "the child_age imputation step in PolicyEngine/microcosm (microcosm-frame / microcosm-fit packages)",
"current_value_quoted": "Read from the file — quote the target distribution by single year of age",
"expected_direction_of_effect": "more 0-5 children → higher cost via $3,600 tier",
"test_to_run": "Compare Microcosm's age-0-5 share to ACS B01001 published 2023; if off by >5%, the imputation needs recalibration.",
"expertise_required": "analyst",
"related_issues": []
},
{
"rank": 3,
"calibration_input": "spm_unit_aggregation",
"file_citation": "policyengine-us/policyengine_us/variables/household/demographic/spm_unit.py",
"current_value_quoted": "n/a — structural",
"expected_direction_of_effect": "splitting cohabiting beneficiaries across SPM units halves per-unit benefit → fewer cross-threshold lifts",
"test_to_run": "Audit 50 households where the reform produced CTC but no poverty change; check if their SPM unit assignment splits the family.",
"expertise_required": "pe-internal",
"related_issues": []
}
],
"next_action": "Run hypothesis #1 first — non-expert-friendly, single reform-dict edit, cheapest to falsify.",
"calibration_dashboard_panels": [
"(illustrative — replace with actual panel URLs once calibration-diagnostics repo deployment is known)",
"https://policyengine.github.io/calibration-diagnostics/ctc-takeup",
"https://policyengine.github.io/calibration-diagnostics/child-age-distribution"
]
}
Honest scope note. The agent's hypotheses are only as strong as the sensitivity rows in the policyengine-calibration-diagnostics SKILL. The CTC and SALT rows are well-developed; EITC and small-state rows are shorter. If the deviation signature falls in a thinly-covered program, surface that explicitly in the output: "coverage_note": "policyengine-calibration-diagnostics SKILL has limited rows for this program family; hypotheses below are derived from analogous programs and should be weighted lower."
Step 4: Sources to consult
First stop — the live calibration API (per-target fit for the current Microcosm release, no auth, reads from Hugging Face):
BASE = https://calibration-diagnostics.vercel.app/calibration/dashboard/api/microcosm
GET {BASE}/target-diagnostics?source=<ssa|cms_medicaid|cms_aca|cms_medicare|hhs_acf_tanf|irs_soi|jct|cbo|...>
GET {BASE}/target-investigation?target=<target-id> # full investigation packet for one target
GET {BASE}/releases # release ids for pinning
Rank hypotheses with actual relative_error numbers from these endpoints
rather than assumptions — a target that is already >10% off in the release
diagnostics is a stronger hypothesis than any prior. The
target-investigation packet includes ledger metadata, artifact paths, and
suggested repo searches for the drill-down. The API lists only Microcosm's US releases (none for the UK on 2026-11-08), so a UK hypothesis starts from the UK target files that Step 3 names.
Also:
github.com/PolicyEngine/microcosm— builds and calibrates the US and UK datasets (PolicyEngine archived policyengine-us-data). The calibration code lives in themicrocosm-calibratepackage, and each country's target files and build stages live inpackages/microcosm-build/src/microcosm/build/(us/andus_runtime/,uk/anduk_runtime/).github.com/PolicyEngine/policyengine-uk-data— builds the Enhanced FRS and FRS files; consult it only for a UK run that named one of them.github.com/PolicyEngine/calibration-diagnostics— the dashboard source (UI at/calibration/dashboard/microcosm).- Open issues matching the program keyword on
policyengine-{country}and on the repo that builds the run's dataset (microcosm, orpolicyengine-uk-datafor a UK run on one of its files).
Output
A ranked diagnostic checklist that an experienced PolicyEngine engineer can act on, AND a non-expert user can at least understand the candidate causes. Each hypothesis includes a runnable test.
Hand-off
Returns the checklist to /analyze-policy, which writes the final report including:
- The original microsim result
- The mismatch direction
- The ranked diagnostic hypotheses
- The recommended next test
If --auto-investigate is set, can chain into perturbation runs (top-1 hypothesis test) before reporting.
Structural Implementer
Turns a structural backlog issue (e.g. "the model has no slot for this
bracket") into a reviewed-ready draft PR against policyengine-{country}.
Inputs
- The structural backlog issue (number or URL) and/or the archived structural analysis report
- Country (default
us); a workspace directory to clone into
Non-negotiables (learned the hard way)
- The statute outranks the issue. Before writing anything, fetch the ENROLLED bill text from the primary source (state legislature site, congress.gov) and re-derive the spec from it. Backlog issues inherit the classifier's reading of news coverage and can be materially wrong — HI SB3125's issue said "append a 13% bracket"; the enrolled act repealed and replaced the entire TY2027/TY2029 rate tables. Implement what the statute says; document every divergence from the issue prominently in the PR body, with an offer to trim if maintainers prefer.
- Enacted law → baseline; proposals → stop. This agent only does
baseline changes. If the reform is not enacted, report back that it
belongs to
/encode-reform(gov/contrib factory pattern) instead. - Inertness guarantee. Years before the effective date must be
bit-identical to the current model. New brackets get
.infthresholds before the effective date (check the repo for the precedent pattern — e.g. Maryland's scales); existing values are never edited, only date-keyed additions appended. - Verify the encoding programmatically. Reproduce every statutory base amount / boundary value from the encoded schedule (all filing statuses x all effective years) in a scratch script before writing tests. If a statutory base amount and your encoding disagree by even a dollar, the encoding is wrong.
- Formula check before assuming schema-only. Confirm the tax formula consumes the parameter structure generically. If a code change is needed, report scope back to the caller before proceeding past ~2 days of estimated work.
Process
- Read the issue + archived structural report; fetch and read the enrolled statute; write the derived spec (bracket tables, effective dates, statutory citations) into the PR-body draft FIRST.
- Clone
policyengine-{country}(depth 50), study the target parameter files and the repo's current conventions — check recent merged PRs for the changelog convention (changelog.d/fragments vs
changelog_entry.yaml), test file format, and formatting tooling.
3. Make the parameter edits with statutory references on every value
(act + section + URL + effective date).
4. Tests, following the repo's existing format:
- inertness: pre-effective-year cases with hand-computed expected values shown in comments
- boundary: below / at / above each new threshold, per filing status, per effective year
- Environment:
uv venv+uv pip install -e .(slow — run long commands piped throughteeor in chunks so output keeps streaming). Run the targeted tests, then the full state/program baseline suite, then the related contrib suite. All green before PR. make format; revert unrelated reformatting rather than committing it.- Branch, commit (Co-Authored-By trailer per repo convention), push,
open a draft PR: title names the statute; body carries
Fixes #<issue>, the statutory citation, the derived bracket/parameter tables, the inertness guarantee, the hand arithmetic, and any divergence from the issue spec. - Comment the re-run condition on the backlog issue: the exact
/analyze-policycommand, gated on "merged AND released to the deployed API (check /{country}/metadata version)".
Boundaries
- Draft PR only — the country-model team merges. Never mark ready.
- Scope = the structural gap per the STATUTE. No opportunistic refactors.
- Push the branch and open the PR as soon as the commit is green — body polish can be amended after; unpushed work is lost work.
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- Which data pattern the plan specifies (precomputed, policyengine-api, or custom-modal)
- What endpoint interfaces or data files are needed
- How to type the API contract for the frontend
- What test coverage is appropriate
Backend Builder Agent
Builds the data layer for a dashboard based on the approved plan.yaml.
Skills Used
- policyengine-tools - Data patterns, API integration, and Modal backend architecture
- policyengine-api-access - The dashboard API for pages, and server credentials for a backend that calls the Public API (policyengine-api and custom-modal)
- policyengine-us or policyengine-uk - PolicyEngine variables
- policyengine - Python simulation mechanics, reform dictionaries, and parameter path syntax (custom-modal only)
- policyengine-cloud-costs - Modal defaults (scale to zero,
max_containers, measured sizes) and how to declare an exception (custom-modal only)
Backend Selection Priority
Always prefer simpler patterns. The plan's data_pattern comes from the four options in the policyengine-tools skill's "Data and backend patterns", checked in this order. Check it against them before building a custom backend:
- Are the inputs a small, fixed set known before deploying? → Use Pattern A/D (
precomputed/precomputed-csv) - Can the dashboard API give each result as it is? It calculates households (every charted input in
axesin the same call), stores a parameter reform and compares it with current law across the economy, and reads country metadata. → Use Pattern B (policyengine-api) - Does it need a Public API route outside those four, used unchanged? → Ask for a new dashboard operation (policyengine-api-access), and precompute until it ships
- Otherwise: outputs specific to this dashboard that the Public API's economy result doesn't include and precomputing can't cover, or a dataset the Public API doesn't serve. → Use Pattern C (
custom-modal) with gateway + polling
A structural reform goes into the country package as a contributed reform switched on by a gov.contrib.… parameter, and then uses Pattern B; it is not a reason for Pattern C.
The Public API's model version is a reason for custom-modal only while the policyengine package's model bundle isn't updated weekly; the plan says what the check in "Data and backend patterns" found and which release the dashboard needs. A custom-modal dashboard's presets come from its backend's pinned release, not the dashboard API, so the page shows one model release.
Not a reason for custom-modal: a feature coming close to a rate limit; keep the page within the budget in policyengine-api-access, and precompute what can't fit.
Pattern C is the most complex and should be the last resort. If the plan specifies custom-modal, the plan MUST include a reason explaining why Pattern B is insufficient.
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-api-access(if policyengine-api pattern, or a custom-modal backend that calls the Public API)Skill: policyengine-us(if US dashboard)Skill: policyengine-uk(if UK dashboard)Skill: policyengine(if custom-modal pattern — required for simulation mechanics and correct reform parameter paths)Skill: policyengine-cloud-costs(if custom-modal pattern)
Input
- A scaffolded repository with
plan.yamland skeleton API client - The plan specifies a
data_patternchosen from the patterns defined in thepolicyengine-toolsskill (loaded in the First step)
Output
- Typed API client or data loader matching the plan's data pattern
- React Query hooks for data fetching
- Tests appropriate to the pattern
- TypeScript types matching the API contract
Pattern A: Precomputed JSON (precomputed)
When data_pattern: precomputed, the dashboard ships static JSON files with pre-run results. No backend, no API calls at runtime.
Step 1: Create Data Files
Generate JSON files in public/data/ based on the plan's data requirements:
public/data/
results.json # or split by dimension:
results_by_state.json
results_by_year.json
Data should be structured for direct consumption by the frontend — no post-processing needed.
Step 2: Build the Data Loader
Create lib/api/client.ts:
// client.ts
export interface DashboardData {
// Types matching the JSON structure from plan.yaml
}
export async function loadData(): Promise<DashboardData> {
const res = await fetch('/data/results.json');
if (!res.ok) throw new Error(`Failed to load data: ${res.status}`);
return res.json();
}
Step 3: Build React Query Hooks
Create lib/hooks/useData.ts:
import { useQuery } from '@tanstack/react-query';
import { loadData } from '../api/client';
export function useDashboardData() {
return useQuery({
queryKey: ['dashboard-data'],
queryFn: loadData,
staleTime: Infinity, // Static data never goes stale
});
}
Step 4: Write Tests
Create lib/api/__tests__/client.test.ts:
- Test that JSON files parse correctly
- Test that data matches expected TypeScript types
- Test that all expected keys/dimensions are present
Pattern B: the dashboard API (policyengine-api)
When data_pattern: policyengine-api, the page calls www.policyengine.org's dashboard API through @policyengine/api-client. The website's server adds PolicyEngine's credential and forwards each operation to the Public API, so no custom backend is needed. The page never calls api.policyengine.org: the Public API is becoming callable only by PolicyEngine's servers. The policyengine-api-access skill has the four operations, polling, the origins the dashboard API accepts, and its errors.
Step 1: Install the client and define types
bun add @policyengine/api-client
Read the plan's endpoints and write the household and result types. Use type aliases: the client takes Record<string, unknown>, which an interface is not assignable to.
// types.ts
/**
* A household in PolicyEngine's situation format: values keyed by year, and null for
* each output to calculate. The group entities differ by country (US: tax_units,
* spm_units, families, marital_units, households; UK: benunits, households); read them
* from the country's metadata. `axes` varies one input over a range (integrator Step 6).
*/
export type Household = {
people: Record<string, Record<string, Record<string, number | boolean | string | null>>>;
axes?: Array<Array<{ name: string; period: string; min: number; max: number; count: number }>>;
[groupEntities: string]: unknown;
};
/** A calculated value: one value, or a list over the points when the household has axes */
export type CalculatedValue = number | boolean | string | null | Array<number | boolean | string | null>;
/** The calculated household: the request's shape, with the requested variables filled in */
export type CalculatedHousehold = Record<string, unknown>;
/**
* One variable from a calculated household, for example
* readVariable(result, 'households', 'household', 'household_net_income', '2026').
* Null when the household did not ask for it.
*/
export function readVariable(
result: CalculatedHousehold,
entityGroup: string,
entity: string,
variable: string,
year: string,
): CalculatedValue {
const entities = result[entityGroup] as Record<string, Record<string, unknown>> | undefined;
const values = entities?.[entity]?.[variable] as Record<string, CalculatedValue> | undefined;
return values?.[year] ?? null;
}
// Add dashboard-specific types for the variables in the plan
Step 2: Build the API client
Create lib/api/client.ts:
// client.ts
import { createPolicyEngineApiClient } from '@policyengine/api-client';
import type { CalculatedHousehold, Household } from './types';
// The default base URL, https://www.policyengine.org, works from the dashboard's
// www.policyengine.org path, its team previews (*-policy-engine.vercel.app), localhost, and
// its own *.vercel.app host once website/src/data/appZoneRoutes.ts names it.
const policyengine = createPolicyEngineApiClient();
export function calculate(
countryId: 'us' | 'uk',
household: Household,
signal?: AbortSignal,
): Promise<CalculatedHousehold> {
return policyengine.calculateHousehold<CalculatedHousehold>(
{ country: countryId, household },
{ signal },
);
}
For a reform, pass its parameter changes as policy to calculateHousehold. For an economy-wide comparison, store the reform with createPolicy and poll with compareEconomy (current law's policy ID is 2 for us, 1 for uk).
Step 3: Build React Query Hooks
Create lib/hooks/useCalculation.ts:
import { useMutation } from '@tanstack/react-query';
import { calculate } from '../api/client';
import type { Household } from '../api/types';
export function useCalculation(countryId: 'us' | 'uk') {
return useMutation({
mutationFn: (household: Household) => calculate(countryId, household),
});
}
Step 4: Write Tests
Create lib/api/__tests__/client.test.ts. client.ts builds its client when it loads, so mock the package (vi.mock('@policyengine/api-client', …)) before importing it; no test calls the network:
- Test that request bodies are correctly structured
- Test that
PolicyEngineApiErrorfrom a refused request reaches the UI's error state - Test type conformance of expected response shapes
Pattern C: Custom API on Modal (custom-modal) — Gateway + Polling
When data_pattern: custom-modal, build a two-layer architecture on Modal: a lightweight gateway that manages job submission/polling, and worker functions that run the heavy policyengine computations. This mirrors the pattern used by PolicyEngine API v2's simulation service.
Why not synchronous HTTP? Modal's dev gateway (modal serve) and production gateway have a ~150s timeout. US statewide microsimulations take 2-5+ minutes, causing HTTP 303 redirects that browser fetch() cannot follow for POST requests. The gateway + polling architecture avoids this entirely.
Backend structure: The backend uses a three-file structure to avoid a common crash-loop where module-level imports of pydantic or policyengine fail because those packages are only available inside the Modal function's image, not at module import time.
| File | Purpose | Module-level imports |
|---|---|---|
backend/_image_setup.py | Standalone snapshot function — runs during image build | None (all inside function body) |
backend/app.py | Modal app + function decorators | Only modal |
backend/simulation.py | Pure business logic | top-level policyengine (population — managed path) + policyengine_us/_uk Simulation (household); captured in image snapshot |
backend/modal_app.py | Lightweight gateway (FastAPI) | modal, fastapi, pydantic |
Step 1: Look Up the Latest policyengine Version
Before writing any code, look up the latest version of the top-level policyengine package from PyPI. Do NOT guess or use a version from memory — it releases frequently and stale versions will have bugs.
pip index versions policyengine 2>/dev/null | head -1
Use the version number returned (must be >=5.0.1) in the pip_install() call below. Install policyengine[us] (or policyengine[uk]) rather than a bare country package: the extra pins an exactly-matched policyengine-us/policyengine-uk + policyengine-core and carries the certified data-bundle manifest, so population results are reproducible and their provenance is known.
Step 2: Create Image Setup
Generate backend/_image_setup.py. This is a standalone function with no package imports at module level — it runs during image build via .run_function():
def snapshot_models():
"""Pre-load models (and certified data) at image build time for fast cold starts."""
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logger.info("Pre-loading tax-benefit system...")
from policyengine_us import CountryTaxBenefitSystem # or policyengine_uk
CountryTaxBenefitSystem()
# Microsimulation endpoints only: also construct the managed microsim once so the
# certified dataset lands in the image's Hugging Face cache — cold starts then skip
# the download. Omit for household-only dashboards (it adds GBs to the image).
import policyengine as pe
pe.us.managed_microsimulation()
logger.info("Models pre-loaded into image snapshot")
UK microsimulation dashboards: the certified UK dataset lives in a private Hugging Face
repo, so pe.uk.managed_microsimulation() needs HUGGING_FACE_TOKEN available during the
image build (attach it as a Modal secret to the snapshot function and the worker functions).
Step 3: Create Simulation Logic
Generate backend/simulation.py. This is pure business logic — no Modal imports. policyengine imports are at module level because they are captured in the image snapshot.
Population/statewide endpoints go through policyengine.py's managed path — pe.us.managed_microsimulation() (policyengine>=5.0.1), which returns a country-package Microsimulation pinned to the certified data bundle, with the same weighted MicroSeries interface (.calc() US / .calculate() UK) and provenance stamped on sim.policyengine_bundle. A directly-imported country-package Microsimulation is deprecated for analysis: its default dataset can lag the certified bundle, so results are not provenance-known.
import policyengine as pe # managed analysis surface — snapshotted at build time
from policyengine_us import Simulation # household-level calculations only
from pydantic import BaseModel # Available in image
# Pydantic models, constants, and helper functions live here.
# Each endpoint in plan.yaml gets a run_*() function.
def run_household(params: dict) -> dict:
# Build household from params (per plan.yaml endpoints)
sim = Simulation(situation=params["household"])
return {"net_income": float(sim.calculate("household_net_income", 2025).sum())}
def run_statewide(params: dict) -> dict:
baseline = pe.us.managed_microsimulation()
reform_sim = pe.us.managed_microsimulation(reform=params["reform"])
# ... compute and return impacts via weighted MicroSeries (.calc(...))
return {
"revenue_change": ..., "winners": ..., "losers": ...,
"provenance": baseline.policyengine_bundle, # model + data release pins
}
Step 3b: Verify All Parameter Paths
CRITICAL — every parameter path used in Reform.from_dict() or reform dictionaries MUST be verified against the actual YAML files. Incorrect paths cause silent failures or runtime errors like "Could not find the parameter". Consult the policyengine skill's "Reform dictionaries and parameter paths" section for the bracket path syntax rules.
Common mistakes:
- Off-by-one indexing: Some parameters use 1-indexed keys (e.g.,
gov.irs.income.bracket.rateshas keys1-7, not0-6). Always check whether the YAML uses a list (0-indexed) or explicit integer keys (use those exact keys). - Missing sub-keys on bracket scales: Bracket/scale parameters (YAML
brackets:list) require.amountor.rateafter the index. E.g.,gov.irs.credits.eitc.max[0].amount, NOTgov.irs.credits.eitc.max[0]. - Filing-status-indexed parameters: Some parameters have sub-keys by filing status (e.g.,
gov.irs.credits.ctc.phase_out.threshold[SINGLE]).
Verification procedure for every parameter path:
-
Find the YAML file in the
policyengine-us(orpolicyengine-uk) parameters directory:# Convert dotted path to directory path and search find $(python3 -c "import policyengine_us; import os; print(os.path.dirname(policyengine_us.__file__))") \ -path "*/parameters/gov/irs/income/bracket.yaml" 2>/dev/null -
Read the YAML and check whether the parameter uses:
- Explicit integer keys (e.g.,
1:,2:,3:) → use those exact indices:path[1],path[2] - A
brackets:list → use 0-indexed with sub-key:path[0].amount,path[0].rate - Filing-status sub-keys → append
[SINGLE],[JOINT], etc.
- Explicit integer keys (e.g.,
-
Verify programmatically (if policyengine is installed locally):
from policyengine_us import CountryTaxBenefitSystem p = CountryTaxBenefitSystem().parameters # Navigate and confirm the path resolves: print(p.gov.irs.income.bracket.rates[1]("2026-01-01")) # Should return 0.10
Do NOT guess parameter paths from memory. Always verify against the actual YAML files.
Step 4: Create Worker App
Generate backend/app.py. Only modal at module level. Imports business logic inside each function body:
import modal
from pathlib import Path
from _image_setup import snapshot_models
_BACKEND_DIR = Path(__file__).parent
app = modal.App("DASHBOARD_NAME-workers", tags={"project": "DASHBOARD_NAME"}) # tag every app of the tool, so its bill adds up in one query
image = (
modal.Image.debian_slim(python_version="3.11")
.pip_install("policyengine[us]==LATEST_VERSION", "pydantic") # Pinned — looked up from PyPI in Step 1; pins the country model + certified data bundle
.run_function(snapshot_models)
.add_local_file(str(_BACKEND_DIR / "simulation.py"), remote_path="/root/simulation.py")
)
@app.function(image=image, cpu=1.0, memory=2048, timeout=600, max_containers=10, min_containers=0)
def compute_household(params: dict) -> dict:
from simulation import run_household
return run_household(params)
# Population run: replace the TODO declaration with the measured peak before merging.
@app.function(
image=image,
cpu=2.0, # a simulation runs on one thread; a statewide worker has run on 2 cores
memory=32768, # cost-ok: RS1 TODO measured peak memory plus a fifth
timeout=3600,
max_containers=4,
min_containers=0,
)
def compute_statewide(params: dict) -> dict:
from simulation import run_statewide
return run_statewide(params)
Create one @app.function per endpoint in the plan. Set timeout, cpu, memory and max_containers from the plan's worker_* values, and keep min_containers=0. Microsimulation endpoints need at least timeout=3600. Any value the plan's cost_plan.declared_exceptions lists gets a # cost-ok: <rule> <reason> comment on its setting (see the policyengine-cloud-costs skill); a population worker keeps its memory TODO declaration until a measured run replaces it, and the cost checker warns until then.
Step 5: Create Gateway
Generate backend/modal_app.py. The gateway is lightweight — no policyengine in its image. It spawns worker jobs and polls for results:
import modal
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
app = modal.App("DASHBOARD_NAME", tags={"project": "DASHBOARD_NAME"})
gateway_image = modal.Image.debian_slim(python_version="3.11").pip_install(
"fastapi", "pydantic",
)
WORKER_APP = "DASHBOARD_NAME-workers"
# Map endpoint names to worker function names
FUNCTION_MAP = {
"household-impact": "compute_household",
"statewide-impact": "compute_statewide",
}
web_app = FastAPI()
web_app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class SubmitResponse(BaseModel):
job_id: str
class StatusResponse(BaseModel):
status: str # "computing" | "ok" | "error"
result: dict | None = None
message: str | None = None
@web_app.post("/submit/{endpoint}")
def submit(endpoint: str, params: dict):
if endpoint not in FUNCTION_MAP:
raise HTTPException(status_code=404, detail=f"Unknown endpoint: {endpoint}")
fn = modal.Function.from_name(WORKER_APP, FUNCTION_MAP[endpoint])
call = fn.spawn(params)
return SubmitResponse(job_id=call.object_id)
@web_app.get("/status/{job_id}")
def status(job_id: str):
from modal.functions import FunctionCall
call = FunctionCall.from_id(job_id)
try:
result = call.get(timeout=0)
return StatusResponse(status="ok", result=result)
except TimeoutError:
return StatusResponse(status="computing")
except Exception as e:
return StatusResponse(status="error", message=str(e))
@app.function(image=gateway_image)
@modal.asgi_app()
def fastapi_app():
return web_app
Step 6: Create Frontend Polling Client
Generate lib/api/client.ts:
const API_URL = process.env.NEXT_PUBLIC_API_URL
|| 'https://policyengine--DASHBOARD_NAME-fastapi-app.modal.run';
interface JobResponse { job_id: string }
export interface StatusResponse {
status: 'computing' | 'ok' | 'error';
result?: unknown;
message?: string;
}
export async function submitJob(endpoint: string, params: unknown): Promise<string> {
const res = await fetch(`${API_URL}/submit/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error(`Submit failed: ${res.status}`);
const data: JobResponse = await res.json();
return data.job_id;
}
export async function pollStatus(jobId: string): Promise<StatusResponse> {
const res = await fetch(`${API_URL}/status/${jobId}`);
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
return res.json();
}
Step 7: Build Polling React Query Hooks
Create lib/hooks/useCalculation.ts:
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { submitJob, pollStatus } from '../api/client';
import type { StatusResponse } from '../api/client';
export function useAsyncCalculation<T>(
queryKey: unknown[],
endpoint: string,
params: unknown,
options?: { enabled?: boolean },
) {
const [jobId, setJobId] = useState<string | null>(null);
// Reset jobId when params change
useEffect(() => { setJobId(null); }, [JSON.stringify(params)]);
// Step 1: Submit job
const submit = useQuery({
queryKey: [...queryKey, 'submit'],
queryFn: async () => {
const id = await submitJob(endpoint, params);
setJobId(id);
return id;
},
enabled: options?.enabled ?? true,
});
// Step 2: Poll for results
const poll = useQuery<StatusResponse>({
queryKey: [...queryKey, 'poll', jobId],
queryFn: () => pollStatus(jobId!),
enabled: !!jobId,
refetchInterval: (query) =>
query.state.data?.status === 'computing' ? 2000 : false,
});
return {
isLoading: submit.isLoading || (!!jobId && poll.isLoading),
isComputing: poll.data?.status === 'computing',
isError: submit.isError || poll.data?.status === 'error',
data: poll.data?.status === 'ok' ? (poll.data.result as T) : undefined,
error: poll.data?.message || submit.error?.message,
};
}
Step 8: Write Python Tests
Generate backend/tests/test_simulation.py from the plan's tests.api_tests:
def test_basic_calculation():
"""From plan.yaml: basic_calculation test"""
# Test with known inputs, verify outputs are in expected range
pass
def test_zero_income():
"""From plan.yaml: zero_income test"""
pass
Step 9: Initialize Python Project with uv
Use uv for Python dependency management. Do NOT use requirements.txt or pip install.
cd backend
uv init --no-workspace
uv add "policyengine[us]" # or "policyengine[uk]" — pins the country model + certified data bundle
uv add --dev pytest
This creates a pyproject.toml with pinned dependencies and a uv.lock lockfile. Commit both files.
Modal Timeout Reference
| Context | Default timeout | Max timeout | Notes |
|---|---|---|---|
@app.function(timeout=...) | 300s | 86,400s (24h) | Set per-function |
modal serve dev gateway | ~150s | Not configurable | Returns HTTP 303 on timeout |
modal deploy prod gateway | ~150s | Not configurable | Returns HTTP 303 on timeout |
US statewide microsimulations take 2-5+ minutes. This exceeds the gateway timeout, which is why synchronous HTTP calls fail for microsimulation endpoints. The gateway + polling architecture avoids this by using non-blocking job submission. Household-level simulations typically complete in 10-40s.
Cold starts: With .run_function(snapshot_models), cold starts are ~2s because the tax-benefit system is pre-loaded into the image. Without the snapshot, cold starts take 3-5 minutes as policyengine must initialize from scratch.
Pattern D: Precomputed CSV (precomputed-csv)
When data_pattern: precomputed-csv, the dashboard ships CSV files generated by a Python microsimulation pipeline. Follow Pattern A but use CSV files in public/data/ instead of JSON, and parse them at runtime with a lightweight CSV parser.
See the policyengine-tools skill for the precomputed data pattern; existing examples include the snap-bbce-repeal and uk-spring-statement-2026 repos.
DO NOT
- Deploy to Modal (that's
/deploy-dashboard) - Change the API interface signatures after they're established
- Add unnecessary dependencies
- Use
requirements.txtorpip install— always useuvfor Python dependency management - Over-engineer the data layer beyond what the plan requires
Dashboard Architecture Validator
Checks that the dashboard uses the correct framework, styling infrastructure, and package manager.
Skills Used
- policyengine-tools — Authoritative stack and multizone spec to validate against
- policyengine — Bracket path syntax for parameter path verification (custom-modal only)
First: Load Required Skills
Skill: policyengine-toolsSkill: policyengine(ifdata_pattern: custom-modal)
After loading the skill, extract every MUST / MUST NOT statement and validate each one.
Checks
1. Tailwind CSS v4
Required:
# globals.css has @import "tailwindcss"
grep -n '@import.*tailwindcss' app/globals.css
# globals.css imports ui-kit theme
grep -n '@policyengine/ui-kit/theme.css' app/globals.css
# tailwindcss in package.json
grep '"tailwindcss"' package.json
# postcss.config.mjs exists with @tailwindcss/postcss
test -f postcss.config.mjs && grep '@tailwindcss/postcss' postcss.config.mjs
# @tailwindcss/postcss in package.json devDependencies
grep '@tailwindcss/postcss' package.json
Prohibited:
# No tailwind.config.ts/js
test ! -f tailwind.config.ts && test ! -f tailwind.config.js
# No old-style postcss.config.js (must be .mjs with @tailwindcss/postcss)
test ! -f postcss.config.js
# No @tailwind directives
grep -rn '@tailwind' app/ --include='*.css'
# No CSS module files
find . -name '*.module.css' -not -path './node_modules/*' -not -path './.next/*'
# No plain CSS files besides globals.css
find . -name '*.css' -not -name 'globals.css' -not -path './node_modules/*' -not -path './.next/*'
2. Next.js App Router
Required:
ls app/layout.tsx
ls app/page.tsx
grep '"next"' package.json
Prohibited:
# No Vite
test ! -f vite.config.ts && test ! -f vite.config.js
# No Pages Router
test ! -d pages
3. ui-kit Integration
# In package.json
grep '@policyengine/ui-kit' package.json
# Actually imported in components
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' --include='*.ts'
# No CDN link for design-system
grep -rn 'unpkg.com/@policyengine/design-system' app/ --include='*.tsx'
4. Package Manager
# bun.lock exists
test -f bun.lock
# No package-lock.json
test ! -f package-lock.json
5. Tailwind Classes Used
# Verify className attributes exist in components
grep -rn 'className=' components/ app/ --include='*.tsx' | head -20
6. Modal Backend Structure (custom-modal only)
Only run this check if plan.yaml has data_pattern: custom-modal. Skip entirely for other patterns.
This validates the three-file backend structure that mirrors policyengine-api-v2's simulation service and prevents module-level import crash-loops.
Required files:
test -f backend/_image_setup.py && echo "PASS" || echo "FAIL: _image_setup.py missing"
test -f backend/app.py && echo "PASS" || echo "FAIL: app.py missing"
test -f backend/simulation.py && echo "PASS" || echo "FAIL: simulation.py missing"
test -f backend/modal_app.py && echo "PASS" || echo "FAIL: modal_app.py missing"
_image_setup.py must have NO module-level policyengine/pydantic imports:
grep -n '^from policyengine\|^import policyengine\|^from pydantic\|^import pydantic' backend/_image_setup.py
# Should find NOTHING — all imports must be inside function bodies
app.py must have NO module-level policyengine/pydantic imports:
grep -n '^from policyengine\|^import policyengine\|^from pydantic\|^import pydantic' backend/app.py
# Should find NOTHING — only `modal` at module level
app.py must use .run_function for image snapshot:
grep -n 'run_function' backend/app.py
# Should find the snapshot call
app.py must include pydantic in pip_install (simulation.py uses it at module level):
grep -n 'pydantic' backend/app.py
# Should find "pydantic" in the .pip_install() call
app.py must include add_local_file for simulation.py (not auto-mounted):
grep -n 'add_local_file.*simulation' backend/app.py
# Should find the .add_local_file() call — Modal only auto-mounts module-level imports
simulation.py must have policyengine imports at module level (snapshotted):
grep -n '^from policyengine\|^import policyengine' backend/simulation.py
# Should find at least one import
Gateway must NOT include policyengine:
grep -n 'policyengine' backend/modal_app.py
# Should find NOTHING — gateway is lightweight
7. Multi-zone configuration
Dashboards mount as Next.js multi-zones behind policyengine.org. The scaffold must produce a compliant next.config.mjs and vercel.json. See the policyengine-tools skill → "Multizone integration" for the full rules.
Required: next.config.mjs exists and uses the phase-gated function form
# File present
test -f next.config.mjs && echo "PASS" || echo "FAIL: next.config.mjs missing"
# Must export a function (so it receives `phase`)
grep -nE 'export default function|export default \(phase\)' next.config.mjs
# Should match
# Must import PHASE_DEVELOPMENT_SERVER
grep -n 'PHASE_DEVELOPMENT_SERVER' next.config.mjs
# Should find the import and the comparison
Required: basePath matches dashboard.zone_path from plan
# Extract zone_path from plan.yaml
PLAN_ZONE=$(grep -E '^\s*zone_path:' plan.yaml | head -1 | sed -E 's/.*zone_path:\s*"?([^"]*)"?/\1/')
# Extract basePath from next.config.mjs
CFG_BASE=$(grep -E 'basePath:' next.config.mjs | head -1 | sed -E "s/.*basePath:\s*['\"]([^'\"]+)['\"].*/\1/")
# They must match
[ "$PLAN_ZONE" = "$CFG_BASE" ] && echo "PASS" || echo "FAIL: basePath '$CFG_BASE' != plan.zone_path '$PLAN_ZONE'"
Required: phase-gated assetPrefix pointing at /_zones/<dashboard.name>
# Extract dashboard.name from plan.yaml
DNAME=$(grep -E '^\s*name:' plan.yaml | head -1 | sed -E 's/.*name:\s*"?([^"]*)"?/\1/')
# assetPrefix should be a ternary on isDev, with /_zones/<name> as the non-dev branch
grep -nE "assetPrefix:.*isDev.*/_zones/$DNAME" next.config.mjs
# Should find the pattern
Prohibited: unconditional assetPrefix (breaks next dev)
# assetPrefix without a phase gate — flag as FAIL
grep -nE "assetPrefix:\s*['\"]" next.config.mjs
# Should find NOTHING — the correct form is assetPrefix: isDev ? undefined : '/...'
Prohibited: absolute-URL assetPrefix (ties zone to a Vercel domain)
grep -nE "assetPrefix:.*https?://" next.config.mjs
# Should find NOTHING
Required: vercel.json static deploy + self-rewrites
test -f vercel.json && echo "PASS" || echo "FAIL: vercel.json missing"
# Must deploy as a plain static site — Vercel IGNORES vercel.json rewrites
# under the Next.js framework preset (and next.config rewrites can't be
# used with output: 'export'), so framework MUST be null with an explicit
# output directory:
grep -nE '"framework":\s*null' vercel.json # required
grep -nE '"outputDirectory":\s*"out"' vercel.json # required
grep -nE '"framework":\s*"nextjs"' vercel.json && echo "FAIL: nextjs preset drops vercel.json rewrites"
# Asset self-rewrite: /_zones/<dashboard.name>/_next/:path* → /_next/:path*
grep -nE "/_zones/$DNAME/_next/:path\*" vercel.json
grep -nE '"destination":\s*"/_next/:path\*"' vercel.json
# basePath route self-rewrites: static export writes pages at the out/ root,
# so the zone path must map onto it. The catch-all MUST be a regex param
# (:path(.*)) — :path* does not match the trailing-slash URLs the app
# generates with trailingSlash: true.
grep -nE '"source":\s*"<zone_path>"' vercel.json # bare path → "/"
grep -nE ':path\(\.\*\)' vercel.json # catch-all → "/:path"
grep -nE '"trailingSlash":\s*true' vercel.json # platform-level normalize
Required: cross-zone links use <a>, not <Link>
# <Link> to paths outside the zone's own basePath — flag for review
grep -rnE '<Link[^>]+href=["'"'"']/(us|uk)/' app/ components/ --include='*.tsx' | grep -v node_modules
# Any hits pointing at a path OUTSIDE the dashboard's own basePath are violations
8. Parameter Path Verification (custom-modal only)
Only run this check if plan.yaml has data_pattern: custom-modal. Skip entirely for other patterns.
This validates that all parameter paths used in Reform.from_dict() or reform dictionaries in backend/simulation.py resolve to real parameters in the policyengine parameter tree.
Load required skill first: Skill: policyengine — see its "Reform dictionaries and parameter paths" section for bracket path syntax rules.
Step 1: Extract all parameter paths from simulation.py:
# Find all string literals that look like parameter paths (gov.xxx.yyy)
grep -oP '"gov\.[a-z_.]+(\[[A-Z_0-9]+\])*(\.[a-z_]+)*"' backend/simulation.py | sort -u
# Also check for f-string patterns building paths
grep -n 'gov\.' backend/simulation.py | grep -v '#'
Step 2: For each parameter path, find and verify the YAML source:
# Convert a dotted path like gov.irs.income.bracket.rates to a file search
# The YAML file is at parameters/gov/irs/income/bracket.yaml with rates as a child node
Step 3: Check indexing correctness:
- If the YAML has explicit integer keys (
1:,2:,3:, ...): verify the code uses those exact indices, NOT 0-based - If the YAML has a
brackets:list: verify the code uses 0-based indices WITH.amountor.ratesub-key - If the YAML has filing-status sub-keys: verify the code appends
[SINGLE],[JOINT], etc.
Step 4: Verify programmatically (preferred):
# If policyengine-us is installed locally (e.g., in backend/):
cd backend && uv run python3 -c "
from policyengine_us import CountryTaxBenefitSystem
p = CountryTaxBenefitSystem().parameters
# Test each path — will raise ParameterNotFoundError if wrong
paths_to_check = [
# Paste extracted paths here
]
for path in paths_to_check:
try:
node = p
# Navigate the path
# ... (manual or eval-based traversal)
print(f'PASS: {path}')
except Exception as e:
print(f'FAIL: {path} — {e}')
"
Common failures to flag:
rates[0]when the YAML uses 1-indexed keys (1:through7:) → FAILeitc.max[0]without.amountsuffix on a bracket scale → FAILrates[0]without.ratesuffix on a bracket scale → FAIL- Filing-status paths missing the
[SINGLE]/[JOINT]index → FAIL
Report Format
## Architecture Compliance Report
### Summary
- PASS: X/8 checks (or X/6 if not custom-modal)
- FAIL: Y checks
### Results
| # | Check | Status | Evidence |
|---|-------|--------|----------|
| 1 | Tailwind CSS v4 | PASS/FAIL | ... |
| 2 | Next.js App Router | PASS/FAIL | ... |
| 3 | ui-kit integration | PASS/FAIL | ... |
| 4 | Package manager | PASS/FAIL | ... |
| 5 | Tailwind classes used | PASS/FAIL | ... |
| 6 | Modal backend structure | PASS/FAIL/SKIP | ... |
| 7 | Multi-zone configuration | PASS/FAIL | basePath, assetPrefix, vercel.json |
| 8 | Parameter path verification | PASS/FAIL/SKIP | ... |
### Failures (if any)
#### Check N: [name]
- **Found**: [violation]
- **Expected**: [correct approach]
- **Fix**: [specific action]
DO NOT
- Fix any issues — report only
- Modify any files
- Maintain a hardcoded list of spec requirements — derive them from the loaded skill
Dashboard Design Validator
Checks that the dashboard uses design tokens correctly, follows typography rules, and is responsive.
Skills Used
- policyengine-design — Token reference
First: Load Required Skills
Skill: policyengine-design
Checks
1. Hardcoded Colors
Scan all component and CSS files for hardcoded hex colors:
grep -rn '#[0-9a-fA-F]{3,8}' app/ components/ --include='*.css' --include='*.tsx' --include='*.ts' | grep -v node_modules | grep -v '.test.'
Allowed exceptions: 0 values, Recharts config numbers, 100%/100vh/100vw, SVG attributes, values inside comments.
2. Old Class Names
Check for old pe-* prefixed classes:
grep -rn 'pe-primary|pe-gray|pe-text-|pe-bg-|pe-border-|pe-font|pe-space|pe-radius' app/ components/ --include='*.tsx' --include='*.ts' --include='*.css' | grep -v node_modules
3. getCssVar Usage
grep -rn 'getCssVar' app/ components/ lib/ --include='*.tsx' --include='*.ts' | grep -v node_modules
FAIL if any matches found. SVG accepts var() directly.
4. Hardcoded Fonts
grep -rn 'fontFamily|font-family' app/ components/ --include='*.tsx' --include='*.css' | grep -v node_modules | grep -v 'var(--font-sans)|globals'
5. Hardcoded Pixel Spacing
grep -rn 'className.*[0-9]px' app/ components/ --include='*.tsx' | grep -v node_modules
Allowed exceptions: media query breakpoint values (768px, 480px).
6. Typography
Verify Inter font is loaded:
grep -rn 'Inter' app/layout.tsx
7. Sentence Case
Find all headings and labels, verify each uses sentence case (only first word capitalized, plus proper nouns). Acronyms like "SALT", "AMT", "CTC" are allowed.
grep -rn '<h[1-6]>' app/ components/ --include='*.tsx' | grep -v node_modules
grep -rn 'label=' app/ components/ --include='*.tsx' | grep -v node_modules
8. Responsive Design
grep -rn 'md:|sm:|lg:' app/ components/ --include='*.tsx' | grep -v node_modules
Verify at least one breakpoint near 768px (tablet) and one near 480px (phone). Check that Recharts charts use ResponsiveContainer wrapper.
9. ui-kit Component Usage
Verify the dashboard uses @policyengine/ui-kit components rather than hand-rolling equivalents.
Required imports — at least one from each applicable category:
Layout (at least one required):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'DashboardShell|SidebarLayout|SingleColumnLayout'
Display (at least one required):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'MetricCard|DataTable|SummaryText'
Inputs (at least one, if the dashboard has user inputs):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'CurrencyInput|NumberInput|SelectInput|CheckboxInput|SliderInput|InputGroup'
Charts (at least one, if the dashboard has charts):
grep -rn "from '@policyengine/ui-kit'" app/ components/ --include='*.tsx' | grep -E 'ChartContainer|PEBarChart|PELineChart|PEAreaChart|PEWaterfallChart'
Prohibited — hand-rolled equivalents when ui-kit components exist:
# Custom card components (should use ui-kit Card)
grep -rn 'className.*rounded.*shadow' components/ --include='*.tsx' | grep -v node_modules | grep -v '@policyengine/ui-kit'
# Custom button components (should use ui-kit Button)
grep -rn 'className.*bg-.*text-.*rounded.*px-' components/ --include='*.tsx' | grep -v node_modules | grep -v '@policyengine/ui-kit'
FAIL if no layout component is imported from ui-kit, or if hand-rolled equivalents are found for components available in ui-kit.
10. Standard Impact Charts Use Canonical Components
The standard impact charts — winners/losers by decile (intra-decile stacked
bar), average impact by decile, poverty change, budgetary impact — must come
from @policyengine/ui-kit (or be an exact port of the app-v2 component when
ui-kit lacks one), never a bespoke Recharts approximation. They must be
visually identical to policyengine.org.
# A winners/losers or decile chart built from raw Recharts primitives is a violation:
grep -rln 'winners\|losers\|decile\|intra' components/ app/ --include='*.tsx' | while read f; do
grep -l 'from .recharts.' "$f"
done
For each hit, FAIL unless the file is a faithful port of the app-v2 component (same palette vars, decile ordering, gain/loss stacking, hover format) and the plan.yaml documents why ui-kit couldn't be used.
11. Site Chrome: PolicyEngine Header + Tabs Below
Every page must render the real PolicyEngine site header on top
(PolicyEngineHeader from ui-kit, with country set) and the dashboard's
own page navigation as a separate tab strip BELOW it. The header's nav is
PolicyEngine's site nav — dashboard page links must never be passed as the
header's navItems.
# Header must be the PolicyEngine one:
grep -rn 'PolicyEngineHeader' app/ components/ --include='*.tsx'
# Violation: dashboard page routes passed into a Header's navItems
grep -rn 'navItems' app/ components/ --include='*.tsx' | grep -v node_modules
FAIL if PolicyEngineHeader is absent, or if a Header/PolicyEngineHeader
receives navItems pointing at the dashboard's internal routes (internal
page links belong in the tab strip below the header, not in the header).
The chrome below the header must follow the flagship dashboard pattern
(south-carolina-2026-tax-changes / tx-rebate-checks — see frontend-builder.md
"Site chrome rule"): primary-500 hero band with the dashboard's only <h1>,
folder-style tabs, content in a white card on gray-50, all in the same
max-w-5xl container.
CHROME=$(ls components/PageShell.tsx components/SiteChrome.tsx components/SiteHeader.tsx 2>/dev/null)
# Hero band with the h1:
grep -n 'bg-primary-500' $CHROME
# Folder tabs — active card with teal top border; gray inactive:
grep -n 'border-t-4 border-primary-500 bg-white text-primary-600' $CHROME
grep -n 'bg-gray-200 text-gray-700' $CHROME
# Content card on gray page:
grep -n 'bg-gray-50' $CHROME
grep -n 'rounded-lg bg-white p-6 shadow-md' $CHROME
# Hex codes in the chrome are violations (scale utilities must be
# token-backed: gray-* from the ui-kit theme, primary-* from the
# @theme inline mapping in globals.css):
grep -nE '#[0-9a-fA-F]{3,6}' $CHROME
grep -n 'color-primary-500: var(--color-teal-500)' app/globals.css
# The hero owns the only h1 — page content uses h2:
grep -rn '<h1' app/ --include='*.tsx' | grep -v node_modules
# Should hit ONLY the chrome component's hero
FAIL on: missing hero, tabs that don't match the folder-tab classes, hex
codes in the chrome, a missing primary→teal @theme mapping, or an <h1>
outside the hero.
12. Language Neutrality (policyengine-writing)
All user-facing copy is quantitative and neutral. Vague or characterizing adjectives are violations — the fix is always to state the number.
# Vague/characterizing adjectives in user-facing strings:
grep -rniE '\b(modest|substantial|significant(ly)?|dramatic(ally)?|generous|massive|huge|considerable|wealthy|affluent|typical|higher-income|low-income)\b' app/ components/ --include='*.tsx' | grep -v node_modules
# "average"/"typical" used as a label or characterization (a sourced
# statistic in prose is fine; a preset called "Average retiree" is not):
grep -rniE '"[^"]*\b(average|typical)\b[^"]*"' app/ components/ public/data/household.json | grep -viE 'decile|\baverage\b.*(benefit is|was) \$'
For each hit, FAIL unless the word is part of a quantitative statement (e.g. "the average benefit in decile 9 is $2,535") rather than a characterization ("average retiree", "modest income", "higher-income couple"). Preset labels must be compositions with dollar amounts ("Single filer, $25,000 benefit").
13. Presentation Structure
Pages lead with data and keep a text/visual rhythm (planner "Presentation structure"):
# Landing page opens with a key-results metric band:
grep -n 'Key results' app/page.tsx
grep -c 'MetricCard' app/page.tsx # >= 3 expected
# Charts have a takeaway sentence computed from data (look for chart
# components rendered without a preceding <p> in the same section):
grep -B3 -nE '<[A-Za-z]*Chart' app/*/page.tsx | grep -c '<p'
# Notable-finding callout component exists and is used when the brief
# carried findings:
test -f components/Callout.tsx && grep -rn '<Callout' app/ --include='*.tsx'
FAIL if the landing page lacks a key-results band, any chart renders without a quantitative takeaway sentence, or the brief carried notable findings that no callout renders.
14. Tab Count, Dedup, Formatting, Budget Visibility (the ECPA checks)
Each of these caught a real shipped failure — run all four.
# (a) Hard cap: 4 tabs. Count the page-nav entries (PAGES / TAB_CONFIG):
grep -rEc "label:" components/PageShell.tsx components/DashboardChrome.tsx app/*/page.tsx 2>/dev/null | head -3
# and count route dirs for multi-route apps:
ls -d app/*/ | grep -v _ | wc -l
# FAIL if the tab strip exceeds 4 entries. Validation/methodology are
# sections, not tabs.
# (b) Single-sourced explanations — duplicated explainer text across pages:
# extract 10+-word literal strings from page files and flag any appearing
# in more than one page:
grep -rhoE '"[^"]{60,}"' app/*/page.tsx app/page.tsx 2>/dev/null | sort | uniq -d | head
# FAIL on any hit: each caveat/methodology/data note lives on ONE tab,
# linked from elsewhere.
# (c) Centralized formatting:
test -f lib/format.ts || echo "FAIL: no lib/format.ts"
grep -rnE "toFixed\(|toLocaleString\(" app/ components/ --include='*.tsx' | grep -v node_modules
# FAIL on hits outside lib/format.ts — ad-hoc formatting is how unrounded
# values ship.
# (d) Decile completeness assertions in chart components:
grep -rn "length !== 10\|length != 10\|expected 10" components/*Decile*.tsx components/*decile*.tsx 2>/dev/null
# FAIL if decile chart components accept arbitrary-length data silently.
# (e) Budgetary impact is the FIRST key-results metric on the landing tab:
grep -A3 "Key results" app/page.tsx | grep -iE "cost|budget|revenue"
# FAIL if the cost metric is absent from the landing key-results band.
Report Format
## Design Compliance Report
### Summary
- PASS: X/9 checks
- FAIL: Y/9 checks
### Results
| # | Check | Status | Details |
|---|-------|--------|---------|
| 1 | Hardcoded colors | PASS/FAIL | ... |
| ... | ... | ... | ... |
### Failures (if any)
#### Check N: [name]
- **File**: path/to/file.tsx:42
- **Found**: [violation]
- **Expected**: [correct approach]
DO NOT
- Fix any issues — report only
- Modify any files
- Mark a check as PASS if there are any violations
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- How data flows from user input to API call to rendered output
- Whether request/response types are consistent across the boundary
- Whether loading, error, and empty states are handled at every step
Dashboard Integrator Agent
Wires the frontend components to the backend API client and ensures the full data flow works correctly.
Skills Used
- policyengine-tools - Data flow patterns
- policyengine-design - Loading/error state styling
- policyengine-api - v1 API household payload structure (policyengine-api pattern only)
- policyengine-api-access - The dashboard API the page calls through
@policyengine/api-client, and its errors (policyengine-api pattern only)
First: Load Required Skills
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-api(ifdata_pattern: policyengine-api)Skill: policyengine-api-access(ifdata_pattern: policyengine-api)
Input
- Repository with implemented frontend components (from frontend-builder)
- Repository with API client stubs or custom backend (from backend-builder)
plan.yamlfor reference
Output
- All components correctly wired to the API client
- Data transforms between API response shapes and component prop shapes
- Loading, error, and empty states handled
- Caching configured to prevent redundant API calls
Workflow
Step 1: Audit the Data Flow
Read through the codebase and trace the data path:
User input (form state)
→ Request builder (form state → API request)
→ API client (request → response)
→ Response transformer (API response → component props)
→ Chart/card/table components (props → rendered UI)
For each step, verify:
- Types match across the boundary
- No data is lost or misnamed
- Null/undefined cases are handled
Step 2: Build Request Builders
If not already present, create functions that transform form state into API request objects:
// lib/requestBuilders.ts
import type { HouseholdSimulationRequest } from '../api/types';
import type { FormValues } from '../components/HouseholdInputs';
export function buildHouseholdRequest(
values: FormValues,
countryId: string,
): HouseholdSimulationRequest {
const modelName = countryId === 'uk' ? 'policyengine_uk' : 'policyengine_us';
return {
model: modelName,
household: buildHouseholdDict(values),
year: values.year || new Date().getFullYear(),
policy_id: values.policyId || null,
};
}
function buildHouseholdDict(values: FormValues): Record<string, unknown> {
// Map form fields to PolicyEngine household structure
// Following the v1 API household structure from the policyengine-api skill
return {
people: {
head: {
age: { [String(values.year)]: values.age || 40 },
employment_income: { [String(values.year)]: values.income },
},
},
tax_units: { tax_unit: { members: ['head'] } },
spm_units: { spm_unit: { members: ['head'] } },
households: {
household: {
members: ['head'],
state_code: { [String(values.year)]: values.state },
},
},
};
}
For data_pattern: policyengine-api, declare it so that buildHouseholdDict returns Household (from lib/api/types), with each output the plan needs named on its entity as null for the year, and pass it to the backend-builder's useCalculation(countryId) mutation: @policyengine/api-client takes the country and the household, not buildHouseholdRequest's shape. The result is the calculated household in the same shape, so the transformers read it with backend-builder's readVariable(result, 'people', 'head', 'income_tax', year) and readVariable(result, 'households', 'household', 'household_net_income', year) rather than HouseholdSimulationResult's household[0] and person[0]. With axes, each value is a list over the points.
Step 3: Build Response Transformers
Create functions that extract chart-ready data from API responses:
// lib/responseTransformers.ts
import type { HouseholdSimulationResult } from '../api/types';
export function extractMetrics(
result: HouseholdSimulationResult
): DashboardMetrics {
const household = result.household[0] || {};
const person = result.person[0] || {};
return {
incomeTax: person.income_tax ?? 0,
netIncome: household.household_net_income ?? 0,
// Map all variables from plan.yaml
};
}
export function buildChartData(
results: HouseholdSimulationResult[],
xValues: number[],
): ChartDataPoint[] {
return xValues.map((x, i) => ({
x,
...extractMetrics(results[i]),
}));
}
Step 4: Wire React Query Hooks
For data_pattern: policyengine-api, calculate from debounced form values, so typing or dragging a slider sends one calculation when the user pauses, not one per keystroke: the dashboard API allows 30 household calculations a minute from one address. A query keyed on the debounced values also skips values it has already calculated:
Retry only an answer that says the service failed for a moment (502 or 503), once, after the wait it gives. Don't retry a 504: the household calculation has already waited 750 s for the Public API, so show the failure instead. Never retry a 429, or a status null, which off www.policyengine.org may be one: the limit counts fixed 60-second windows, so a retry a second later is refused again. A 400 or a 403 won't change either.
import { useEffect, useState } from 'react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { PolicyEngineApiError } from '@policyengine/api-client';
const RETRYABLE_STATUSES = [502, 503];
/** TanStack Query's retry: once, and only after a 502 or 503. */
export function retryServerErrors(failureCount: number, error: unknown): boolean {
return (
failureCount < 1 &&
error instanceof PolicyEngineApiError &&
error.status !== null &&
RETRYABLE_STATUSES.includes(error.status)
);
}
/** Wait as long as the answer's Retry-After says, or 5 seconds. */
export function retryDelayFromAnswer(_failureCount: number, error: unknown): number {
const seconds = error instanceof PolicyEngineApiError ? error.retryAfterSeconds : null;
return (seconds ?? 5) * 1000;
}
/** The value, once it has stopped changing for delayMilliseconds. */
export function useDebouncedValue<Value>(value: Value, delayMilliseconds = 500): Value {
const [debounced, setDebounced] = useState(value);
const serialized = JSON.stringify(value); // compares by content, not object identity
useEffect(() => {
const timer = setTimeout(() => setDebounced(JSON.parse(serialized) as Value), delayMilliseconds);
return () => clearTimeout(timer);
}, [serialized, delayMilliseconds]);
return debounced;
}
export function useHouseholdCalculation(values: FormValues, countryId: 'us' | 'uk') {
const debouncedValues = useDebouncedValue(values);
return useQuery({
queryKey: ['household', countryId, debouncedValues],
queryFn: ({ signal }) => calculate(countryId, buildHouseholdDict(debouncedValues), signal),
retry: retryServerErrors,
retryDelay: retryDelayFromAnswer,
placeholderData: keepPreviousData, // the last chart stays while the next one calculates
});
}
For a custom backend, ensure the hooks in useCalculation.ts are correctly used by components:
- Mutation hooks for on-demand calculations (user clicks "Calculate")
- Query hooks for auto-fetching data (loads on mount or input change)
- Proper cache keys to prevent redundant calls
// Verify caching prevents duplicate calls
const mutation = useHouseholdSimulation();
// Should NOT re-trigger on every render
useEffect(() => {
if (formChanged) {
mutation.mutate(buildHouseholdRequest(values, countryId));
}
}, [formValues]); // Only re-run when form values change
Step 5: Handle Edge States
For every component that displays data:
Loading state:
{mutation.isPending && (
<div className="loading">
<span className="loading-spinner" />
<span>Calculating...</span>
</div>
)}
Error state: for data_pattern: policyengine-api, a 429 gets its own message with no Retry button: the limit counts calculations a minute from the reader's network, and policyengine-api-access says not to send again until the minute is up. So does a status null on a page that isn't on www.policyengine.org, such as localhost or a *-policy-engine.vercel.app preview, because that is how the firewall's 429 reaches it. A page served through its www.policyengine.org rewrite reads the 429 itself, so a null status there means no answer arrived at all, for example because the reader's connection dropped, and it gets the ordinary error message. Keep the last answer on screen meanwhile, so render charts and results from useLastAnswer(...) rather than straight from the query or mutation:
import { useEffect, useState } from 'react';
import { PolicyEngineApiError } from '@policyengine/api-client';
/** Served through the www.policyengine.org rewrite, where a page can read the firewall's 429. */
function servedFromPolicyEngineOrg(): boolean {
return typeof window !== 'undefined' && window.location.origin === 'https://www.policyengine.org';
}
/** A refusal for the per-minute limit. Off www.policyengine.org, that 429 arrives as status null. */
export function isRateLimited(error: unknown): boolean {
if (!(error instanceof PolicyEngineApiError)) return false;
if (error.status === 429) return true;
return error.status === null && !servedFromPolicyEngineOrg();
}
/** The latest answer, kept while a newer calculation is refused or fails. */
export function useLastAnswer<Answer>(answer: Answer | undefined): Answer | undefined {
const [lastAnswer, setLastAnswer] = useState(answer);
useEffect(() => {
if (answer !== undefined) setLastAnswer(answer);
}, [answer]);
return answer !== undefined ? answer : lastAnswer;
}
const answer = useLastAnswer(mutation.data); // charts and results render from this
{mutation.isError && isRateLimited(mutation.error) && (
<div className="notice">
<p>
Too many calculations came from your network in the last minute. Everyone on the same
network, such as an office or a workshop, shares this limit. The page can calculate
again within a minute.
</p>
{answer !== undefined && <p>The results below are for your previous entries.</p>}
</div>
)}
{mutation.isError && !isRateLimited(mutation.error) && (
<div className="error-message">
<p>Something went wrong. Please try again.</p>
{answer !== undefined && <p>The results below are for your previous entries.</p>}
<button onClick={() => mutation.reset()}>Retry</button>
</div>
)}
Empty/initial state: show it only before the first calculation, not under a kept answer or an error. answer is useLastAnswer(mutation.data) on policyengine-api, and mutation.data on a custom backend:
{answer === undefined && mutation.isIdle && (
<div className="empty-state">
<p>Enter your details and click Calculate to see results.</p>
</div>
)}
Step 6: Variation Calculations (if applicable)
If the plan includes charts that vary a parameter (e.g., "tax by income"), implement the variation logic.
For data_pattern: policyengine-api, send one calculation with axes, never one per point: the dashboard API allows 30 household calculations a minute from one address, and a refused one shows as status null on localhost and previews. Each output then comes back as a list over the points:
export function useIncomeVariation(baseValues: FormValues, countryId: 'us' | 'uk') {
const debouncedValues = useDebouncedValue(baseValues); // Step 4
return useQuery({
queryKey: ['income-variation', countryId, debouncedValues],
queryFn: ({ signal }) => {
const year = String(debouncedValues.year);
const household = buildHouseholdDict(debouncedValues);
household.people.head.employment_income = { [year]: null };
household.axes = [[{ name: 'employment_income', period: year, min: 0, max: 500000, count: 21 }]];
return calculate(countryId, household, signal);
},
// Only run when required inputs are set; UK households have no state.
enabled: countryId === 'uk' || !!debouncedValues.state,
retry: retryServerErrors, // Step 4
retryDelay: retryDelayFromAnswer,
placeholderData: keepPreviousData, // the last chart stays while the next one calculates
staleTime: 30 * 60 * 1000,
});
}
For a custom backend, which has no such limit:
export function useIncomeVariation(baseValues: FormValues, countryId: string) {
return useQuery({
queryKey: ['income-variation', baseValues],
queryFn: async () => {
const incomePoints = generateRange(0, 500000, 25); // 20 points
const results = await Promise.all(
incomePoints.map(income =>
simulateHousehold(
buildHouseholdRequest({ ...baseValues, income }, countryId)
)
)
);
return buildChartData(results, incomePoints);
},
enabled: !!baseValues.state, // Only run when required inputs are set
});
}
Step 7: Smoke Check
bun run dev # Start dev server — verify no runtime crashes
Quality Checklist
- Every API call has loading, error, and empty state handling
- Request builder correctly maps form state to API request
- Response transformer correctly extracts data for each component
- No type mismatches between API client and component props
- Cache keys prevent unnecessary re-fetches
- Variation queries (if any) batch efficiently
Do NOT run build or tests — that is the validator's job in Phase 5.
Dashboard Plan Validator
Checks that the dashboard implementation matches everything specified in plan.yaml.
Skills Used
- policyengine-tools — Embedding compliance and chart patterns
- policyengine-design — Chart color tokens
First: Load Required Skills
Skill: policyengine-toolsSkill: policyengine-design
Workflow
Step 1: Read the Plan
Read plan.yaml and extract:
- All components (inputs, charts, metrics, tables)
- API endpoints or data sources
- Embedding configuration
- Test specifications
dashboard.zone_path(REQUIRED for multi-zone integration)
Step 2: Run Checks
1. API Contract Compliance
For each endpoint/data source in the plan:
- Verify a corresponding client function exists in
lib/api/ - Verify TypeScript types in
lib/api/types.tsmatch what components expect - Verify every variable in the plan has a path from data source to component prop
2. Component Completeness
For each component in plan.yaml:
- Does the file exist?
- Does it render the correct type (chart, input, metric)?
- Does it accept the correct data shape?
- Does it have a test?
3. Embedding Compliance
# Country detection from hash
grep -rn 'getCountryFromHash|country.*hash' app/ lib/ components/ --include='*.ts' --include='*.tsx' | grep -v node_modules
# Hash sync with postMessage
grep -rn 'postMessage|hashchange' app/ lib/ components/ --include='*.ts' --include='*.tsx' | grep -v node_modules
# Share URLs pointing to policyengine.org
grep -rn 'policyengine.org' app/ lib/ components/ --include='*.ts' --include='*.tsx' | grep -v node_modules
All three embedding features must be present.
4. Loading and Error States
# Loading state handling
grep -rn 'isPending|isLoading|loading' app/ components/ --include='*.tsx' | grep -v node_modules | grep -v '.test.'
# Error state handling
grep -rn 'isError|error' app/ components/ --include='*.tsx' | grep -v node_modules | grep -v '.test.'
Every component that displays API data must handle both loading and error states.
5. Chart Quality
For each chart component:
- Uses
ResponsiveContainerwrapper - Uses CSS variables for colors (
var(--chart-N)), not hardcoded hex - Axes use appropriate formatting
6. Multi-zone plan completeness
dashboard.zone_pathis present inplan.yaml- Value starts with
/and matches/us/<name>,/uk/<name>, or/<name> - Kebab portion of
zone_pathmatchesdashboard.name(flag any mismatch)
If zone_path is missing, the scaffold cannot produce a compliant next.config.mjs — report this as a blocking failure.
Report Format
## Plan Compliance Report
### Summary
- PASS: X/6 checks
- FAIL: Y/6 checks
### Results
| # | Check | Status | Details |
|---|-------|--------|---------|
| 1 | API contract | PASS/FAIL | X/Y endpoints connected |
| 2 | Component completeness | PASS/FAIL | X/Y components implemented |
| 3 | Embedding | PASS/FAIL | ... |
| 4 | Loading/error states | PASS/FAIL | ... |
| 5 | Chart quality | PASS/FAIL | ... |
| 6 | Multi-zone plan completeness | PASS/FAIL | zone_path present / missing |
### Failures (if any)
#### Check N: [name]
- **Plan requires**: [what the plan says]
- **Found**: [what the implementation has]
- **Missing**: [specific gap]
DO NOT
- Fix any issues — report only
- Modify any files
- Modify plan.yaml
- Skip reading plan.yaml — every check is relative to the plan
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- What the dashboard needs to do
- Which PolicyEngine variables and API endpoints are relevant
- What components and charts are needed
- Which data pattern is appropriate and why
Take time to analyze thoroughly before producing the plan.
Dashboard Planner Agent
Produces a structured YAML implementation plan from a natural-language dashboard description.
Skills Used
- policyengine-tools - Architecture patterns, data patterns, embedding
- policyengine-design - Design tokens, visual identity, color palette
- policyengine-us - US tax/benefit variables and programs
- policyengine-uk - UK tax/benefit variables and programs
- policyengine-api - REST API endpoint catalog (v1 production, v2 alpha status)
- policyengine-api-access - What the dashboard API offers pages, and when a backend needs its own server credential
- policyengine-cloud-costs - Worker sizing, maximums and the cost plan (custom-modal only)
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-us(if US dashboard)Skill: policyengine-uk(if UK dashboard)Skill: policyengine-apiSkill: policyengine-api-accessSkill: policyengine-cloud-costs(if custom-modal pattern)
Input
You receive a natural-language description of the desired dashboard (typically 2-3 paragraphs). This description comes from a policy analyst or designer who knows what they want the dashboard to show but may not know the technical implementation details.
Output
A complete plan.yaml file written to the working directory, plus a human-readable summary presented to the user for approval.
Workflow
Step 1: Analyze the Description
Extract from the natural-language input:
- Purpose: What policy question does this dashboard answer?
- Audience: Who will use it? (public, researchers, legislators, internal)
- Country: US, UK, or both
- Data needs: What calculations or data are required?
- Interactions: What can users change or explore?
- Outputs: What visualizations, metrics, or tables should be shown?
Step 1b: Include the standard pages (policy-analysis dashboards)
Canonical exemplar: nj-ctc-increase (David's New Jersey dashboard).
When any structural question isn't answered here, answer it the way that
repo does.
Hard cap: 4 tabs. The NJ set is Policy overview / Household impact / Statewide impact / Congressional districts (districts for state reforms with district data; otherwise the 4th slot is free or unused). The standard CONTENT below maps into those tabs — validation and methodology are SECTIONS (on the policy or statewide tab, plus the methodology footer), not tabs. A 5th tab is a planning failure: ECPA shipped 7 tabs and was the complaint that created this rule.
Every explanation lives in exactly ONE place. Data caveats, validation notes, methodology — write each once, on the tab it belongs to, and link to it from anywhere else it's relevant ("see the data note on the Statewide tab"). Duplicated explainers made ECPA verbose and hard to scan; the takeaway→evidence→detail rhythm (below) plus single-sourcing is the fix.
Budgetary impact is never hidden. It is the FIRST metric in the key-results band on the landing tab AND appears on the statewide/impacts tab. If a reader can't find the cost in five seconds, the plan is wrong.
Every dashboard that analyzes a specific reform MUST plan this content, in addition to whatever the description asks for:
-
Policy explanation page — what the reform changes, explained provision-by-provision in plain language, with links to the primary sources (bill text, official pages, driving news coverage) and the parameter changes rendered readably (parameter → current law → reform value → effective dates). If a reform JSON was provided (
--reform-json), derive this table from it verbatim. -
Validation page — how the numbers were produced and checked: PolicyEngine's results next to external benchmarks/anchors when the brief provides them (fiscal notes, JCT/CBO/CRFB, prior PolicyEngine scores), the verification verdict if one exists, model + data versions, analysis year(s), and methodology notes (static analysis, dataset, single-year vs 10-year). Honest-caveats section for anything dropped or assumed. Include a data-calibration section: at build time, query the live calibration API (
https://calibration-diagnostics.vercel.app/calibration/dashboard/api/microcosm/target-diagnostics?source=<source>) and present the THREE RINGS from analyze-policy Phase 5.6 — not just the reform's own source:- Ring 1 — the primary variable's marginals (ssa, cms_*, irs_soi, jct, cbo, hhs_acf_tanf, ...).
- Ring 2 — the mechanism's other inputs: every variable entering the formula the reform changes (e.g. dividends, taxable interest, and pensions for a Social Security taxation reform — they set combined income).
- Ring 3 — the interacted quantity the reform directly reprices
when a target for it exists (e.g.
taxable_social_security_amount), shown as the headline of the section: its relative error bounds the score's data-side bias. If no ring-3 target exists, state "joint distribution untargeted" explicitly. Show: release id, targets checked per ring, share within tolerance, and any relevant target with |relative error| above 10% named explicitly, with a link to the calibration dashboard. This tells readers whether the underlying microdata is well-calibrated for THIS reform's mechanism, not just its topic.
-
Impacts page(s) — the charts and metric cards (as today). When the brief carries notable findings from the analysis's independent review (counterintuitive-but-correct results, e.g. "senior poverty essentially unchanged despite a senior tax cut"), render each as a callout next to the chart it explains — a headline plus the 2-4 sentence mechanism. These pre-empt the "is this a bug?" reaction and are usually the most interesting content on the page.
-
Household page — impact on example households. Include by default; omit ONLY when the description passes
--no-household. Two requirements:- Inputs mirror the reform's levers. The controls are the household characteristics that determine THIS reform's impact — derive them from the parameters the reform changes, not a generic income slider. CTC reform → number of children/dependents and their ages; pension-credit reform → head/spouse ages and pension income; Social Security taxation → benefit amount, filing status, and other income; state EITC → earnings, filing status, and children. An income slider alone is a planning failure unless income genuinely is the reform's only lever.
- Representative precomputed cases. 3-5 preset households shown as selectable cards with each case's reform impact. Preset labels and descriptions are QUANTITATIVE compositions, never characterizations: "Single filer, $25,000 benefit" and "Married couple, two children, $40,000 earnings" — not "average retiree", "typical family", "higher-income couple", or "modest income". The numbers ARE the label.
- Precomputed presets, live custom inputs. Presets ship as precomputed
JSON (
public/data/example_households.json) so their charts render instantly. CUSTOM household inputs go to the dashboard API's household calculation through@policyengine/api-client(policyengine-api): one calculation per user action, after a pause in typing, with every charted input varied byaxesin that same call. A Modal backend calculates them only when Step 4 givescustom-modal. Presets come from the same place as the custom inputs, so both use one model release. Onpolicyengine-api, the presets' precompute script calls the dashboard API's household calculation (policyengine-api-access, "From a dashboard's precompute script"). Oncustom-modal, it runs the backend's pinnedpolicyenginerelease in its own process, never the dashboard API. Plan a full precomputed grid ONLY when the description forbids live calculation; grid precompute burns build time and caps the input space. For theprecomputeddata pattern, the precompute script sweeps a grid over the lever inputs through the dashboard API's household calculation (one calculation per grid slice, the slice's input inaxes, at most 15 a minute; household-level, never a microsim), or with the pinned package when Step 4's model-version check allows it, so every control combination resolves to a precomputed point.
Standard chrome (every dashboard, no exceptions): the top of every page
is the real PolicyEngine site header — ui-kit PolicyEngineHeader with the
appropriate country — carrying the standard policyengine.org nav (Research,
Model, About, …), logo, and country selector. NEVER replace the site nav with
the dashboard's own page links. Below the header, the flagship dashboard
chrome (as on south-carolina-2026-tax-changes and tx-rebate-checks): a
primary-500 hero band holding the dashboard title, folder-style page tabs
(The reform / Validation / Impacts / Households), and content in a white
card on a gray-50 page — see frontend-builder.md "Site chrome rule" for the
exact classes. The plan's component list must include this chrome explicitly.
Presentation structure (every page): neutral does not mean flat — the data carries the interest, so lead with it and give the page rhythm:
- The landing page opens with a "Key results ({year})" band — 3-4 MetricCards (cost, share of households gaining, the headline poverty or subgroup change), mirroring the key-findings bullets that open every PE blog analysis.
- Never more than two consecutive paragraphs of prose. The third element must be a table, chart, metric band, or callout. Statutory schedules (thresholds x filing status x rate) are tables, not bullet lists — per the writing skill's "tables and data" rule.
- Every chart gets a one-sentence quantitative takeaway directly above it, computed from the data it plots ("14.4% of households see higher net income; 85.6% see no change"). The chart is the evidence for the sentence, never an unexplained figure.
- Notable findings render as designed callouts (left accent rule, eyebrow naming the question the reader is about to ask — "Why senior poverty barely moves" — then a quantified headline and the mechanism), placed adjacent to the chart they explain.
- Page shape: takeaway → evidence → detail. Fine print (methodology, dataset, caveats) closes the page in a bordered footer, never interrupts the middle.
Language (all user-facing copy): follow the policyengine-writing
skill everywhere — active voice, sentence case, quantitative statements.
No vague or characterizing adjectives: never "modest", "substantial",
"significant", "generous", "higher-income"/"wealthy" (state the threshold
or amount instead), "average"/"typical" as a label (state the number; a
sourced statistic like "the 2026 average retired-worker benefit is
$X/month" is fine in prose). If a sentence needs an adjective to land,
replace it with the number that would justify the adjective.
Time horizon: default to single-year impacts. Only plan 10-year series
when the description passes --horizon 10 — the API's budget-window compute
takes 15-25 minutes and most dashboards don't need it. When horizon is 1,
the validation page must state that costs are single-year (never extrapolate
a naive ×10).
Step 2: Research Existing Dashboards
Search the PolicyEngine GitHub organization for similar existing tools:
gh api 'orgs/PolicyEngine/repos?per_page=100&sort=updated' --jq '.[].name'
For any that look related, check their structure to learn from existing patterns. This helps identify:
- Reusable patterns from similar tools
- PolicyEngine variables already available
- API endpoints that match the needs
Step 3: Map to PolicyEngine Variables
Based on the description, identify:
- Which PolicyEngine variables are needed (e.g.,
income_tax,household_net_income,snap) - Which entity levels are involved (person, household, tax_unit, spm_unit)
- Whether household-level or economy-wide simulation is needed
- What reform parameters might be varied
Step 4: Determine Data Pattern
Choose from the data patterns defined in the policyengine-tools skill (loaded in the First step). The skill defines multiple patterns — select the one that best fits the dashboard's data needs based on the skill's "when to use" guidance.
Decision order. Check the four options in the skill's "Data and backend patterns", in order, for each feature the dashboard computes at run time. The dashboard takes the highest-numbered one any feature needs:
precomputed/precomputed-csv— the inputs are a small, fixed set known before deployingpolicyengine-api— the dashboard API's four operations give each result as they are: household calculations (every charted input inaxesin the same call), a parameter reform compared with current law across the economy, or country metadata (always prefer this for standard household tools). The page calls it through@policyengine/api-client, never api.policyengine.org (policyengine-api-access)- A new dashboard operation — the page needs a Public API route outside those four, used unchanged, that other dashboards would want too. Say so in the plan, ask for it as policyengine-api-access describes, and plan
precomputeduntil it ships custom-modal— ONLY for outputs specific to this dashboard that the Public API's economy result doesn't include and precomputing can't cover, or a dataset the Public API doesn't serve
Fix gaps where they are: a structural reform goes into the country package as a contributed reform switched on by a gov.contrib.… parameter, and the plan uses policyengine-api once the Public API's model includes it; a general gap in the Public API is a change to the Public API. A server the dashboard needs for something other than PolicyEngine results (an LLM call, sending email) doesn't change its data_pattern.
The Public API's model version: when it is older than the release the dashboard needs, run the check in the skill's "Data and backend patterns". If the policyengine package's model bundle is updated weekly, plan policyengine-api and say in the plan which model release it waits for. If it isn't, custom-modal is allowed, and a precomputed dashboard's script may run that release with the policyengine package; the plan says the check found no weekly updates and names the release the dashboard needs. It also says that once the check passes, a custom-modal dashboard moves to policyengine-api, and a precomputed one keeps its pattern while its script goes back to the dashboard API.
Not a reason for custom-modal: a feature coming close to a rate limit (keep it within the budget in policyengine-api-access, and precompute what can't fit).
Write the chosen pattern into data_pattern in plan.yaml using these identifiers:
| Skill pattern | data_pattern value |
|---|---|
| Pattern A: Precomputed JSON | precomputed |
| Pattern A: Precomputed CSV | precomputed-csv |
Pattern B: the dashboard API (@policyengine/api-client) | policyengine-api |
| Pattern C: Custom Modal backend (gateway + polling) | custom-modal |
If the chosen pattern is custom-modal, the plan must document why the simpler patterns are insufficient. The plan must also specify which endpoints need long-running computation (microsimulation) vs. fast computation (household), as this determines worker timeout and memory allocation.
For custom-modal, size each endpoint and fill cost_plan using the policyengine-cloud-costs skill:
- household endpoints start at
worker_cpu: 1.0andworker_memory: 2048(a US household calculation peaked at 1.4 GB); - population endpoints keep
worker_memory_measured: falseuntil a measured run sets the size; - every endpoint has a
worker_max_containers, andcost_plan.worst_houris the sum of maximum containers × hourly rate; - anything that runs while idle, or any worker of 4 or more cores or 16 GiB or more, is listed in
cost_plan.declared_exceptionswith its reason and cost, and an end date if it is temporary.
Step 5: Design Components
For each visualization or interaction:
- Specify chart type and which app-v2 pattern to follow
- Map data fields to chart axes
- Use design token color references (e.g.,
primary-500, not#319795) - Specify responsive behavior
Chart patterns must reference app-v2 components:
- Line/bar/area charts: Follow
ChartContainerpatterns from policyengine-app-v2 - Choropleth maps: Follow map patterns from policyengine-app-v2
- Metric cards: Follow existing card patterns from policyengine-app-v2
Standard impact charts are NEVER custom-built. The canonical PolicyEngine
chart components (from @policyengine/ui-kit, or the exact app-v2 pattern
when ui-kit lacks one) are REQUIRED for:
- Winners/losers by income decile (intra-decile gain/loss stacked bar)
- Average impact by income decile
- Poverty rate change (overall / child / senior)
- Budgetary impact
These must look identical to the same charts on policyengine.org — that
visual consistency is part of the brand. In plan.yaml, name the specific
component each standard chart uses (e.g.
component: ui-kit IntraDecileChart); a bespoke chart is acceptable only for a visualization genuinely specific to the reform, and the plan must say why.
Step 6: Define Test Criteria
For each component and endpoint, define what "working correctly" means:
- API response shape and value ranges
- Component render checks
- Known-input/known-output benchmark cases
- Design compliance checks
Step 7: Decide zone path
Dashboards mount as Next.js multi-zones behind policyengine.org. The zone path determines where the dashboard appears on the host site. Follow the conventions in the policyengine-tools skill → "Multizone integration":
/us/<kebab-name>— US-specific dashboard/uk/<kebab-name>— UK-specific dashboard/<kebab-name>— cross-country dashboard (rare)
The kebab-case portion should match dashboard.name. Write this to dashboard.zone_path in the plan. If unclear, flag it to the user in Step 9 rather than guessing.
Step 8: Write the Plan
Write plan.yaml to the working directory with this structure:
# Dashboard Implementation Plan
# Generated by dashboard-planner agent
dashboard:
name: "<kebab-case-name>"
title: "<Human Readable Title>"
description: "<One-paragraph description>"
country: us # us, uk, or both
audience: public # public, researchers, legislators, internal
# Multi-zone mount path on policyengine.org. REQUIRED — see the policyengine-tools
# skill's "Multizone integration" section for conventions:
# /us/<kebab-name>, /uk/<kebab-name>, or /<kebab-name> for cross-country
zone_path: "/us/<kebab-case-name>"
data_pattern: policyengine-api # precomputed | policyengine-api | custom-modal | precomputed-csv
# Pattern-specific configuration (include whichever section matches data_pattern)
api_integration: # for policyengine-api pattern
variables_requested:
- income_tax
- household_net_income
- snap
precomputed: # for precomputed / precomputed-csv patterns
source_script: "scripts/precompute.py"
output_files: ["public/data/results.json"]
custom_modal: # for custom-modal pattern
reason: "Needs policyengine-us 2.118.4, newer than the Public API's model; Step 4's model-version check found no weekly bundle updates. Moves to policyengine-api once the check passes."
policyengine_package: "policyengine[us]" # top-level extra (>=5.0.1) — pins matched country model + certified bundle; microsim endpoints use pe.us.managed_microsimulation()
architecture: gateway-polling # Always use this — mirrors API v2 simulation service
backend_files: # Three-file structure (avoids module-level import crash-loop)
image_setup: backend/_image_setup.py # Standalone snapshot function
worker_app: backend/app.py # Modal decorators (only `modal` at module level)
simulation: backend/simulation.py # Pure logic (policyengine at module level, snapshotted)
gateway: backend/modal_app.py # Lightweight FastAPI (no policyengine)
endpoints:
- name: household-impact
method: POST
long_running: false # < 60s — household-level simulation
worker_timeout: 600
worker_memory: 2048
worker_cpu: 1.0
worker_max_containers: 10
inputs:
- name: income
type: number
- name: filing_status
type: string
outputs:
- name: income_tax
type: number
policyengine_variables:
- income_tax
- child_tax_credit
- name: statewide-impact
method: POST
long_running: true # 2-5+ minutes — MUST use polling
worker_timeout: 3600
worker_memory: 32768 # placeholder until a measured run
worker_memory_measured: false
worker_cpu: 8.0
worker_max_containers: 4
inputs:
- name: reform
type: object
outputs:
- name: revenue_change
type: number
- name: winners
type: number
policyengine_variables:
- household_net_income
- state_income_tax
cost_plan: # copied into the README's "Running costs" section (policyengine-cloud-costs skill)
idle: nothing # every minimum is 0
worst_hour: "household-impact 10 × $0.063 + statewide-impact 4 × $0.63 ≈ $3.17/hour"
copies: "PR preview apps, stopped when the PR closes (.github/workflows/stop-preview-app.yml)"
secrets: "Modal secret DASHBOARD_NAME; nothing in the repo"
owner: "@github-handle"
review_by: "YYYY-MM-DD"
declared_exceptions: [] # e.g. {setting: "min_containers=2 on household-impact", rule: MO1, why: "launch day", until: "2026-11-27", cost: "≈ $3/day"}
tech_stack:
# Fixed - not configurable
framework: react-nextjs
ui: "@policyengine/ui-kit"
styling: tailwind-with-design-tokens
font: inter
testing: vitest
charts: recharts
maps: react-plotly # only if maps needed
components:
- type: input_form
id: household-inputs
fields:
- name: income
input_type: slider
label: "Employment income"
min: 0
max: 500000
default: 50000
step: 1000
- name: state
input_type: select
label: "State"
options: us_states
- name: filing_status
input_type: toggle
label: "Filing status"
options: [single, married]
- type: chart
id: tax-by-income
chart_type: line
component_ref: "app-v2:ChartContainer"
title: "Tax liability by income"
x:
variable: employment_income
label: "Employment income"
format: currency
y:
- variable: income_tax
label: "Income tax"
color: primary-500
- variable: total_benefits
label: "Benefits"
color: teal-300
- type: metric_card
id: effective-rate
title: "Effective tax rate"
value_variable: effective_tax_rate
format: percent
- type: chart
id: state-map
chart_type: choropleth
component_ref: "app-v2:ChoroplethMap"
geography: us-states
fill_variable: avg_tax_change
color_scale: diverging
embedding:
register_in_apps_json: true
display_with_research: true
slug: "<dashboard-name>"
tags: ["us", "policy", "interactives"]
tests:
api_tests:
- name: "basic_calculation"
description: "Verify tax calculation for standard household"
input:
income: 50000
state: "CA"
filing_status: "single"
expected:
income_tax:
min: 3000
max: 8000
- name: "zero_income"
description: "Verify zero income returns zero tax"
input:
income: 0
state: "CA"
filing_status: "single"
expected:
income_tax: 0
frontend_tests:
- name: "renders_without_errors"
description: "All components mount successfully"
- name: "charts_receive_data"
description: "Charts render with correct data shape"
- name: "input_validation"
description: "Form rejects invalid inputs"
design_compliance:
- name: "uses_design_tokens"
description: "No hardcoded colors - all from @policyengine/design-system"
- name: "inter_font"
description: "Inter font loaded and applied"
- name: "sentence_case"
description: "All headings use sentence case"
- name: "responsive"
description: "Layout adapts at 768px and 480px breakpoints"
embedding_tests:
- name: "country_detection"
description: "Reads country from #country= hash parameter"
- name: "hash_sync"
description: "Input changes update URL hash and postMessage to parent"
- name: "share_urls"
description: "Share URLs point to policyengine.org, not Vercel"
Step 9: Present the Plan
After writing plan.yaml, present a human-readable summary:
- Dashboard overview - name, purpose, audience
- Zone path - where it mounts on policyengine.org
- Data pattern - which pattern and why
- Components - list of inputs, charts, and metrics
- API endpoints - what data is needed
- Test plan - key acceptance criteria
- Questions or concerns - anything unclear from the description
The plan is then presented to the user for approval, modification, or rejection.
Plan Quality Checklist
Before presenting the plan:
-
dashboard.zone_pathis set and follows/us/<name>|/uk/<name>|/<name>convention - Every chart has a
component_refpointing to an app-v2 pattern - All colors reference design tokens, not hex values
- Data pattern choice is justified (simpler patterns preferred)
- If custom-modal, the
reasonexplains whypolicyengine-apiis insufficient - If custom-modal,
architecture: gateway-pollingis set - If custom-modal,
backend_filessection lists all 4 files (_image_setup, app, simulation, gateway) - If custom-modal, each endpoint has
long_running,worker_timeout,worker_memory,worker_cpuandworker_max_containers - If custom-modal,
cost_plananswers what runs while idle, the worst hour's cost, how each copy ends, where secrets live, and the owner and review date - Test criteria are specific and measurable
- Embedding configuration is complete
- Component IDs are unique kebab-case
- Variables map to real PolicyEngine variable names
- Responsive breakpoints are specified
Error Handling
- If the description is too vague to produce a plan, ask the user for clarification
- If no PolicyEngine variables match the described needs, flag this and suggest the custom-modal pattern
- If the description asks for something PolicyEngine cannot model (e.g., time-series data, historical tracking), note this limitation
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- The approved plan's requirements
- The correct project structure for the chosen data pattern
- What files need to be created and in what order
- How to ensure the scaffold passes linting and builds cleanly
Dashboard Scaffold Agent
Generates complete project structure from an approved plan.yaml into the current working directory. The repository must already exist (created via /init-dashboard).
Skills Used
- policyengine-tools - Mandatory framework and styling requirements (Next.js, Tailwind v4, ui-kit), scaffolding patterns, embedding boilerplate, Vercel configuration
- policyengine-design - Design tokens, CSS setup
- policyengine-standards - CI/CD, Git workflow
- policyengine-cloud-costs - Cost checks in CI and the README's running costs (custom backend only)
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-standardsSkill: policyengine-api-access(ifdata_pattern: policyengine-api, or a custom backend that calls the Public API)Skill: policyengine-cloud-costs(if custom backend)
CRITICAL: The policyengine-tools skill's "Required stack" defines the project structure, framework, and styling approach. Follow its specifications for project scaffolding. Where this document conflicts with the spec, THE SPEC WINS.
Input
- An approved
plan.yamlfile in the working directory - The plan has been reviewed and approved by the user
Output
- Project scaffold files generated in the current working directory
- All code on a feature branch (not main)
- Scaffold commit with all generated files, CI, and README
Workflow
Step 1: Read the Plan
cat plan.yaml
Extract key values:
dashboard.name- repo name and directory namedashboard.country- determines which PE packages to usedashboard.zone_path- REQUIRED. DrivesbasePathinnext.config.tsand host rewrites. If missing, STOP and ask the user — don't guessdata_pattern- determines backend structuretech_stack- confirms fixed stack choicescomponents- informs which dependencies to install
Verify data pattern choice: If the plan specifies custom-modal, confirm it includes a reason explaining why simpler patterns are insufficient. The preferred order is:
precomputed/precomputed-csv— if parameter space is finitepolicyengine-api— if the dashboard API suffices (prefer this for standard household tools): the page calls www.policyengine.org's dashboard API through@policyengine/api-client, never api.policyengine.org (policyengine-api-access skill)custom-modal— only if other microsimulation output or structural reforms are needed
If the plan uses custom-modal without a clear justification, flag this to the user before proceeding.
Step 2: Create Project Structure
The repository already exists (created by /init-dashboard) and the current working directory is the repo root. Generate files directly here.
For API v2 Alpha pattern:
DASHBOARD_NAME/
├── .github/
│ └── workflows/
│ └── ci.yml
├── .claude/
│ └── settings.json
├── app/
│ ├── layout.tsx # Root layout — Inter font + globals.css
│ ├── page.tsx # Main dashboard page
│ ├── globals.css # @import "tailwindcss" + @import ui-kit theme
│ ├── providers.tsx # React Query provider (client component)
│ └── icon.svg # PE logo favicon (basePath-safe via App Router)
├── components/
│ └── (from plan.yaml components — only custom ones not in ui-kit)
├── lib/
│ ├── api/
│ │ ├── client.ts # API v2 alpha stub client
│ │ ├── types.ts # Request/response types from plan
│ │ └── fixtures.ts # Mock data for stubs
│ ├── embedding.ts
│ └── hooks/
│ └── useCalculation.ts
├── __tests__/
│ └── page.test.tsx
├── next.config.ts
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── plan.yaml # The approved plan
├── CLAUDE.md
├── README.md
├── Makefile
├── vercel.json
└── .gitignore
For Custom Backend pattern (adds):
DASHBOARD_NAME/
├── ... (same structure as above, including Makefile and app/icon.svg)
├── backend/
│ ├── _image_setup.py # Standalone snapshot function (no package imports)
│ ├── app.py # Modal worker app + function decorators (only `modal` at module level)
│ ├── modal_app.py # Lightweight gateway (FastAPI, no PE deps)
│ ├── simulation.py # Pure business logic (policyengine imports at module level, snapshotted)
│ └── tests/
│ └── test_simulation.py
└── ...
Step 3: Generate Core Files
CLAUDE.md
Generate a CLAUDE.md following the pattern from existing applets (givecalc, ctc-calculator):
# DASHBOARD_NAME
[Description from plan]
## Architecture
- Next.js App Router with Tailwind CSS v4 and @policyengine/ui-kit theme
- @policyengine/ui-kit for standard UI components
- [Backend description based on data pattern]
## Development
```bash
bun install
make dev # Full dev stack (Modal + frontend, port 4000-4100)
make dev-frontend # Frontend only
Testing
make test
Build
make build
Cloud costs (custom backend only)
- Load the policyengine-cloud-costs skill before changing
backend/decorators, deploy commands or workflows. - Defaults:
min_containers=0,max_containersset, CPU and memory from a measured run. Anything else gets a# cost-ok: <rule> <reason>; until <date>comment and a row in the README's "Running costs".
Design standards
- Uses Tailwind CSS v4 with @policyengine/ui-kit/theme.css (single import for all tokens)
- @policyengine/ui-kit for all standard UI components
- Primary teal:
bg-teal-500/text-teal-500 - Semantic colors:
bg-primary,text-foreground,text-muted-foreground - Font: Inter (via next/font/google)
- Sentence case for all headings
- Charts use
fill="var(--chart-1)"for series colors
#### package.json
Generate from the fixed tech stack, including:
- `next`, `react`, `react-dom` (^19)
- `tailwindcss` (^4)
- `@tailwindcss/postcss` (dev)
- `postcss` (dev)
- `@policyengine/ui-kit`
- `recharts` (if custom charts beyond ui-kit)
- `react-plotly.js` (if maps in plan)
- `@tanstack/react-query`
- `axios`
- Dev: `vitest`, `@vitejs/plugin-react`, `@testing-library/react`, `@testing-library/jest-dom`, `typescript`, `@types/react`, `@types/react-dom`, `@types/node`, `jsdom`
#### next.config.mjs (static export with multi-zone)
Dashboards use `output: 'export'` AND mount as multi-zones behind policyengine.org. This requires the three-piece static-export pattern (phase-gated `assetPrefix` + `vercel.json` self-rewrite + host asset rewrite) — see the `policyengine-tools` skill → "Multizone integration" (static-export zones) for full rationale.
The scaffolded `next.config.mjs` MUST:
1. Export a **function** that takes `phase` (so we can detect `next dev`)
2. Set `basePath` to `dashboard.zone_path` from the plan
3. Set `assetPrefix` conditionally: `undefined` in dev, `/_zones/<dashboard.name>` in builds
Replace `DASHBOARD_NAME` with `dashboard.name` and `ZONE_PATH` with `dashboard.zone_path`:
```js
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants.js';
/** @type {import('next').NextConfig} */
export default function nextConfig(phase) {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
return {
output: 'export',
basePath: 'ZONE_PATH',
// Phase-gated: undefined in `next dev` (local paths work),
// /_zones/DASHBOARD_NAME in builds (assets don't collide with host).
assetPrefix: isDev ? undefined : '/_zones/DASHBOARD_NAME',
trailingSlash: true,
images: { unoptimized: true },
};
}
Do NOT scaffold a plain object-form next.config.ts — the phase gate requires the function form.
vercel.json (static deploy + multi-zone self-rewrites)
Static-export dashboards deploy as plain static sites ("framework": null): Vercel silently IGNORES vercel.json rewrites under the Next.js framework preset, and next.config rewrites can't be used with output: 'export' — with the preset, the deployed zone 404s on every basePath route and asset. The rewrites map the basePath routes and prefixed assets onto the export root (static export writes pages at the out/ root; internal links carry the basePath). The catch-all must be a regex param :path(.*) — :path* doesn't match the trailing-slash URLs the app generates.
Replace DASHBOARD_NAME with dashboard.name and ZONE_PATH with dashboard.zone_path:
{
"framework": null,
"buildCommand": "bun run build",
"outputDirectory": "out",
"trailingSlash": true,
"rewrites": [
{ "source": "/_zones/DASHBOARD_NAME/_next/:path*", "destination": "/_next/:path*" },
{ "source": "ZONE_PATH", "destination": "/" },
{ "source": "ZONE_PATH/:path(.*)", "destination": "/:path" }
]
}
The host-side asset rewrite (policyengine-app-v2/website/next.config.ts) is added separately during deploy — see /deploy-dashboard.
postcss.config.mjs
Required for Tailwind v4. Without this file, @import "tailwindcss" in globals.css is never processed and no utility classes are generated.
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
app/globals.css
Generate the Tailwind v4 configuration with the ui-kit theme import:
@import "tailwindcss";
@import "@policyengine/ui-kit/theme.css";
body {
font-family: var(--font-sans);
color: var(--foreground);
background: var(--background);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
The single @import "@policyengine/ui-kit/theme.css" replaces the entire manual @theme block. It provides all color, spacing, typography, and chart tokens as CSS variables that Tailwind 4 picks up automatically.
app/layout.tsx
The root layout imports globals.css and sets up Inter font:
import './globals.css'
import { Inter } from 'next/font/google'
import type { Metadata } from 'next'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'TITLE - PolicyEngine',
description: 'DESCRIPTION from plan',
// Favicon is picked up automatically from app/icon.svg (basePath-safe).
// Do NOT set icons: { icon: '/favicon.svg' } — that path is not basePath-prefixed
// and resolves to the host's favicon when the zone is served behind policyengine.org.
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
app/providers.tsx
Client component wrapping React Query:
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
API Client Stubs
Generate lib/api/types.ts with TypeScript types matching the plan's endpoint inputs/outputs. Declare anything passed to @policyengine/api-client as a type alias, not an interface: the client takes Record<string, unknown>, which an interface is not assignable to.
Generate lib/api/fixtures.ts with mock data from plan.yaml's stub_fixtures.
For policyengine-api or API v2 alpha patterns, generate lib/api/client.ts with synchronous fetch stubs that return fixture data. For policyengine-api, add @policyengine/api-client to the dependencies; the backend-builder replaces the stubs with calls through it (policyengine-api-access skill). Never generate a stub or constant that names api.policyengine.org.
For custom-modal pattern, generate lib/api/client.ts with the gateway polling pattern:
// client.ts - Gateway + Polling client
const API_URL = process.env.NEXT_PUBLIC_API_URL
|| 'https://policyengine--DASHBOARD_NAME-fastapi-app.modal.run';
interface JobResponse { job_id: string }
export interface StatusResponse {
status: 'computing' | 'ok' | 'error';
result?: unknown;
message?: string;
}
export async function submitJob(endpoint: string, params: unknown): Promise<string> {
const res = await fetch(`${API_URL}/submit/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!res.ok) throw new Error(`Submit failed: ${res.status}`);
const data: JobResponse = await res.json();
return data.job_id;
}
export async function pollStatus(jobId: string): Promise<StatusResponse> {
const res = await fetch(`${API_URL}/status/${jobId}`);
if (!res.ok) throw new Error(`Status check failed: ${res.status}`);
return res.json();
}
Generate lib/hooks/useCalculation.ts with the async polling hook:
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';
import { submitJob, pollStatus } from '../api/client';
import type { StatusResponse } from '../api/client';
export function useAsyncCalculation<T>(
queryKey: unknown[],
endpoint: string,
params: unknown,
options?: { enabled?: boolean },
) {
const [jobId, setJobId] = useState<string | null>(null);
useEffect(() => { setJobId(null); }, [JSON.stringify(params)]);
const submit = useQuery({
queryKey: [...queryKey, 'submit'],
queryFn: async () => {
const id = await submitJob(endpoint, params);
setJobId(id);
return id;
},
enabled: options?.enabled ?? true,
});
const poll = useQuery<StatusResponse>({
queryKey: [...queryKey, 'poll', jobId],
queryFn: () => pollStatus(jobId!),
enabled: !!jobId,
refetchInterval: (query) =>
query.state.data?.status === 'computing' ? 2000 : false,
});
return {
isLoading: submit.isLoading || (!!jobId && poll.isLoading),
isComputing: poll.data?.status === 'computing',
isError: submit.isError || poll.data?.status === 'error',
data: poll.data?.status === 'ok' ? (poll.data.result as T) : undefined,
error: poll.data?.message || submit.error?.message,
};
}
.claude/settings.json
Skip this file if it already exists — /init-dashboard creates it with the correct plugin configuration.
If it does not exist, create it:
{
"plugins": {
"marketplaces": ["PolicyEngine/policyengine-claude"],
"auto_install": ["dashboard-builder@policyengine-claude"]
}
}
CI Workflow
Generate .github/workflows/ci.yml:
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx vitest run
- run: bun run build
For the custom backend pattern, add this step after checkout (policyengine-cloud-costs skill). Replace CHECKER_COMMIT_SHA with the output of gh api repos/PolicyEngine/policyengine-skills/commits/main --jq .sha:
- name: Check deploy settings for cost
env:
# A policyengine-skills commit; move it forward on purpose (policyengine-cloud-costs skill, section 4)
CHECKER_COMMIT: CHECKER_COMMIT_SHA
run: |
curl -fsSL "https://raw.githubusercontent.com/PolicyEngine/policyengine-skills/${CHECKER_COMMIT}/skills/policyengine-cloud-costs/scripts/check_deploy_config.py" -o /tmp/check_deploy_config.py
python3 /tmp/check_deploy_config.py .
Also for the custom backend pattern:
- add the "Running costs" section from the policyengine-cloud-costs skill's
templates/running-costs.mdtoREADME.md, filled from the plan'scost_plan; - if the plan uses PR preview apps, add
templates/stop-preview-app.ymlas.github/workflows/stop-preview-app.yml; - make sure
.gitignorelists.env.
Makefile
Generate a Makefile that provides standard development targets. The Makefile content depends on the data_pattern from plan.yaml.
IMPORTANT: Makefile recipes must use literal tab characters for indentation, not spaces.
For all patterns: The dev and dev-frontend targets must try port 4000 first, then increment by 1 up to 4100, erroring out if no port in that range is available. Use this helper script embedded in the Makefile — copy it exactly:
# Port selection helper — finds the first available port in 4000-4100
define find_port
$$(python3 -c '\
import socket, sys;\
for p in range(4000, 4101):\
try:\
s = socket.socket(); s.bind(("", p)); s.close(); print(p); sys.exit(0)\
except OSError:\
continue\
print("ERROR: no free port in 4000-4100", file=sys.stderr); sys.exit(1)\
')
endef
For precomputed, policyengine-api, or precomputed-csv patterns:
.PHONY: dev dev-frontend
.PHONY: build test lint clean
# Port selection helper — finds the first available port in 4000-4100
define find_port
$$(python3 -c '\
import socket, sys;\
for p in range(4000, 4101):\
try:\
s = socket.socket(); s.bind(("", p)); s.close(); print(p); sys.exit(0)\
except OSError:\
continue\
print("ERROR: no free port in 4000-4100", file=sys.stderr); sys.exit(1)\
')
endef
# Start development server
dev: dev-frontend
# Frontend only (no backend for this data pattern)
dev-frontend:
@PORT=$(find_port); \
echo "Frontend: http://localhost:$$PORT"; \
PORT=$$PORT bun run dev
build:
bun run build
test:
bunx vitest run
lint:
bun run lint
clean:
rm -rf .next node_modules
For custom-modal pattern:
Replace DASHBOARD_NAME below with the actual dashboard.name value from plan.yaml.
The custom-modal pattern uses a gateway + worker architecture with frontend polling. The worker must be deployed first (it contains the heavy policyengine code), then the gateway is started in dev mode.
.PHONY: dev dev-frontend dev-backend deploy-worker
.PHONY: build test test-backend lint clean
# Port selection helper — finds the first available port in 4000-4100
define find_port
$$(python3 -c '\
import socket, sys;\
for p in range(4000, 4101):\
try:\
s = socket.socket(); s.bind(("", p)); s.close(); print(p); sys.exit(0)\
except OSError:\
continue\
print("ERROR: no free port in 4000-4100", file=sys.stderr); sys.exit(1)\
')
endef
# Deploy worker functions, then start gateway + frontend
dev:
@echo "Deploying worker functions..."
@unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET && modal deploy backend/app.py
@echo "Starting gateway (ephemeral)..."
@modal serve backend/modal_app.py & MODAL_PID=$$!; \
sleep 5; \
MODAL_URL="https://policyengine--DASHBOARD_NAME-fastapi-app-dev.modal.run"; \
PORT=$(find_port); \
echo "Gateway: $$MODAL_URL"; \
echo "Frontend: http://localhost:$$PORT"; \
NEXT_PUBLIC_API_URL=$$MODAL_URL PORT=$$PORT bun run dev; \
kill $$MODAL_PID 2>/dev/null
# Frontend only (uses production API or NEXT_PUBLIC_API_URL if set)
dev-frontend:
@PORT=$(find_port); \
echo "Frontend: http://localhost:$$PORT"; \
PORT=$$PORT bun run dev
# Backend only (gateway in dev mode — worker must already be deployed)
dev-backend:
modal serve backend/modal_app.py
# Deploy worker functions to Modal (required before gateway can spawn jobs)
deploy-worker:
unset MODAL_TOKEN_ID MODAL_TOKEN_SECRET && modal deploy backend/app.py
build:
bun run build
test:
bunx vitest run
test-backend:
cd backend && uv run pytest
lint:
bun run lint
clean:
rm -rf .next node_modules
Favicon
Copy the PolicyEngine logo into app/icon.svg — the App Router convention. Next.js emits it at <basePath>/icon.svg, so it resolves correctly whether the zone is hit directly or served behind the host.
cp node_modules/@policyengine/ui-kit/src/assets/logos/policyengine/teal-square.svg app/icon.svg
Do NOT use public/favicon.svg + icons: { icon: '/favicon.svg' } in metadata — that path is written verbatim into the HTML and is not basePath-prefixed. Behind the host, the browser will request it from policyengine.org/favicon.svg and get the host's favicon instead of the zone's.
Embedding Boilerplate
Generate country detection, hash sync, and share URL helpers in lib/embedding.ts:
export function getCountryFromHash(): string {
const params = new URLSearchParams(window.location.hash.slice(1));
return params.get("country") || "us";
}
export function isEmbedded(): boolean {
return window.self !== window.top;
}
export function updateHash(params: Record<string, string>, countryId: string) {
const p = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => p.set(k, v));
if (countryId !== "us" && !isEmbedded()) p.set("country", countryId);
const hash = `#${p.toString()}`;
window.history.replaceState(null, "", hash);
if (isEmbedded()) {
window.parent.postMessage({ type: "hashchange", hash }, "*");
}
}
export function getShareUrl(countryId: string, slug: string): string {
const hash = window.location.hash;
if (isEmbedded()) {
return `https://policyengine.org/${countryId}/${slug}${hash}`;
}
return window.location.href;
}
Initial Test File
Generate __tests__/page.test.tsx with a basic render test.
Step 4: Create Skeleton Components
For each component in plan.yaml, first check if @policyengine/ui-kit already provides it. Only create skeleton files for components NOT available in ui-kit.
Each custom skeleton should:
- Use Tailwind utility classes with semantic and brand tokens for styling
- Have the correct TypeScript props interface
- Include a
// TODO: Implementcomment where real logic goes - Export the component
Step 5: Commit and Create Feature Branch
The repository and remote already exist (created by /init-dashboard). Commit the scaffold and create a feature branch:
git add -A
git commit -m "Initial scaffold from dashboard plan"
git checkout -b feature/initial-implementation
git push -u origin feature/initial-implementation
Step 6: Verify
bun install
bun run build # Should succeed with skeleton components
bunx vitest run # Initial test should pass
If either fails, fix before proceeding.
Quality Checklist
-
plan.yamlis included in the repo -
dashboard.zone_pathfrom plan is written asbasePathinnext.config.mjs -
next.config.mjsexports a FUNCTION takingphase(not a plain object) -
assetPrefixis phase-gated:undefinedin dev,/_zones/<dashboard.name>in builds -
vercel.jsonhasframework: null,outputDirectory: out,trailingSlash: true, the asset self-rewrite (/_zones/<dashboard.name>/_next/:path*→/_next/:path*), and the basePath route rewrites (<zone_path>→/,<zone_path>/:path(.*)→/:path) -
CLAUDE.mdfollows existing applet patterns -
package.jsonhas all required dependencies (Next.js, Tailwind v4, ui-kit) -
globals.csshas@import "tailwindcss"+@import "@policyengine/ui-kit/theme.css" -
postcss.config.mjsexists with@tailwindcss/postcssplugin - No
tailwind.config.ts(Tailwind v4) - No CDN
<link>for design-system tokens (ui-kit theme provides everything) - Inter font is loaded via
next/font/google - Embedding boilerplate is in place
- API client stubs match the plan's endpoint signatures
- CI workflow is configured
-
.claude/settings.jsonauto-installs the dashboard-builder plugin -
vercel.jsonis configured for frontend deployment - Feature branch is created and pushed
-
app/icon.svgexists (PE logo, basePath-safe via App Router) -
layout.tsxmetadata does NOT seticons: { icon: '/favicon.svg' }(that path isn't basePath-prefixed and would collide with the host behind policyengine.org) - Header uses
logos.whiteWordmarkorlogos.tealWordmark(not text-only) -
Makefilehas correct targets for the data pattern -
make devuses port range 4000-4100 (not random, not hardcoded 3000) - If custom-modal:
make devdeploys worker, then starts gateway + frontend - If custom-modal: backend has 3-file structure (
_image_setup.py,app.py,simulation.py) - If custom-modal:
_image_setup.pyhas no package imports at module level - If custom-modal:
app.pyonly importsmodalat module level - If custom-modal:
simulation.pyhas policyengine imports at module level (snapshotted) - If custom-modal: image uses
.run_function(snapshot_models)for fast cold starts - If custom-modal: worker image
pip_installincludes"pydantic"(simulation.py uses it at module level) - If custom-modal: worker image uses
.add_local_file()forsimulation.py(not auto-mounted since it's imported inside function bodies) - If custom-modal: gateway is lightweight (no policyengine in its Modal image)
- If custom-modal: gateway image explicitly includes
pydantic - If custom-modal: workers use the plan's
timeout,cpu,memoryandmax_containers, keepmin_containers=0, and population workers keep their memory# cost-ok: RS1 TODOdeclaration until a measured run replaces it - If custom-modal: CI runs the cost check, and
README.mdhas the "Running costs" section filled from the plan'scost_plan - If custom-modal: frontend uses polling (
refetchInterval), not synchronous await - If custom-modal:
/statusendpoint returns{status, result, message} - Build passes on the scaffold
- Initial test passes
DO NOT
- Commit to main after the initial scaffold commit
- Deploy to Vercel or Modal (that's
/deploy-dashboard) - Implement real logic (that's Phase 3 agents)
- Skip the feature branch
- Create
tailwind.config.ts(Tailwind v4 uses@themein CSS) - Omit
postcss.config.mjs— it IS required for Tailwind v4 (the@tailwindcss/postcssplugin processes@import "tailwindcss") - Rebuild components that exist in
@policyengine/ui-kit - Load tokens via CDN
<link>(use@import "@policyengine/ui-kit/theme.css"instead) - Use
getCssVar()— it no longer exists. SVG acceptsvar()directly.
Thinking Mode
IMPORTANT: Use careful, step-by-step reasoning before taking any action. Think through:
- The component specifications from the plan
- Whether @policyengine/ui-kit already provides the component
- How app-v2 implements similar components
- Correct use of design system tokens
- Responsive behavior and accessibility
Frontend Builder Agent
Implements React components for a PolicyEngine dashboard following the app-v2 design system and chart patterns.
Skills Used
- policyengine-tools - Mandatory framework and styling requirements (Tailwind v4, Next.js, ui-kit), embedding, hash sync, country detection, Recharts chart patterns
- policyengine-design - Design tokens, visual identity, colors, spacing
- policyengine-app - app-v2 component architecture
First: Load Required Skills
Before starting ANY work, use the Skill tool to load each required skill:
Skill: policyengine-toolsSkill: policyengine-designSkill: policyengine-app
CRITICAL: The policyengine-tools skill's "Required stack" defines mandatory technology requirements. All instructions below MUST be interpreted through the lens of that spec. Where this document conflicts with the spec, THE SPEC WINS.
Input
- A scaffolded repository with skeleton components
plan.yamlwith component specifications- API client with types and stubs already built by backend-builder
Output
- Fully implemented React components
- Working input forms, charts, and metric cards
- Responsive CSS using design system tokens
- Component tests
Design System Rules (NON-NEGOTIABLE)
These rules complement the frontend-builder-spec. Use standard Tailwind utility classes — not plain CSS or CSS modules.
Colors
- NEVER hardcode hex colors. Always use Tailwind classes with design tokens:
text-teal-500orbg-teal-500for primary tealhover:bg-teal-600orhover:bg-primaryfor hover statestext-foregroundfor body texttext-muted-foregroundfor muted textbg-backgroundfor backgroundsborder-borderfor borders
- Chart colors: use CSS vars directly —
fill="var(--chart-1)"for Recharts
Typography
- Font: Inter (loaded via
next/font/googleinapp/layout.tsx) - Use standard Tailwind text classes:
text-xs,text-sm,text-base,text-lg,text-xl,text-2xl - Use Tailwind
font-medium,font-semibold,font-boldfor weights - Sentence case on all headings and labels
Spacing
- Use standard Tailwind spacing:
p-4,m-6,gap-2,gap-3,gap-4, etc. - Never hardcode pixel values for spacing
Border Radius
- Use Tailwind
rounded-sm,rounded-md,rounded-lgclasses
Workflow
Step 0: Check ui-kit Component Availability
Before building ANY component, check the ui-kit component availability table from the spec. For each component in the plan:
- If ui-kit provides it → import and use it directly (e.g.,
MetricCard,Button,DataTable,PEBarChart) - If ui-kit doesn't have it but shadcn/ui does → use the shadcn/ui primitive styled with semantic classes
- Only build from scratch if neither covers it
// CORRECT — use ui-kit when available:
import { MetricCard, Button, Card, CardContent, DashboardShell, SidebarLayout, InputPanel, ResultsPanel } from '@policyengine/ui-kit';
import { CurrencyInput, NumberInput, SelectInput, SliderInput, InputGroup } from '@policyengine/ui-kit';
import { PEBarChart, PELineChart, ChartContainer } from '@policyengine/ui-kit';
import { formatCurrency, formatPercent } from '@policyengine/ui-kit';
// WRONG — don't rebuild what ui-kit already has:
// function MetricCard({ title, value }) { ... } // ui-kit has this
Step 1: Study App-v2 Patterns
Before building custom components, study the referenced app-v2 patterns. For each component_ref in the plan:
# Fetch the referenced app-v2 component to understand its pattern
gh api 'repos/PolicyEngine/policyengine-app-v2/contents/app/src/components/ChartContainer.tsx?ref=main' --jq '.content' | base64 -d
Extract:
- Component structure and props interface
- How data flows from API response to chart
- Responsive behavior patterns
- Tooltip and axis formatting patterns
You are NOT copying app-v2 components. You are learning their patterns and building compatible components for this standalone dashboard.
Step 2: Implement Input Forms
For each type: input_form component in the plan, use ui-kit input components:
import { useState, useEffect } from 'react';
import { InputGroup, CurrencyInput, NumberInput, SelectInput, SliderInput, CheckboxInput } from '@policyengine/ui-kit';
import { updateHash } from '../lib/embedding';
interface HouseholdInputsProps {
onChange: (values: FormValues) => void;
initialValues: FormValues;
}
export function HouseholdInputs({ onChange, initialValues }: HouseholdInputsProps) {
const [values, setValues] = useState(initialValues);
useEffect(() => {
onChange(values);
updateHash(
{ income: String(values.income), state: values.state },
values.countryId
);
}, [values]);
return (
<InputGroup label="Household details">
<CurrencyInput
label="Annual income"
value={values.income}
onChange={(v) => setValues({ ...values, income: v })}
/>
<SelectInput
label="State"
options={STATE_OPTIONS}
value={values.state}
onChange={(v) => setValues({ ...values, state: v })}
/>
<SliderInput
label="Filing year"
value={values.year}
min={2020}
max={2030}
onChange={(v) => setValues({ ...values, year: v })}
/>
</InputGroup>
);
}
Step 3: Implement Charts
Standard impact charts (winners/losers by decile, decile impact, poverty
change, budgetary impact) MUST use the canonical PolicyEngine components —
never hand-roll them. Check @policyengine/ui-kit first; if ui-kit lacks
the chart, port the exact component from policyengine-app-v2 (same colors,
ordering, hover format, axis conventions) rather than approximating it with
a generic Recharts build. These charts must be indistinguishable from the
same charts on policyengine.org.
For each type: chart component in the plan, prefer ui-kit chart components:
import { PEBarChart, PELineChart, PEAreaChart, ChartContainer } from '@policyengine/ui-kit';
// Simple bar chart — use ui-kit directly:
<PEBarChart data={chartData} xKey="category" yKey="value" />
// Wrapped with title/subtitle:
<ChartContainer title="Tax impact by income">
<PELineChart data={lineData} xKey="income" series={[{ dataKey: 'baseline' }, { dataKey: 'reform' }]} />
</ChartContainer>
For custom Recharts charts not covered by ui-kit, use CSS vars directly:
// SVG fill/stroke accept var() natively:
<Line stroke="var(--chart-1)" />
<Bar fill="var(--chart-2)" />
Step 4: Implement Metric Cards and Display
Notable-finding callout (standard component, build it once):
// components/Callout.tsx — eyebrow names the reader's question; headline is
// quantified; body explains the mechanism. Place adjacent to the chart it explains.
export function Callout({ eyebrow, headline, children }: {
eyebrow: string; headline: string; children: React.ReactNode;
}) {
return (
<aside className="rounded-r-lg border-l-4 border-primary-500 bg-teal-50 p-4">
<p className="text-xs font-semibold uppercase tracking-wide text-primary-700">{eyebrow}</p>
<p className="mt-1 font-semibold text-gray-900">{headline}</p>
<div className="mt-1 text-sm text-gray-700">{children}</div>
</aside>
);
}
Centralized formatting (lib/format.ts) — the ONLY place numbers get
formatted. ECPA shipped unrounded values and inconsistently formatted
axes because each component formatted ad hoc. Build one module and import
it everywhere:
// lib/format.ts — every user-visible number flows through here
export const fmtCurrency = (v: number) => // $1,234 — no decimals under $10k magnitude rules
export const fmtCurrencyCompact = (v: number) => // $1.2B / $340M / $12k for axes and metric cards
export const fmtPercent = (v: number, dp = 1) => // 14.4% — one decimal by default
export const fmtCount = (v: number) => // 1,234,000 with separators
Rules: no raw toFixed/toLocaleString outside this module; chart axes,
tooltips, metric cards, and prose takeaways all use the same formatter for
the same quantity; magnitudes get compact units on axes ($1.2B, never
1234567890).
Chart data completeness is asserted, not hoped. Decile charts render
exactly 10 deciles — components validate their input (if (data.length !== 10) throw) so a truncated payload fails loudly in build/tests instead
of shipping a chart with missing bars (ECPA shipped decile charts with
deciles missing). Same for filing-status series and poverty age groups:
assert the expected keys.
Use ui-kit's MetricCard, SummaryText, DataTable:
import { MetricCard, SummaryText, DataTable } from '@policyengine/ui-kit';
// MetricCard with currency formatting and trend:
<MetricCard label="Net income" value={45000} format="currency" trend="positive" delta="+$2,500" />
// SummaryText for narrative:
<SummaryText>This reform would increase your net income by $2,500.</SummaryText>
// DataTable for tabular data:
<DataTable
columns={[{ key: 'name', header: 'Variable' }, { key: 'value', header: 'Amount' }]}
data={tableData}
/>
Step 5: Wire Page Layout
Site chrome rule (mandatory): every page renders the real PolicyEngine
site header on top — ui-kit PolicyEngineHeader with the right country —
which supplies the policyengine.org nav, logo, and country selector. Do NOT
pass the dashboard's own pages as the header's navItems; the header's nav
is PolicyEngine's site nav.
Below the header, use the standard dashboard chrome shared by the live one-off dashboards (south-carolina-2026-tax-changes, tx-rebate-checks):
- Hero band —
bg-primary-500 text-white py-8 px-4 shadow-md, holding the dashboard's single<h1>and a one-line subtitle, in amax-w-5xlcontainer. Page-level titles inside content are<h2>. - Folder-style tabs — active:
bg-white text-primary-600 border-t-4 border-primary-500; inactive:bg-gray-200 text-gray-700 hover:bg-gray-300; base:px-6 py-3 rounded-t-lg font-semibold transition-colors. Buttons withrole="tablist"for single-route dashboards;Links witharia-currentfor multi-route ones. - Content card —
bg-white rounded-lg shadow-md p-6on abg-gray-50page, samemax-w-5xlcontainer as the hero and tabs.
The primary-* utilities map to the ui-kit teal scale via @theme inline
in globals.css:
@theme inline {
--color-primary-500: var(--color-teal-500);
--color-primary-600: var(--color-teal-600);
--color-primary-700: var(--color-teal-700);
--color-primary-800: var(--color-teal-800);
}
Multi-route shape (shared PageShell wrapping every page's content):
// components/PageShell.tsx — wraps every page's content
'use client'
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { PolicyEngineHeader } from '@policyengine/ui-kit';
const PAGES = [
{ label: 'The reform', href: '/' },
{ label: 'Validation', href: '/validation' },
{ label: 'Impacts', href: '/impacts' },
{ label: 'Households', href: '/household' },
];
/** Trailing-slash-insensitive match (trailingSlash: true in next.config). */
function isActive(pathname: string, href: string): boolean {
const normalize = (p: string) => (p !== '/' && p.endsWith('/') ? p.slice(0, -1) : p);
return normalize(pathname) === normalize(href);
}
export function PageShell({ children }: { children: React.ReactNode }) {
// Nullish outside the app router (unit tests render without it).
const pathname = usePathname() ?? '';
return (
<>
<PolicyEngineHeader country="us" />
<main className="min-h-screen bg-gray-50">
<div className="bg-primary-500 px-4 py-8 text-white shadow-md">
<div className="mx-auto max-w-5xl">
<h1 className="mb-2 text-4xl font-bold">DASHBOARD_TITLE</h1>
<p className="text-lg opacity-90">DASHBOARD_SUBTITLE</p>
</div>
</div>
<div className="mx-auto max-w-5xl px-4 py-8">
<nav aria-label="Dashboard pages" className="mb-4 flex space-x-1 overflow-x-auto">
{PAGES.map(({ label, href }) => (
<Link
key={href}
href={href}
aria-current={isActive(pathname, href) ? 'page' : undefined}
className={`whitespace-nowrap rounded-t-lg px-6 py-3 font-semibold transition-colors ${
isActive(pathname, href)
? 'border-t-4 border-primary-500 bg-white text-primary-600'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{label}
</Link>
))}
</nav>
<div className="rounded-lg bg-white p-6 shadow-md">{children}</div>
</div>
</main>
</>
);
}
Color rules: hex codes in the chrome are violations. The gray-* scale
comes from the ui-kit theme; primary-* comes from the @theme inline
mapping above — both are token-backed. Reference:
no-tax-on-social-security-dashboard components/PageShell.tsx.
Then use ui-kit layout components for the page body:
'use client'
import { useState } from 'react';
import { DashboardShell, SidebarLayout, InputPanel, ResultsPanel } from '@policyengine/ui-kit';
import { getCountryFromHash } from '@/lib/embedding';
import { HouseholdInputs } from '@/components/HouseholdInputs';
import { useHouseholdSimulation } from '@/lib/hooks/useCalculation';
export default function DashboardPage() {
const [countryId] = useState(getCountryFromHash());
const simulation = useHouseholdSimulation();
return (
<DashboardShell>
<SidebarLayout
sidebar={
<InputPanel title="Settings">
<HouseholdInputs
onChange={(values) => simulation.mutate(buildRequest(values))}
initialValues={defaultValues}
/>
</InputPanel>
}
>
<ResultsPanel>
{simulation.isPending && <LoadingState />}
{simulation.isError && <ErrorState error={simulation.error} />}
{simulation.data && (
<>
{/* Charts and metrics from plan, in order */}
</>
)}
</ResultsPanel>
</SidebarLayout>
</DashboardShell>
);
}
Step 6: Implement Responsive CSS
Use Tailwind responsive prefixes instead of writing CSS media queries:
md:flex-col— stack layout at tablet (768px)sm:px-4 sm:py-3— tighter padding on mobilesm:text-xl— smaller headings on mobile
Step 7: Write Component Tests
For each custom component (not ui-kit imports), create a Vitest test:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { HouseholdInputs } from '../components/HouseholdInputs';
describe('HouseholdInputs', () => {
it('renders all fields from plan', () => {
render(<HouseholdInputs onChange={() => {}} initialValues={defaults} />);
// Check each field from plan exists
});
it('calls onChange when input changes', async () => {
const onChange = vi.fn();
render(<HouseholdInputs onChange={onChange} initialValues={defaults} />);
// Interact with inputs, verify callback
});
});
Step 8: Promote Custom Components to ui-kit
After all custom components are built and tested, check if any would be useful additions to @policyengine/ui-kit. Use AskUserQuestion to ask:
"The following custom components were built for this dashboard: [list]. Would you like to open a PR to
@policyengine/ui-kitto add any of these to the shared library?"
If yes, invoke the /create-new-component command targeting the selected components.
Quality Checklist
- Used ui-kit for all standard patterns (MetricCard, Button, Card, inputs, charts, layout)
- No hardcoded hex colors anywhere in TSX — use Tailwind classes or
var(--chart-N)for Recharts - All spacing uses standard Tailwind classes (
p-4,gap-3, etc.) - No plain CSS files other than
globals.css(which imports ui-kit theme) - Inter font loaded via
next/font/google - All headings and labels use sentence case
- Charts follow app-v2 patterns (ResponsiveContainer, consistent formatting)
- Loading states shown during API calls
- Error states show helpful messages
- Country detection works from hash
- Hash sync updates on input change
- Share URLs point to policyengine.org
- Responsive design uses Tailwind breakpoint prefixes
- All component tests pass
- TypeScript compiles without errors
- Next.js build succeeds
- Custom components offered for promotion to ui-kit
DO NOT
- Use any styling framework OTHER than Tailwind (no Mantine, Chakra, etc.)
- Use plain CSS files or CSS modules for layout/styling — use Tailwind utility classes instead
- Hardcode any colors, spacing, or font values when a design token exists
- Copy app-v2 components directly — follow their patterns
- Skip responsive styles
- Leave
console.logstatements in production code - Install dependencies not in the plan
- Use Vite — use Next.js as specified in the frontend-builder-spec skill
- Create
tailwind.config.tsorpostcss.config.js— Tailwind v4 uses@themein CSS - Rebuild components that exist in
@policyengine/ui-kit - Use
getCssVar()— it no longer exists. SVG acceptsvar()directly.
Microsim Runner
Runs a single reform through PolicyEngine and returns structured impact results.
Two execution paths — pick based on environment:
- API path (default, no install required): call
api.policyengine.org. - Local path (heavy, more flexible): install the
policyenginepackage (latest,>=5.0.1) and run through its managedpe.{us,uk}surfaces (see "Process — Local path").
Inputs
reform_dict: PolicyEngine parameter changes (fromparameter-locator)jurisdiction:{country, state?}(e.g.,{country: us, state: ri},{country: us}for federal, or{country: uk})year: simulation year (default2026)mode:api(default) orlocaldataset(optional): a dataset name from policyengine.py's bundle, such aspopulace_us_2024_acs_localorenhanced_frs_2024_25. The API selects data by region and accepts no dataset override. When the caller names a dataset, take the local path whatevermodesays, and recordexecution_mode: "local"in the output. This includes the national default's name, because a regional API request can select another file. An omitted dataset lets the API select the region's data.
Process — API path
Load the policyengine-api skill for the v1 routes.
Step 1: Create the policy
import requests
response = requests.post(
"https://api.policyengine.org/us/policy",
json={"data": reform_dict},
)
policy_id = response.json()["result"]["policy_id"]
Step 2: Request economy-wide impacts
Critical: the URL is /economy/{reform_policy_id}/over/{baseline_policy_id}, and the baseline ID must be the country's current-law ID. The over/{N} segment is the baseline policy ID to diff against, not a year count. Common baseline IDs:
| Country | Current-law baseline policy_id |
|---|---|
| US | 2 |
| UK | 1 (current_law_id in /uk/metadata) |
A baseline ID other than the country's current-law ID usually returns a misleading parse error or computes against a stale baseline. Always use the documented current-law baseline.
region = state.lower() if state else "us"
baseline_id = 2 # US current law — verify
url = f"https://api.policyengine.org/us/economy/{policy_id}/over/{baseline_id}"
response = requests.get(url, params={
"region": region,
"time_period": str(year),
})
The annual route takes region and time_period, plus optional version (the country model version), target (general or cliff), spm (a JSON-encoded SPM selection) and, for UK runs only, breakdown. policyengine-api answers HTTP 400 to any other query parameter, so a request that sends dataset gets "dataset: Extra inputs are not permitted". The API builds the request with dataset="default"; the executor then selects the region's dataset. US state and district runs load ACS-local data, whereas national runs use the default that /{country}/metadata lists under economy_options.datasets. On 2026-11-08 the US entry named Microcosm's populace_us_2024 at data version populace-us-2024-spm-20260915, and the UK entry named Microcosm's microcosm_uk_2024_25. To choose a dataset explicitly, take the local path.
For a UK run, call the same routes under /uk/ with region=uk and baseline policy 1. The dataset check below applies to both countries.
Dataset check (REQUIRED)
After the run completes, record the provenance the response supports and compare it with the default that /{country}/metadata lists. The gateway's policyengine_bundle describes the app's default dataset, even for a regional run that loads another dataset. Preserve an explicit top-level dataset from the worker, as a UK kind: "local_areas" result supplies, ahead of that bundle. The worker's top-level data_version reports the run's revision; preserve it ahead of the bundle's release. Otherwise, a regional API result leaves dataset null because it does not identify the selected file. Do not infer the file from a release-name prefix. A budget-window result reports neither a bundle nor a release, so its dataset and release both stay null.
The gateway contract comes from bundle_payloads.py:build_policyengine_bundle; regional selection and the worker's release come from the executor's region_resolution.py:resolve_region, simulation_runtime.py:_resolve_dataset_selection and simulation_output_provenance.py:output_data_version (policyengine-api-v2 a6b12d7b8). simulation_output_local_areas.py:LocalAreaBreakdownBuilder reports the selected dataset even when region=uk requests the local-area breakdown. run_dataset below compares a known dataset URI, for local runs and national API bundles. api_run_dataset applies the gateway distinction before using it. URIs can start hf:// or gs://; the name follows policyengine.py's dataset_logical_name.
from pathlib import PurePosixPath
def run_dataset(bundle, datasets):
"""Return the dataset and data version behind an economy result.
bundle: a known dataset identity, such as the local run's resolved URI.
datasets: `economy_options.datasets` from /{country}/metadata.
`dataset_matches_default` reads None when the result doesn't say which
release it loaded.
"""
default = next(entry for entry in datasets if entry.get("default"))
bundle = bundle or {}
uri = bundle.get("dataset") or "default"
if uri == "default":
name = default["name"]
else:
name = PurePosixPath(uri.rsplit("@", 1)[0]).stem
pinned = uri.rsplit("@", 1)[1] if "@" in uri else None
data_version = bundle.get("data_version") or pinned or None
if name != default["name"]:
matches = False
elif data_version and default.get("data_version"):
matches = data_version == default["data_version"]
else:
matches = None
return {"dataset": name, "data_version": data_version, "dataset_matches_default": matches}
def api_run_dataset(result, datasets, *, country, region):
"""Keep a gateway default bundle separate from a regional run's dataset."""
result = result or {}
bundle = result.get("policyengine_bundle") or {}
worker_version = result.get("data_version") or None
worker_dataset = result.get("dataset")
if isinstance(worker_dataset, str) and worker_dataset not in ("", "default"):
return run_dataset({"dataset": worker_dataset, "data_version": worker_version}, datasets)
default = next(entry for entry in datasets if entry.get("default"))
if region.lower() != country.lower() or result.get("kind") == "local_areas" or not bundle:
matches = None
if worker_version and default.get("data_version"):
if worker_version != default["data_version"]:
matches = False
return {"dataset": None, "data_version": worker_version, "dataset_matches_default": matches}
identity = dict(bundle)
if worker_version:
identity["data_version"] = worker_version
return run_dataset(identity, datasets)
Fetch the metadata once the result arrives, so the check compares the run with the default the API lists now, and record the check with the model version:
metadata = requests.get(f"https://api.policyengine.org/{country}/metadata").json()["result"]
result = response.json()["result"]
check = api_run_dataset(result, metadata["economy_options"]["datasets"], country=country, region=region)
model_version = result.get("model_version") or (result.get("policyengine_bundle") or {}).get("model_version")
dataset_matches_default reads true only when both dataset identity and release match the default. It reads false when a known dataset differs or the worker reports a different release. It reads null when the response cannot verify the identity or either release, including a regional run whose reported release matches the default. Preserve that uncertainty; never copy metadata values into the run's provenance. The comparator notes false and null results.
Polling: the response starts with status: "computing" and can carry stage fields, such as stage, progress, progress_max and segments, while the job runs. Wait 30s and retry, for up to 20 minutes. A failed run answers HTTP 502 with status: "error" and a message. A US economy-wide run for policy 97853 on 2026-11-08 finished in about three minutes.
Reform-dict row coverage pre-flight (REQUIRED before submission)
The . (dot) separator in a reform-dict date-range key does NOT sweep forward through the baseline's later rows. A key like "2026-01-01.2035-12-31": 33200 inserts a single value at 2026-01-01 with an end date at 2035-12-31 — but if the baseline has intermediate rows (2027-01-01, 2028-01-01, ...), those take precedence for their respective years and the reform is silently a no-op for years 2027+.
This is exactly what happened to the std-ded +$1K reform on 2026-07-01. Policy 97852 used {"2026-01-01.2035-12-31": 33200} for each filing status. The 2026 result was correct (−$17.86B), but 2027-2035 all reported budgetary_impact=0 because the baseline had inflation-indexed per-year rows the reform-dict didn't override. Policy 97853 fixed it with per-year values.
Required pre-flight — before submitting any policy:
- Fetch the baseline parameter's
valuesobject via/{country}/metadatafor every parameter path in the reform-dict. - For each parameter path, count the number of baseline rows falling inside the reform's date range.
- If a single reform-dict row covers a date range where the baseline has >1 row, emit:
{
"warning": "reform_dict_row_coverage_mismatch",
"parameter_path": "gov.irs.deductions.standard.amount.JOINT",
"reform_dict_rows_in_range": 1,
"baseline_rows_in_range": 10,
"baseline_row_years": [2026, 2027, 2028, ..., 2035],
"risk": "reform will only override year 2026; years 2027+ will fall back to inflation-indexed baseline values",
"suggested_fix": "expand reform-dict to per-year values: {2026-01-01.2026-12-31: X_2026, 2027-01-01.2027-12-31: X_2027, ...}",
"auto_expand_available": true
}
- If
--auto-expand(or the agent has authority): rewrite the reform-dict to per-year values. Compute reform value per year asbaseline[year] + delta, wheredeltais inferred from the (single row value) − (baseline at row start date). - If not auto-expanding: refuse to submit the run and surface the warning to the analyst.
Applies to any inflation-indexed parameter family: standard deduction, tax brackets, EITC amounts, CTC amounts, phase-out thresholds. Does NOT apply to parameters with only 1-2 baseline rows in the reform window (like SALT cap under OBBBA, which has 2026 + 2030 rows only — a single reform-dict row "2026-01-01.2035-12-31": 60000 correctly overrides both).
Multi-year runs (horizon > 1) — use the budget-window endpoint
The API provides a native multi-year endpoint that queues all years server-side with progress tracking. Prefer it over N parallel single-year calls — it's simpler to poll, gives a unified response shape, and doesn't rely on client-side parallel orchestration.
GET /{country}/economy/{policy_id}/over/{baseline_id}/budget-window
?region={region}
&start_year=YYYY
&window_size=N
It also takes the annual route's optional version and spm, and target only as general. It answers HTTP 400 to breakdown and to dataset.
Keep the window inside the years /{country}/metadata lists under economy_options.time_period: 2024-2035 for the US and 2024-2032 for the UK on 2026-11-11. The route checks only start_year against that list, and the single-year fallback answers HTTP 400 to any year outside it.
Response while computing:
{
"status": "computing",
"message": "Scoring 2029 (3 of 10 complete)...",
"result": null,
"progress": 30,
"completed_years": ["2026", "2027", "2028"],
"computing_years": ["2029"],
"queued_years": ["2030", "2031", "2032", "2033", "2034", "2035"],
"error": null
}
When status: "ok", result holds kind: "budgetWindow", startYear, endYear, windowSize, one annualImpacts row per year and a totals row. Each row carries budgetaryImpact, taxRevenueImpact, federalTaxRevenueImpact, stateTaxRevenueImpact and benefitSpendingImpact in the country's currency. The result carries no policyengine_bundle, data_version or model_version. Apply api_run_dataset to that result: dataset, data_version and dataset_matches_default all stay null. Leave model_version null too; the metadata's current version does not establish which model computed the batch.
A failed batch answers HTTP 200 with status: "error" and the reason in error. The route answers HTTP 502 only when it can't reach the simulation service, and HTTP 400 or 404 when it refuses the request itself.
Polling cadence: poll every 60s while status == "computing". A poll that times out or answers with something other than JSON doesn't end the wait, since the batch keeps running on the server. On 2026-11-08 a two-year US window finished 2027 within 45 minutes of the first request and still showed 2026 computing two hours in.
Fallback: if run_horizon raises RuntimeError (a failed batch, or an HTTP 502) or TimeoutError (a batch that keeps computing past 45 minutes), fall back to N parallel calls to the single-year endpoint. Log the fallback and its reason, so the analyst knows the years came from single-year calls. A ValueError means the API refused the request (HTTP 400 or 404): report its message and fix the request instead of falling back.
import time
import requests
def run_horizon(country, region, policy_id, baseline_id, start_year, window_size, max_wait_minutes=45):
url = (
f"https://api.policyengine.org/{country}/economy/{policy_id}/over/{baseline_id}/budget-window"
f"?region={region}&start_year={start_year}&window_size={window_size}"
)
deadline = time.monotonic() + 60 * max_wait_minutes
message = None
while True:
try:
response = requests.get(url, timeout=120)
r = response.json()
except (requests.RequestException, ValueError) as error:
message = f"last poll failed: {error!r}" # the batch keeps running; poll again
else:
if response.status_code in (400, 404):
raise ValueError(r.get("message"))
if r.get("status") == "ok":
return r["result"]
if r.get("status") != "computing":
raise RuntimeError(r.get("error") or r.get("message"))
message = r.get("message")
if time.monotonic() >= deadline:
raise TimeoutError(f"still computing after {max_wait_minutes} minutes: {message}")
time.sleep(60)
Do NOT compute a naive yr1 × N extrapolation. If the horizon is 1, report yr-1 cost only in the archive. If the horizon is 10 or a custom year range, use the budget-window endpoint; for a custom list of separate years, make one single-year call per year. Either way, report the actual per-year cost table AND the summed multi-year cost.
Step 2b: Real response shape
The API returns raw baseline and reform LEVELS, not deltas. The agent must compute deltas itself. Top-level keys (representative):
{
"status": "ok",
"message": null,
"result": {
"model_version": "2.2.1",
"data_version": "populace-us-2024-spm-20260915",
"policyengine_bundle": {
"model_version": "2.2.1",
"policyengine_version": "6.16.0",
"data_version": "populace-us-2024-spm-20260915",
"dataset": "hf://policyengine/populace-us/populace_us_2024.h5@populace-us-2024-spm-20260915",
...
},
"budget": {
"budgetary_impact": ...,
"tax_revenue_impact": ...,
"state_tax_revenue_impact": ...,
"benefit_spending_impact": ...,
"households": ...,
"baseline_net_income": ...
},
"poverty": {
"poverty": {
"all": {"baseline": 0.124, "reform": 0.118},
"adult": {...},
"child": {"baseline": 0.143, "reform": 0.094},
"senior": {...}
},
"deep_poverty": {...}
},
"inequality": {
"gini": {"baseline": 0.4123, "reform": 0.4042},
"top_1_pct_share": {"baseline": ..., "reform": ...},
"top_10_pct_share": {"baseline": ..., "reform": ...}
},
"decile": {...},
"intra_decile": {...},
"labor_supply_response": null
}
}
Compute deltas:
child_poverty_pct_change = (reform - baseline) / baseline * 100adult_poverty_pct_change = (reform - baseline) / baseline * 100— required since some reforms move adult-only poverty (childless EITC, SALT)senior_poverty_pct_change = (reform - baseline) / baseline * 100— required since reforms that touch age-eligibility (ARPA EITC age-cap removal) move senior poverty more than child or overall; the EITC live test showed senior -5.4% while child was 0.0%gini_pct_change = (reform_gini - baseline_gini) / baseline_gini * 100annual_cost_billion = -budgetary_impact / 1e9(sign convention: negative budgetary_impact means revenue loss / spending → positive cost)
Surface unusual-bucket signals. If senior_poverty_pct_change is more than 2× overall_poverty_pct_change (or vice versa for child), include a headline_metric_note in the output flagging which sub-bucket is driving the result. This catches age-targeted reforms whose effect would otherwise be invisible in the "overall poverty" headline.
Step 3: For multi-year scoring
The analyst picks the number of years at /analyze-policy's horizon prompt (Phase 0), and the budget-window endpoint above scores them in one batch.
Naive yr1 × N extrapolation is banned. It silently misstates 10-year totals whenever the baseline evolves over the window (inflation-indexed thresholds, 2030 OBBBA snap-back, phase-out schedules, TCJA-style sunsets). Empirical case from 2026-07-01 std-ded +$1K: yr1×10 = $178.6B understated the real 10-year cost ($196.10B) by 10%. If the analyst asks for a single year, the archive reports one year — no fabricated 10-year total.
For a 10-year US cost, call run_horizon(country, region, policy_id, baseline_id, start_year=2026, window_size=10) and report its annualImpacts rows and totals. A UK window must end by 2032, the last year /uk/metadata lists.
Process — Local path
Load the policyengine skill for current import patterns and certified dataset names. The canonical local entry point is the top-level policyengine package (latest, >=5.0.1) pinned to the certified Microcosm bundle — never from policyengine_us import Microsimulation (unmanaged; deprecated for analysis) or a bare from policyengine import Microsimulation (that class does not exist).
Baseline-vs-reform economy analysis (budget, poverty, deciles, inequality in one pass):
import policyengine as pe
from policyengine.core import Simulation
loaded = pe.us.ensure_datasets(
datasets=[dataset] if dataset else None, # None loads the certified default
years=[year],
data_folder="./data",
)
data = next(iter(loaded.values()))
baseline = Simulation(dataset=data, tax_benefit_model_version=pe.us.model)
reform = Simulation(dataset=data, tax_benefit_model_version=pe.us.model, policy=reform_dict)
analysis = pe.us.economic_impact_analysis(baseline, reform) # deciles, poverty, inequality
budget = pe.us.calculate_budgetary_impact(baseline, reform) # total / federal / state split
Simulation(policy=reform_dict) takes the same flat reform dict as the API path. Call economic_impact_analysis before any manual ensure() — it configures the output variables both simulations need. policyengine.py 6.16.0's US bundle serves two datasets by name: the certified default populace_us_2024 and populace_us_2024_acs_local.
For a UK run, follow the same pattern with pe.uk, which has economic_impact_analysis but no calculate_budgetary_impact, and whose analysis result carries no budget total. Take a UK run's cost from policyengine-uk's household gov_balance, which adds gov_tax and subtracts gov_spending. pe.uk's default outputs leave it out, so pass extra_variables={"household": ["gov_balance"]} to both Simulations. The reform's weighted total minus the baseline's gives the change in the government's balance, and annual_cost_billion_year1 takes the opposite sign, in £ billions. pe.uk.ensure_datasets(years=[year]) loads the certified default, Microcosm's microcosm_uk_2024_25, from a private Hugging Face repo, so set HUGGING_FACE_TOKEN. When the caller names a dataset, load it by name: pe.uk.ensure_datasets(datasets=[dataset], years=[year]). policyengine.py serves the Enhanced FRS and FRS files that policyengine-uk-data builds (enhanced_frs_2024_25, frs_2024_25, and their 2023-24 and _tiny siblings) only by name, from the dataset_overlays in its bundle manifest. It downloads them from the gated policyengine/policyengine-uk-data-private repo, so the token also needs access there. Say in the result that a run on one of them left the certified default.
Record the dataset the run used and its version for the output, in either country, with the API path's run_dataset, so dataset_matches_default compares the local run with the same default /{country}/metadata lists:
from policyengine.provenance.manifest import resolve_managed_dataset_reference
uri = resolve_managed_dataset_reference(country, dataset) # dataset=None gives the certified default
metadata = requests.get(f"https://api.policyengine.org/{country}/metadata").json()["result"]
check = run_dataset({"dataset": uri}, metadata["economy_options"]["datasets"])
# check["dataset"]: e.g. "microcosm_uk_2024_25"; check["data_version"]: the URI's @ revision
For the analyst MicroSeries surface (weighted .calc() on the certified default populace_us_2024):
import policyengine as pe
sim = pe.us.managed_microsimulation() # certified default dataset
sim.calc("household_net_income", period=year).sum() # US: .calc(); UK: .calculate()
For state- or district-level impacts, load populace_us_2024_acs_local by name and scope a Simulation with RowFilterStrategy(variable_name="state_fips", ...) from policyengine.core.scoping_strategy — never a per-state or per-district H5 file (policyengine.py's bundle serves none, and PolicyEngine archived policyengine-us-data). See the policyengine skill's regional-analysis section.
Output
The agent normalizes the raw API response into this canonical shape. Always include the raw response path so downstream agents can re-check the source levels.
{
"policy_id": "97748",
"baseline_policy_id": "2",
"jurisdiction": {"country": "us", "state": null},
"year": 2026,
"execution_mode": "api",
"dataset": "populace_us_2024",
"data_version": "populace-us-2024-spm-20260915",
"dataset_matches_default": true,
"model_version": "2.2.1",
"wall_clock_seconds": 480,
"results": {
"budget": {
"annual_cost_billion_year1": 86.6,
"ten_year_cost_billion_actual": null,
"ten_year_cost_billion_actual_note": "null unless horizon > 1; naive yr1×N extrapolation is banned"
},
"years_run": [2026],
"per_year_budget_billion": {"2026": 86.6},
"poverty": {
"overall_baseline": 0.124, "overall_reform": 0.118, "overall_pct_change": -4.8,
"child_baseline": 0.143, "child_reform": 0.094, "child_pct_change": -34.3,
"adult_baseline": 0.108, "adult_reform": 0.105, "adult_pct_change": -2.8,
"senior_baseline": 0.092, "senior_reform": 0.087, "senior_pct_change": -5.4,
"deep_child_baseline": ..., "deep_child_reform": ..., "deep_child_pct_change": ...
},
"distribution": {
"gini_baseline": 0.4123, "gini_reform": 0.4042, "gini_pct_change_relative": -2.0, "gini_pp_change_absolute": -0.81,
"top_1pct_share_baseline": ..., "top_1pct_share_reform": ..., "top_1pct_share_change_absolute": -0.5
},
"labor_supply": null
},
"raw_response_path": "/tmp/microsim-{policy_id}.json"
}
dataset and data_version name the dataset that backed the run and its version when the response verifies them; otherwise use null. /analyze-policy records them in the archive's frontmatter as dataset_at_run and data_version_at_run, and report-logger routes a UK data issue by the dataset. On the API path, call api_run_dataset with the whole result and the requested country and region. It preserves the worker's explicit top-level dataset and release, including for UK local-area breakdowns. A regional response without a worker dataset leaves dataset null, because its gateway bundle describes the app default rather than the selected file. A budget-window result leaves all three provenance fields and model_version null. On the local path, compute the dataset fields as "Process — Local path" shows. For an annual API result, model_version comes from the worker's model_version, then from its bundle; metadata never fills a missing run version.
Sign conventions:
annual_cost_billion: positive = revenue/spending COST to government (reform makes the gov spend more or collect less). For a cap repeal like SALT, this is a positive number (revenue loss to gov).gini_pct_change_relative(e.g., -2.0%): change relative to baseline Gini. Use this for comparison to published PE scores, which typically report relative change.gini_pp_change_absolute(e.g., -0.81pp): absolute percentage-point change. Use for tolerance bands.
The comparator agent expects BOTH absolute and relative values for Gini — published priors don't always specify which they report.
Failure modes & known gotchas
- API timeout: retry with backoff. Cache
policy_idto avoid recompute. Allow up to 20 minutes for an annual run; timing varies by reform and queue. - Unsupported jurisdiction: if
country=ca, surface that Canada has no microdata — only household calculations are supported. - Reform-dict syntax error: PE API returns 400; surface the error so
parameter-locatorcan fix the snippet. - HTTP 400 "dataset: Extra inputs are not permitted": the economy request sent a
datasetquery parameter. Drop it; to run a named dataset, take the local path. - Silently-wrong reforms: the API may accept a half-baked reform-dict (e.g., setting CTC bracket amounts without enabling
phase_out.arpa.in_effect) and return numbers that look plausible but are wrong. ALWAYS sanity-check the result against the prior anchor before continuing. If cost is off by >30%, suspect a missing toggle/switch and re-read the country-model formula file. Infinityencoding depends on the parameter's value type. The policy DB stores reform-dicts as JSON in a MySQL JSON column, which rejects bareInfinity.- Float-typed parameters (caps, thresholds, amounts): accept either the string
".inf"OR a large numeric like1e15. Prefer.inffor clarity. - Integer-typed parameters (ages, qualifying-children counts): the string
".inf"fails silently — the POST is accepted but the/economycall later returnsstatus: "error", message: nullwith no diagnostic. Use a large integer like999instead. - When unsure, read the YAML — if it has
value_type: intor values look like integers (no decimal), use the integer form.
- Float-typed parameters (caps, thresholds, amounts): accept either the string
- Wrong baseline ID: a baseline ID other than the country's current-law ID returns a parse error or a stale baseline. Always use
/over/{baseline_policy_id}— see Step 2. - Multi-year discontinuities: TCJA / OBBBA / similar regime-shift parameters make single-year results unrepresentative of a 10-year window. When the reform touches a parameter that sunsets or inflates, run all 10 years (
--horizon 10) — do NOT extrapolate. The 2026-07-01 std-ded +$1K empirical case: yr1×10 = $178.6B understated the real 10-year cost ($196.10B) by 10%.
Hand-off
Returns the impact JSON. Downstream:
reform-comparator(Stage 5) compares results to theprior-scores-finderanchor.reform-describerproduces the human-readable provisions for the write-up.- The /analyze-policy command formats the final report.
This agent does NOT write to any database. Tracker-specific Supabase writes live in the state-legislative-tracker repo's local db-writer agent — this is the boundary.
Model Corroborator
Purpose: independent verification that the model's data + mechanics are directionally correct for the reform's parameter family, by re-running externally-scored nearby reforms and checking the model can reproduce them.
This is the answer to "we have no exact-shape external comparator — how do we know our headline number isn't drifting?" Rather than widening tolerance bands and shrugging, we run external benchmarks' exact reform shapes through our model and check the agreement explicitly.
When this stage fires
reform-comparator calls this agent when its Step 2b external-benchmark agreement check yields fewer than 2 directly-comparable external sources within ±25% (the threshold for an unambiguous PASS). The original reform has no clean external anchor, so we corroborate the parameter-family calibration via mirror-shape runs.
/analyze-policy orchestrates this between Phase 5 (compare) and Phase 6 (calibration-diagnostics). If corroboration passes, the final verdict is upgraded to PASS-WITH-CORROBORATION. If corroboration fails, escalate directly to calibration-diagnostics (INVESTIGATE).
Inputs
our_reform_dict— the original reform we just ran (for parameter-family context)our_jurisdiction—{country, state?}benchmark_sources[]— the Tier 3 think-tank cluster fromprior-scores-finder, including each source'sreform_shapeandtheir_estimate_10yr_billionour_microsim_result— Stage 4 output (for the parameter-family sensitivity context)our_baseline_policy_id— typically 2 (US) or country equivalent
Process
Step 1: Pick mirror-shape candidates
Frozen-registry rule: candidates come ONLY from benchmark_sources[] —
the registry pre-registered before the microsim ran. You know our result,
so discovering new sources now would let the result pick its own judges.
WebFetch is for retrieving the already-registered sources' documents (to
extract their exact reform shapes), never for finding additional sources.
If the registry has no usable candidate, return NO-CORROBORATION-POSSIBLE
— do not go looking.
From benchmark_sources[], pick 1-3 candidates with:
- Same parameter family as our reform (e.g., SALT cap → SALT cap; CTC amount → CTC amount).
- A specific reform shape that's expressable as a reform-dict (vague "raise the cap" without numbers is not usable).
- A published numeric estimate (10yr or year-1) we can compare against.
- Methodology compatible with static microsim — skip sources that ONLY publish dynamic-scoring numbers (PWBM dynamic), or use them as expected-different.
Prefer candidates with:
- A baseline that matches ours (no separate baseline-policy build needed).
- A reform_shape that brackets ours (one below, one above) — gives the strongest sensitivity check.
- Recent publication (within last 2 years) — older estimates may use outdated calibration targets.
Step 2: Build each mirror-shape reform-dict
For each candidate, translate its reform_shape description into PE parameter paths. Use the same parameter family conventions as the original reform (the locator's work in Stage 2 is reusable). Document each translation in the candidate's mirror_reform_dict.
Filing-status convention (when a source says "$X single / $Y joint"):
- SINGLE: $X
- HEAD_OF_HOUSEHOLD: $X (TPC/CRFB grouping convention)
- JOINT: $Y
- SURVIVING_SPOUSE: $Y
- SEPARATE: $X (half of joint, equal to single)
If the source explicitly specifies different conventions, follow theirs.
Phase-out / floor / refundability switches: if the source mentions a phase-out or floor, replicate it as-is. If unmentioned, keep the deployed default.
Step 3: Build a baseline policy if needed
Most external sources publish vs "current law" or vs "TCJA extension". Map each:
- "Current law" = PE current-law baseline (
policy_id=2for US). CRITICAL: check the source's publication date. If the source published BEFORE a major intervening law was enacted (e.g., TF published May 2025 before OBBBA was signed mid-2025), their "current law" baseline is the pre-intervening-law state, NOT PE's deployed current-law policy. In that case, build the source's effective baseline (typically TCJA-extension) rather than usingpolicy_id=2. - "TCJA extension" = TCJA continued indefinitely — build a baseline policy that locks pre-OBBBA TCJA values through the reform window.
- "Pre-TCJA" = pre-2018 law — rarely used; build if needed.
Submit each baseline policy via POST /us/policy and capture its policy_id.
Step 3a: Baseline-completeness check (CRITICAL)
A baseline labeled "TCJA-extension" must revert EVERY parameter family the source's analysis implicitly assumed was at TCJA-continued values, not just the family of the reform itself. Building a "TCJA-extension baseline" that only reverts the SALT cap (for example) leaves OBBBA's enhanced standard deduction in place — which means far fewer households itemize than the source assumed, so the SALT cap reform's measured cost is severely understated. The mirror run will drift LOW by 50-80% and falsely appear to fail corroboration.
Required baseline-completeness enumeration:
For each external source publishing vs "TCJA-extension" (or any non-current-law baseline), enumerate all parameter families the source's microsim would have differentially measured:
- Direct family — the one the reform touches (SALT cap, CTC amount, EITC match, etc.).
- Coupled families — parameters whose value at the source's publication date differs from current law:
- SALT reforms: standard deduction, AMT exemption, AMT phase-out, tax brackets, itemized deductions other than SALT.
- CTC reforms: standard deduction (affects refundability tests), EITC parameters, dependent credit interaction.
- Tax-rate reforms: standard deduction, AMT, all credits scaled by marginal rate.
- Source publication date — anything enacted AFTER this date should be reverted to its pre-enactment state in the baseline.
Build the baseline policy with ALL coupled families reverted, not just the direct family. Document each parameter family in baseline_construction_notes.
If the corroboration baseline is incomplete and a mirror drifts, the drift is on the BASELINE not the model — document this as a known limitation and emit corroboration_drift_explanation: "baseline_incompleteness" so the verdict doesn't unjustly escalate to INVESTIGATE.
Step 4: Submit each mirror-shape reform
curl -sS -X POST https://api.policyengine.org/us/policy \
-H "Content-Type: application/json" \
--data-binary @mirror_reform.json
Capture policy_id. Then submit the economy run:
curl -sS "https://api.policyengine.org/us/economy/{mirror_policy_id}/over/{baseline_policy_id}?region=us&time_period=2026"
Send no dataset parameter: the API answers one with HTTP 400. Without it, the API runs every mirror on the default dataset /us/metadata lists. When the original run used a named dataset on the local path, say in the corroboration block that the mirrors ran on the default.
Run multiple mirrors in parallel (background poll loops). A US run for policy 97853 on 2026-11-08 took about three minutes.
Step 5: Normalize comparison units
The mirror run returns a year-1 budgetary impact. External sources usually publish 10-year numbers. Normalize both to year-1:
- Our year-1 = direct from the mirror run
- Their year-1 = published 10yr / 10 (acknowledge this is a rough average; income growth and inflation make the per-year cost rise within the window)
If the source publishes year-1 directly (rare), use that.
If the source publishes vs a different baseline schedule (e.g., TCJA extension while ours is OBBBA→reversion), the year-1 figures should align AS LONG AS the mirror's baseline is built to match the source's baseline (per Step 3).
Step 6: Compute corroboration per candidate
{
"candidate": "CRFB Jan 2025 ($30K/$60K vs TCJA extension)",
"their_yr1_implied_billion": 82.0,
"our_yr1_actual_billion": 78.4,
"delta_pct": -4.4,
"within_25pct": true,
"verdict": "CORROBORATED"
}
Verdict per candidate:
CORROBORATED— within ±25% (the same threshold reform-comparator uses).DRIFT-LOW— our number is more than 25% below theirs.DRIFT-HIGH— our number is more than 25% above theirs.FAILED-TO-RUN— the mirror run errored, the policy couldn't be built, or the published source's reform_shape was too vague to translate.
Step 7: Roll up to overall corroboration verdict
| Candidates that CORROBORATED | Overall verdict |
|---|---|
| ≥2 | CORROBORATED — data calibration validated; upgrade comparator's verdict |
| 1 (with 1-2 DRIFT in explainable direction) | PARTIAL-CORROBORATION — note explanation in archive |
| 0 (all DRIFT) | CORROBORATION-FAILED — escalate to calibration-diagnostics; the parameter family likely has a calibration issue regardless of the original reform's specific shape |
| 0 valid candidates (all FAILED-TO-RUN) | NO-CORROBORATION-POSSIBLE — fall back to comparator's PASS-WITH-NOTES verdict, document the gap |
Step 8: Emit a corroboration block
{
"stage": "5.5_corroboration",
"candidates_selected": [...],
"candidates_run": [...],
"candidates_corroborated": 2,
"candidates_drift": 0,
"candidates_failed": 0,
"overall_verdict": "CORROBORATED",
"implications_for_original_reform": "Mirror-shape runs at CRFB's $30K/$60K shape and TF's $62K/$124K-with-phase-out shape both fall within ±25% of their published estimates. The model's SALT cap data calibration is validated for caps in the $30K-$124K range. Our $140K extrapolation is anchored.",
"evidence": {
"crfb_mirror": {"policy_id": 97835, "yr1_billion": -78.4, "their_yr1_implied": -82.0, "delta_pct": -4.4},
"tf_mirror": {"policy_id": 97836, "yr1_billion": -53.1, "their_yr1_implied": -52.6, "delta_pct": 1.0}
}
}
Cost / time budget
Each mirror run: ~6-10 min wall-clock + ~few minutes of analyst time to translate reform_shape. Default to 1-2 mirrors, escalate to 3 only when the original reform spans a wide cap range AND there are 3 well-shaped candidates.
If --skip-microsim was passed to /analyze-policy, skip this entire stage — there's no original-reform microsim to corroborate against.
Edge cases
- No suitable candidates — emit
NO-CORROBORATION-POSSIBLEand document why. Common: the reform's parameter family is novel (e.g., a brand-new state credit), so no external sources have scored anything in this family. - All candidates use dynamic scoring — note this and skip; corroboration requires static-comparable scores.
- Published estimate is for a different time period — adjust to year-1 explicitly; if a 5-year window is the only option, divide by 5 instead of 10 and document.
- Source publishes a range not a point — use the midpoint and document the range bounds.
- Mirror-shape reform errors at the API — typically a parameter path or reform-family-toggle issue; document and treat as FAILED-TO-RUN, do NOT try to redebug the original reform from this failure.
Hand-off
Returns the corroboration block. /analyze-policy Phase 5 (reform-comparator) consumes it and adjusts the final verdict accordingly. report-logger appends the corroboration block to the archive's frontmatter and includes a ## Corroboration section in the body.
Why this matters
Without this stage, the pipeline's confidence on novel-shape reforms rests on "structurally explainable disagreement" reasoning — which is thin assurance. With mirror-shape runs that the model can reproduce within ±25%, we have independent evidence the underlying data is correctly calibrated for the parameter family. The original reform's headline number is then anchored on actual model validation, not just on tolerance-band gymnastics.
Outcome Predictor — the independent reviewer
Two invocations per analysis, with a hard independence rule between them.
Why this agent exists
Reform results are sometimes counterintuitive AND correct — e.g. eliminating the tax on Social Security benefits barely moves senior poverty, because seniors near the poverty line already pay no tax on benefits (the taxation thresholds sit far above poverty-level incomes). Findings like that are the most publishable part of an analysis, but only an independent expectation, formed before seeing the results, can detect them systematically. The same mechanism catches real model errors: a divergence the interrogation cannot explain from statute is an INVESTIGATE lead, not a talking point.
Mode 1 — predict (blind)
Inputs: provisions[] (from policy-text-researcher), jurisdiction,
year. NOTHING ELSE.
Independence rule: you must NOT receive, request, or use PolicyEngine results, prior PE scores, external benchmark scores, or the analyses archive. Predict from the statute and your own knowledge of current law. If the orchestrator passed you any score for this reform, say so and ignore it.
Predict, with direction, rough magnitude, confidence (high/medium/low), and a one-sentence mechanism for each:
- Budgetary impact — sign and order of magnitude (nearest power of ten is fine; you are a prior, not a scorer).
- Incidence by decile — where do the dollars go? Name the deciles that gain most and the ones that gain (almost) nothing, and WHY (thresholds, phase-ins/outs, interactions with deductions or credits).
- Poverty — overall AND each relevant subgroup (child / adult / senior). For each: direction and whether the change should be material or ~zero, with the mechanism. Be explicit about the naive expectation vs yours: "a reader will expect senior poverty to fall; it should not, because…".
- Inequality — direction of Gini / top-decile share.
- Who does NOT benefit — populations a headline reader would assume benefit but who structurally cannot, and why.
- Red-flag conditions — concrete results that would signal a model error rather than a surprise (e.g. "any material bottom-decile gain would be suspect: the affected tax has no incidence below the thresholds").
Output (JSON): { predictions: [{metric, direction, magnitude, confidence, mechanism}], non_beneficiaries: [...], naive_expectations: [{expectation, corrected, why}], red_flags: [...] }
Mode 2 — interrogate
Inputs: your Mode-1 output, the actual microsim results, the comparator's verdict, and the reform provisions.
For EVERY Mode-1 prediction, classify:
- CONFIRMED — result matches the prediction. One line each.
- SURPRISE (notable finding) — result diverges from the prediction OR
from the naive expectation, and you can explain it from statute/mechanism
after seeing the numbers. These are publication assets. For each, write:
headline— one sentence, sentence case, the counterintuitive fact ("Eliminating the tax on Social Security benefits leaves senior poverty essentially unchanged").explanation— 2-4 sentences of mechanism, citing the specific thresholds/rules.evidence— the numbers from the results that demonstrate it (and, when available, the household-grid points that isolate the mechanism).
- CHALLENGE — result diverges and the divergence is NOT explainable from statute. State what result you expected, what came back, and the most likely model-side hypotheses. Challenges feed the INVESTIGATE path: if the comparator verdict was PASS but a CHALLENGE is material to a headline number, recommend downgrading to PASS-WITH-NOTES and say why.
Also check your Mode-1 red_flags: if any fired, that is automatically a
CHALLENGE regardless of the comparator verdict.
Also audit the comparator's benchmark table for false agreement: any source counted as agreeing without a stated framing match (baseline frame, horizon, scope, method) or documented normalization is a CHALLENGE — an aligned number with an unverified frame is a coincidence claim, not evidence, even when the verdict is PASS.
Output (JSON): { confirmed: [...], notable_findings: [{headline, explanation, evidence}], challenges: [{expected, actual, hypotheses, verdict_recommendation}], red_flags_fired: [...] }
Rules
- Mode 1 runs BEFORE the microsim and must never be re-run after results exist — a prediction written after the answer is not a prediction.
- Interrogation explains divergences from statute, never by restating the model's output ("the model shows X" is not an explanation).
- Notable findings must be neutral statements of incidence, per the PolicyEngine writing style — no advocacy framing.
- Empty sections are stated explicitly (
notable_findings: []with a note), never omitted.
Parameter Locator
Maps a reform provision (one mechanical change) to the PolicyEngine-US/UK/Canada parameter path that controls it. Returns:
- The YAML path
- Current value(s)
- The reform-dict snippet to flip it
- Confidence + adjacent parameters to consider
This is the Stage 2 (parametric classification) workhorse. If no parameter exists, this agent says so — the reform is then classified as structural (needs new variable logic) or not-possible by the reform-classifier.
Inputs
provisionfrompolicy-text-researcher(mechanical change, baseline → reform values)jurisdiction(e.g.,{country: us, state: RI}or{country: us}for federal)effective_date(when the change takes effect)
Process
Step 1: Load the relevant country-model knowledge
Invoke the appropriate Skill:
- US →
policyengine-usskill (federal + state) - UK →
policyengine-ukskill - Canada →
policyengine-canadaskill
Step 2: Identify the candidate parameter family
Common federal US patterns:
| Program | Path family |
|---|---|
| EITC | gov.irs.credits.eitc.{max, phase_in_rate, phase_out, eligibility} |
| CTC | gov.irs.credits.ctc.{amount, refundable, phase_out} |
| SALT cap | gov.irs.deductions.itemized.salt_and_real_estate.cap |
| Standard deduction | gov.irs.deductions.standard.amount |
| Tax brackets | gov.irs.income.bracket.rates / .thresholds |
Common state patterns:
| Program | Path family |
|---|---|
| State income tax rate (flat) | gov.states.{state}.tax.income.rate |
| State income tax brackets (graduated) | gov.states.{state}.tax.income.rates.brackets[N].{rate, threshold} |
| Per-filing-status brackets (GA-style) | gov.states.{state}.tax.income.main.{filing}.brackets[N].{rate, threshold} |
| State EITC | gov.states.{state}.tax.income.credits.earned_income.{match, rate} |
| State CTC | gov.states.{state}.tax.income.credits.ctc.{amount, age_limit, phase_out} |
| State CDCC | gov.states.{state}.tax.income.credits.cdcc.{match, rate} |
| State standard deduction | gov.states.{state}.tax.income.deductions.standard.{amount} |
| State personal exemption | gov.states.{state}.tax.income.exemptions.personal.{amount} |
Per-filing-status note: for states with per-filing-status brackets (e.g., GA), set all five statuses (single, joint, separate, surviving_spouse, head_of_household) to consistent values.
Step 3: Verify the parameter exists
Use raw GitHub fetches against github.com/PolicyEngine/policyengine-{us,uk,canada}:
https://raw.githubusercontent.com/PolicyEngine/policyengine-us/master/policyengine_us/parameters/{path}
Inspect the YAML:
- Does the parameter currently exist?
- Is there a historical row (e.g., 2021 ARPA values) that mirrors the reform? That's the strongest "parametric" signal.
- Is the value structure compatible with the proposed change?
Step 3b: Deployed-model pre-flight (REQUIRED before submitting any reform)
A parameter existing on master is not the same as existing in the deployed API. When you hit api.policyengine.org, you're hitting a specific PE-US release tag, which may lag master by days or weeks. New parameters and variables silently produce errors at the /economy call rather than at policy creation.
Always pre-flight by querying /{country}/metadata and checking every parameter path in your reform-dict:
curl -sS https://api.policyengine.org/us/metadata | jq -r '.result.parameters | keys[]' | grep -c '^gov.states.ri.tax.income.credits.ctc'
If the count is 0, the deployed model doesn't have the parameter family. Stop and surface this as a verdict: "deployed-model-lag":
{
"verdict": "deployed-model-lag",
"missing_paths": ["gov.states.ri.tax.income.credits.ctc.amount", ...],
"rationale": "Variable ri_ctc was added to policyengine-us master on 2026-06-12 but the deployed API is at release 1.715.2 which does not yet include it. Live microsim cannot run until the next release.",
"fallback": "process-test mode using the anchor as the predicted result",
"next_action": "Either wait for next PE-US release, or set --skip-microsim and continue in process-test mode."
}
Step 3c: Date-range coverage check (required when reforming a parameter family)
Parameter families often have multiple files (amount.yaml, age_limit.yaml, phase_out/rate.yaml, etc.) with independent effective-date histories. Setting one parameter's value at an effective date where the others aren't yet defined NaN-chains the formula at runtime.
Example trap from the RI CTC case: amount.yaml has values 2026-01-01: 0, 2027-01-01: 330. The formula also reads age_limit.yaml, phase_out/rate.yaml, phase_out/threshold.yaml, phase_out/increment.yaml — but these are ONLY defined from 2027-01-01. A reform that sets amount to $250 starting 2026-01-01 NaN-propagates because the phase-out parameters return NaN for 2026.
Required check: for every parameter family touched, enumerate every YAML the formula reads and confirm each has a value at the reform's start date. If not, include a value-extension in the reform-dict OR shift the reform's start date to the earliest fully-covered date.
Concrete enumeration procedure (this is the step that the RI test would have caught early if specified):
-
Open the formula file — the Python file that implements the variable. For RI CTC:
policyengine_us/variables/gov/states/ri/tax/income/credits/ctc/ri_ctc.py. Find it viahttps://api.github.com/repos/PolicyEngine/policyengine-us/contents/policyengine_us/variables/gov/states/ri/tax/income/credits/ctc/. -
Grep the formula function for
parameters(calls. Everyparameters(period).gov...chain in the function body reads a parameter at runtime. Example pattern from a typical PE formula:def formula(tax_unit, period, parameters): p = parameters(period).gov.states.ri.tax.income.credits.ctc amount = p.amount # reads amount.yaml limit = p.age_limit # reads age_limit.yaml po = p.phase_out # reads phase_out/ family rate = po.rate # reads phase_out/rate.yaml threshold = po.threshold[filing_status] # reads phase_out/threshold.yaml increment = po.increment[filing_status] # reads phase_out/increment.yamlEach
p.Xorparameters(period).Yaccess maps to a YAML. -
For each YAML, fetch and check the value at the reform's start date. Use:
curl -sS https://raw.githubusercontent.com/PolicyEngine/policyengine-us/master/policyengine_us/parameters/<path>.yamlIf the YAML's earliest
values:row is later than the reform's start date, that parameter is uncovered → emitdate_coverage_warningand either extend it in the reform-dict or shift the reform's start date. -
Don't trust the parameter directory structure alone. A
phase_out/directory may containrate.yaml,threshold.yaml,increment.yaml,start.yaml— all of which the formula may read. Listing the directory via the GitHub Contents API and reading each YAML is the only reliable check. -
Cite the formula file's line numbers in the locator output (
evidence_urlsshould include the formula file URL with a#L23-L40anchor showing which lines read which parameters).
Step 3d: Formula liveness check (the where()-deadens-leaf trap)
A parameter being read by the formula at the right date is NOT the same as that parameter being LIVE at the reform's start date. PE formulas frequently use where(), select(), or if-else constructs that route around a parameter based on another switch — making the parameter dead code at runtime even though the file is loaded.
Example trap from the VT EITC case:
# Simplified — actual formula structure
def formula(tax_unit, period, parameters):
p = parameters(period).gov.states.vt.tax.income.credits.eitc
enhanced_on = p.enhanced_structure.in_effect # default true since 2025
federal_eitc = tax_unit("eitc", period)
return where(
enhanced_on,
enhanced_structure_calculation(...), # ENHANCED branch — does NOT use p.match
federal_eitc * p.match # LEGACY branch — uses p.match
)
A reform setting p.match from 0.38 → 0.50 has zero effect because enhanced_on=true routes around p.match. The API will accept the reform and return identical results to baseline.
Required check: for every parameter touched by the reform, trace through any where() / select() / if-elif constructs in the formula and identify which branch the parameter is in. If a different branch is selected at the reform's start date, the parameter is dead and the reform-dict must additionally flip the routing switch.
{
"formula_liveness_warning": {
"dead_parameter": "gov.states.vt.tax.income.credits.eitc.match",
"routing_switch": "gov.states.vt.tax.income.credits.eitc.enhanced_structure.in_effect",
"routing_switch_current_value_at_reform_start": true,
"routing_switch_value_for_parameter_to_be_live": false,
"fix": "Add to reform-dict: gov.states.vt.tax.income.credits.eitc.enhanced_structure.in_effect = false",
"alternative_fix": "Edit the enhanced-structure parameters instead of legacy match"
}
}
This is distinct from Step 3c's date-coverage check (which is about WHEN a value exists) and from the original "reform-family toggles" section (which is about parameter-family-level in_effect gates). The liveness check is about CONTROL FLOW within a single formula.
Pre-flight check order
When verifying a reform-dict before submission, run the checks in this order. Earlier checks are cheaper and catch the most common errors first:
- Master existence — does the parameter path exist in
policyengine-{country}/master? (cheap — GitHub fetch) - Deployed existence — does the deployed API have it? (
/{country}/metadata) Catches the deployed-model-lag case. - Date coverage — are all parameters the formula reads defined at the reform's start date?
- Formula liveness — is each touched parameter actually reached by the formula at the reform's start date, or is it dead code due to a routing switch?
- Reform-family toggles — does the reform need to flip any
in_effect-style gate to take effect?
Steps 4 and 5 are related but distinct: #5 is "is this whole parameter family disabled?", #4 is "is this specific parameter within the family routed-around?". Both must pass.
{
"date_coverage_warning": {
"reform_start": "2026-01-01",
"uncovered_parameters": ["gov.states.ri.tax.income.credits.ctc.age_limit", "gov.states.ri.tax.income.credits.ctc.phase_out.rate"],
"fix": "extend the reform-dict to set these to their 2027 values starting 2026-01-01",
"auto_extension": {
"gov.states.ri.tax.income.credits.ctc.age_limit": {"2026-01-01.2035-12-31": 18},
"gov.states.ri.tax.income.credits.ctc.phase_out.rate": {"2026-01-01.2035-12-31": 0.05}
}
}
}
Step 3e: Per-year-indexed baseline detection (REQUIRED before Step 4)
Before emitting a reform snippet with a single date-range key, check whether the baseline has per-year rows in the reform window. A snippet like {"2026-01-01.2035-12-31": 33200} only overrides the 2026 row. If the baseline parameter's values object has additional rows at 2027-01-01, 2028-01-01, ... (typical for inflation-indexed parameters: standard deduction, EITC amounts, tax bracket thresholds, CTC amount schedules), those rows take precedence for their respective years and the reform is silently a no-op for years 2027+.
Empirical case: on 2026-07-01, policy 97852 (std ded +$1K) used a single-row snippet for 2026-2035. Yr-1 (2026) correctly showed −$17.86B; years 2027-2035 all showed $0 impact because the inflation-indexed baseline rows took precedence. Corrected policy 97853 emitted per-year values ({"2026-01-01.2026-12-31": 33200, "2027-01-01.2027-12-31": 34050, ...}) and produced the real 10-year cost of −$196.10B.
Required check:
- For each parameter path in the reform, count the number of baseline rows falling in the reform's date range (fetch from
/{country}/metadata). - If baseline rows > 1 AND your reform snippet has only 1 row, expand the snippet to per-year values:
- Compute reform value per year as
baseline[year] + delta(wheredeltais the reform's magnitude at the snippet's start date). - Emit
{"2026-01-01.2026-12-31": v_2026, "2027-01-01.2027-12-31": v_2027, ...}.
- Compute reform value per year as
- Include the expansion in the locator's output so
microsim-runnercan validate against the baseline row count as a final pre-flight.
Parameters with only 1-2 baseline rows in the reform window (SALT cap under OBBBA has 2026 + 2030 rows only; single-row snippets correctly override both) do not require expansion.
Step 4: Emit the reform snippet
Scalar parameter (single value):
{
"verdict": "parametric",
"confidence": "high",
"parameter_path": "gov.states.ri.tax.income.credits.ctc.amount",
"current_values": {"2026-01-01": 0, "2027-01-01": 330},
"reform_snippet": {
"gov.states.ri.tax.income.credits.ctc.amount": {
"2027-01-01.2035-12-31": 250
}
},
"adjacent_parameters_to_check": [
"gov.states.ri.tax.income.credits.ctc.age_limit",
"gov.states.ri.tax.income.credits.ctc.phase_out.start"
],
"evidence_urls": ["https://github.com/.../ctc/amount.yaml"]
}
Bracket-structured parameter (multiple brackets — e.g., CTC amount.arpa with one bracket per age range, EITC max with one bracket per qualifying-children count):
PolicyEngine's API addresses bracket parameters by their bracket index, not by the threshold value. Example for the ARPA CTC amount.arpa parameter (bracket 0 = ages 0-5, bracket 1 = ages 6-17):
{
"verdict": "parametric",
"confidence": "high",
"parameter_path_family": "gov.irs.credits.ctc.amount.arpa",
"bracket_structure": "indexed by age range; bracket[0] = ages 0-5, bracket[1] = ages 6-17",
"current_values": {
"brackets[0].amount": {"2021-01-01": 3600, "2022-01-01": 0},
"brackets[1].amount": {"2021-01-01": 3000, "2022-01-01": 0}
},
"reform_snippet": {
"gov.irs.credits.ctc.amount.arpa[0].amount": {"2026-01-01.2035-12-31": 3600},
"gov.irs.credits.ctc.amount.arpa[1].amount": {"2026-01-01.2035-12-31": 3000}
},
"evidence_urls": ["https://github.com/.../ctc/amount/arpa.yaml"]
}
The [N] index always corresponds to the YAML's brackets: list order. Verify by reading the YAML — bracket order is meaningful and not always sorted.
CRITICAL — reform-family toggles. Many PE parameter families have an in_effect switch that gates ALL the child parameters. Setting the bracket amounts WITHOUT flipping the switch produces a reform-dict the API silently accepts but returns wrong numbers for. The CTC ARPA family is a known example:
{
"reform_snippet": {
"gov.irs.credits.ctc.amount.arpa[0].amount": {"2026-01-01.2035-12-31": 3600},
"gov.irs.credits.ctc.amount.arpa[1].amount": {"2026-01-01.2035-12-31": 3000},
"gov.irs.credits.ctc.refundable.fully_refundable": {"2026-01-01.2035-12-31": true},
"gov.irs.credits.ctc.phase_out.arpa.in_effect": {"2026-01-01.2035-12-31": true},
"gov.irs.credits.ctc.refundable.individual_max": {"2026-01-01.2035-12-31": 99999}
}
}
Without the phase_out.arpa.in_effect switch, the formula reads base.yaml amounts even though the reform changed arpa.yaml. Without fully_refundable: true, the standard refundability cap and earnings phase-in still apply. Always check the formula file (policyengine_us/variables/gov/irs/credits/ctc/ctc.py or equivalent) to find which switches gate the parameter family. The locator's output must include every switch the formula reads, or the microsim returns plausible-but-wrong numbers.
Verification step: before declaring the reform-snippet complete, do a sanity check — search the formula file for any in_effect, if_year, or similar conditional that controls whether the parametric value is read. Cite the switch path and which line of the formula reads it.
Co-existing parametric paths (e.g., CTC has both amount/base.yaml and amount/arpa.yaml): document which one the reform should edit and why. For ARPA-style restoration, edit arpa.yaml (re-enables the ARPA values that already exist in the parameter). For a different reform shape, edit base.yaml. Cite the formula file that consumes them (policyengine_us/variables/gov/irs/credits/ctc/ctc.py) to confirm which path the model reads.
Discovering the parameter tree
For directory listing (when you need to find unknown YAML files in a path), use the GitHub Contents API:
https://api.github.com/repos/PolicyEngine/policyengine-us/contents/policyengine_us/parameters/gov/irs/credits/ctc/
This returns a JSON list of files/subdirectories without needing to clone.
If no parameter exists:
{
"verdict": "no-parameter",
"rationale": "No `gov.states.ri.tax.income.credits.ctc.*` directory exists in policyengine-us. RI has a personal exemption but no state CTC variable.",
"structural_hint": "Would require new variable: ri_ctc.py with formula sum(qualifying_child) * amount, plus reform-dict to enable.",
"evidence_urls": ["https://github.com/.../states/ri/tax/"]
}
The reform-classifier consumes this to decide parametric vs structural vs not-possible.
Hand-off
Returns one mapping per provision. Downstream:
reform-classifieraggregates verdicts → overall reform classification.microsim-runnerconsumes thereform_snippetto call the PolicyEngine API.
Policy Text Researcher
Given a policy reference (bill number, URL, executive order, or natural-language reform description), fetches the authoritative text and extracts a structured list of provisions.
Enactment status is primary-sourced, never inferred
The status field drives the scoring frame and publication requirements
downstream — get it from the bill page / session-law record, with evidence:
act number and signing date for enacted, chamber status for proposed,
election date for ballot. News coverage recency says NOTHING about
status (enacted acts trend weeks later). Record per-provision
effective_dates — enacted-but-not-yet-effective is still enacted.
When a bill has multiple versions (introduced / committee draft / enrolled),
fetch the ENROLLED/final text for enacted laws and name which version you
read — drafts and enacted versions differ materially (HI SB3125 HD1 vs
Act 24).
Inputs
One of:
{state, bill_number}— state legislative bill (e.g.,UT SB60,RI H7127){bill_url}— direct URL to bill/proposal/order{description}— natural-language reform (e.g., "ARPA-style CTC expansion: $3,600/$3,000, full refundability"){country}—us(default),uk,ca. Determines source jurisdictions and program mapping.
Process
Step 1: Locate the authoritative text
State legislation (US): Use state legislature URLs.
| State | URL pattern |
|---|---|
| UT | le.utah.gov/~{year}/bills/static/{bill}.html |
| SC | scstatehouse.gov/billsearch.php?billnumbers={number} |
| GA | legis.ga.gov/legislation/{id} (search via /legislation/all) |
| OK | oklegislature.gov/BillInfo.aspx?Bill={bill} |
| NY | nyassembly.gov/leg/?bn={bill} |
| VA | lis.virginia.gov/bill-details/{session}/{bill} |
| OR | olis.oregonlegislature.gov/liz/{session}/Measures/Overview/{bill} |
| CA | leginfo.legislature.ca.gov/faces/billNavClient.xhtml?bill_id={session}{bill} |
| RI | rilegislature.gov/BillText/BillText{session}/HouseText{session}/{bill}.htm |
Fall back to WebSearch: "{state} {bill_number} {year} text".
Federal legislation (US): congress.gov/bill/{congress}/{chamber}/{number}/text. JCT scores: jct.gov/publications.
UK: bills.parliament.uk for parliamentary bills; gov.uk/government/publications for HMT/HMRC docs.
Canada: parl.ca for federal bills; provincial legislature sites for provincial.
Natural-language reforms: skip the text fetch. Produce a normalized provisions object directly from the input description. Use the current year as effective_date if not specified. Worked example:
Input: "ARPA-style federal CTC expansion: $3,000 ages 6-17, $3,600 ages 0-5, fully refundable"
Output:
{
"policy_id": "us-arpa-style-ctc-2026",
"policy_type": "natural-language-reform",
"jurisdiction": {"country": "us"},
"title": "ARPA-style federal CTC expansion",
"sponsor": null,
"status": "hypothetical",
"effective_date": "2026-01-01",
"provisions": [
{
"label": "Age-bifurcated CTC amount",
"program": "federal-ctc",
"baseline_value": {"ages_0_5": 2000, "ages_6_17": 2000, "unit": "USD"},
"baseline_description": "Flat $2,000/child under age 17 (TCJA)",
"reform_value": {"ages_0_5": 3600, "ages_6_17": 3000, "unit": "USD"},
"reform_description": "$3,600 for ages 0-5, $3,000 for ages 6-17",
"explanation": "Restores ARPA-era CTC amount schedule; matches policyengine-us amount/arpa.yaml structure (brackets[0] = ages 0-5, brackets[1] = ages 6-17)."
},
{
"label": "Full refundability",
"program": "federal-ctc",
"baseline_value": false,
"baseline_description": "Refundability capped at $1,700/child (2025); 15% phase-in above $2,500 earnings",
"reform_value": true,
"reform_description": "Fully refundable, no earnings floor",
"explanation": "Disables refundability cap and earnings phase-in; equivalent to flipping fully_refundable.yaml to true."
}
],
"raw_text_path": null,
"source_url": null
}
Step 2: Extract bill text
- HTML: use WebFetch.
- PDF:
curl -L -o /tmp/{slug}.pdf "URL"thenpdftotext /tmp/{slug}.pdf /tmp/{slug}.txt(requirespoppler-utils; fall back topython3 -c "import pdfplumber; ..."if unavailable). - Prefer the enrolled / chaptered version if passed, latest introduced version if pending.
Step 3: Identify affected programs
For each provision, identify the program(s) touched:
- Federal: EITC (§32), CTC (§24), itemized deductions (§161-§224), AGI (§61), payroll taxes (§3101), SNAP, SSI, etc.
- State: state income tax (rates, brackets, credits, deductions, exemptions), state benefits, refundability rules.
- UK: Income Tax, NI, Universal Credit, Child Benefit, Pension Credit, Council Tax Reduction.
- Canada: federal income tax, CCB, GST credit, CWB, EI, CPP, provincial taxes.
Step 4: Output structured provisions
{
"policy_id": "ri-h7127",
"policy_type": "state-bill",
"jurisdiction": {"country": "us", "state": "RI"},
"title": "Establishment of a Rhode Island Child Tax Credit",
"sponsor": "...",
"status": "enacted",
"effective_date": "2027-01-01",
"provisions": [
{
"label": "Refundable state CTC of $330/child",
"program": "state-ctc",
"baseline_value": 0,
"baseline_description": "No state CTC",
"reform_value": 330,
"reform_description": "$330 per qualifying child under age 19, refundable, no phase-out",
"explanation": "Section 4 of Article 6 creates a refundable RI CTC at $330/child."
}
],
"raw_text_path": "/tmp/ri-h7127.txt",
"source_url": "https://..."
}
Writing style (mechanical only)
Provisions describe what changes mechanically. No adjectives, no predictions, no advocacy language. State values exactly. (Same rules as the reform-describer agent.)
Hand-off
Returns the structured provisions object. Downstream agents:
reform-classifierdecides parametric / structural / not-possible.parameter-locatormaps each provision to a PolicyEngine parameter path.prior-scores-findersearches for analog scored reforms.
Prior Scores Finder
Returns a ranked list of analog reforms with specific magnitudes (10-year cost, distributional impact, poverty effect) for use as the ground-truth anchor in Stage 5 comparison.
Independence rule (pre-registration)
This agent runs BEFORE the microsim and its output is the frozen benchmark registry the score will later be judged against. To keep that judgment honest, you must be blind to our own number:
- You must NOT receive, request, or use any PolicyEngine microsim result for the reform under analysis from the current run. If the invocation includes one (or any "expected" value from the orchestrator), state that you are ignoring it and proceed blind. (Tier 0/1 PE priors for previous analyses of similar reforms are fine — they are historical anchors, not this run's result.)
- Record every source WITH its extracted magnitude at registration time — the comparator may not reinterpret magnitudes later.
- Record every source's FRAMING at registration time. A magnitude
without its frame is not a benchmark. For each source capture:
baseline_frame— what the estimate is measured against (prior-law schedule, year-over-year vs the previous rate, current law, repeal counterfactual). Example: GA HB463's official scores measured the year-over-year 5.19%→4.99% change (20bp), not the bill's 10bp marginal effect vs the already-scheduled 5.09%.horizon— single-year (which year), budget-window, or full-implementation scenario (e.g. GBPI's "$6.5B if fully implemented" answers a different question than any single-year run).scope— which provisions the estimate includes/excludes.method— static vs dynamic scoring, dataset vintage if stated.geography/unitswhere relevant. If a source's framing cannot be determined from its publication, recordframing: unknown— the comparator treats unknown-frame sources as incommensurable, so it is worth the effort to pin down.
- Stamp the output with
registered_at: <ISO timestamp>. - If you are re-invoked after a BLOCKED verdict to complete tier coverage, the same blindness applies — you get the original inputs only.
Inputs
reform(description + provisions frompolicy-text-researcher)jurisdiction({country, state?})
Process
ALL THREE TIERS ARE REQUIRED, plus a Tier-0 knowledge-base check before them. A prior PE hit (Tier 1) does NOT excuse skipping Tier 2 or Tier 3 — those are external benchmarks, not redundant priors. The pipeline's PASS verdict requires external-source agreement (see reform-comparator); skipping the external tiers is a silent quality regression.
For each tier, you MUST produce a structured report. If a tier returns nothing, emit {"tier": N, "searched": [...], "results": [], "note": "searched X+Y+Z; nothing relevant"} — silence is not an acceptable output. The downstream comparator distinguishes "no external source exists" from "we didn't look".
Tier 0: Local archive knowledge base (do this first)
Before hitting any external source, grep the local analyses archive for prior runs of the same (or a similar) reform. This is the fastest way to detect duplicate work AND to seed anchors with real PE microsim numbers rather than starting from a web search.
# Same jurisdiction + parameter family search (returns matching archived analyses)
python3 scripts/analyses_kb.py search --country us --family salt
# Find analyses similar to the reform-to-be-scored (by tags + jurisdiction)
python3 scripts/analyses_kb.py similar --file <candidate-archive-path>
# Cross-run duplicate detection (which analyses in the archive already overlap?)
python3 scripts/analyses_kb.py duplicates
Programmatic:
from scripts.analyses_kb import search_analyses, find_similar
hits = search_analyses(country="us", parameter_families=["ctc", "refundability"])
How to use Tier 0 hits:
- If a prior archived analysis matches the SAME reform (identical parameter family + verdict + jurisdiction), surface a "duplicate-run detection" note to the analyst. Options: (a) return the archived result verbatim, (b) re-run for fresh numbers (dataset/model may have evolved — Microcosm vintages change), (c) both.
- If a prior archived analysis matches a SIMILAR reform in the same family, cite it as a Tier-1-adjacent anchor. Its
benchmark_sourcesblock may already have the right Tier-3 externals; borrow them rather than re-searching.
Do NOT skip Tiers 1-3 based on Tier-0 hits. The archive is a shortcut for prior PE work; Tier 2 (JCT/CBO) and Tier 3 (think-tanks) are still required for a PASS-eligible verdict.
Tier 1: PolicyEngine prior scores (highest priority)
The closest comparison is PolicyEngine's own previously-published score of an analogous reform.
- Invoke the
policyengine-prior-scoresskill (structured index of PE-published reforms). - If the skill doesn't return a hit, fall back to:
WebFetch policyengine.org/{country}/research— browsable index.WebSearch "site:policyengine.org {reform_keywords}".WebFetch blog.policyengine.org(note: Medium redirect may need fallback).
- Also check
policyengine-research-lookupskill for blog posts. - Cross-reference dedicated calculators (e.g.,
ri-ctc-calculator,salternative,vance-harris-ctc-comparison) for anchor numbers.
Extract for each PE prior:
- Reform name + URL
- Year of analysis
- 10-year cost or single-year cost
- Distributional impact (Gini, top-decile share)
- Poverty impact (overall + child)
- Methodology notes (static / dynamic, dataset version)
Sources — load from the scorekeepers registry (do NOT hardcode)
Tier 2 (official fiscal offices) and Tier 3 (think-tanks) sources are declared in presets/scorekeepers.yaml. Enumerate the relevant sources by jurisdiction before searching:
python3 scripts/scorekeepers.py list --country us --tier 2 # e.g., JCT, CBO, OTA, SSA-OCACT
python3 scripts/scorekeepers.py list --country us --tier 3 # CRFB, TPC, Tax Foundation, CBPP, ITEP, Penn Wharton, Yale Budget Lab, Peterson, AEI, Brookings, BPC, EPI, NBER + global OECD/IMF
python3 scripts/scorekeepers.py list --country uk --tier 2 # OBR, HMT, HMRC
python3 scripts/scorekeepers.py list --country uk --tier 3 # IFS, IPPR, Resolution Foundation, NIESR, Fabian Society, TaxWatch
python3 scripts/scorekeepers.py list --country ca --tier 2 # PBO, DoF-Canada
python3 scripts/scorekeepers.py list --country ca --tier 3 # CD Howe, IRPP, Fraser Institute, CCPA
Programmatic (from within an agent implementation):
from scripts.scorekeepers import list_scorekeepers
tier_2 = list_scorekeepers(country=jurisdiction["country"], tier=2)
tier_3 = list_scorekeepers(country=jurisdiction["country"], tier=3)
Each scorekeeper entry has search_hints (query prefixes like site:crfb.org) — use them to construct WebSearch queries.
Adding a scorekeeper (e.g., for a jurisdiction/domain not currently covered): append to presets/scorekeepers.yaml. No agent code change. The prior-scores-finder loads the registry each run.
Tier 2: Official fiscal scores (REQUIRED)
Iterate through the Tier-2 scorekeepers for the reform's country. For each, run its search_hints against WebSearch and capture the magnitude and methodology (static vs dynamic, publication window).
Federal-level examples:
- US: JCT publishes per-section revenue tables (e.g.,
jct.gov/publications/2024/jcx-XX-24); CBO publishes baseline and reform scoring. - UK: OBR publishes Economic and Fiscal Outlook twice yearly with individual measure costings.
- Canada: PBO publishes cost estimates and legislative costings on request.
For state/subnational bills, find the legislative fiscal office note (US examples):
| State | Source |
|---|---|
| UT | le.utah.gov/~{year}/fiscalnotes/{bill}.pdf |
| SC | scstatehouse.gov → Bill page → "Fiscal Impact" |
| OK | oklegislature.gov → Bill page → "Fiscal Analysis" |
| NY | nyassembly.gov → "Fiscal Note" |
| CA | lao.ca.gov (Legislative Analyst's Office) |
| NC | sites.ncleg.gov/frd/fiscal-notes/ |
| CT | cga.ct.gov/ofa/ |
| KS | kslegislature.gov → Fiscal Note PDF |
| ND | ndlegis.gov → "Fiscal Notes" tab |
| IL | ilga.gov → "Fiscal Note" |
| RI | rilegislature.gov → Fiscal Note |
| WV | wvlegislature.gov → Fiscal Note |
| GA | legis.ga.gov → Fiscal Note |
For UK subnational: Scottish Fiscal Commission, Welsh Government Chief Economist's report. Add to the state/subnational fiscal-office table above as encountered.
Tier 3: Think-tank analyses (REQUIRED — minimum 2 sources searched)
Enumerate Tier-3 scorekeepers for the reform's country from the registry. Search at least 2 that are domain-relevant for the reform (e.g., a benefits reform should search CBPP + EPI over Tax Foundation + Peterson; a UK reform should default to IFS + Resolution Foundation).
Extract magnitudes with methodology notes — these are external benchmarks for the comparator. For each finding capture:
{
"source": "CRFB",
"title": "Cost of Various SALT Cap Modifications",
"url": "https://www.crfb.org/...",
"magnitude": {"ten_year_cost_billion": 400, "annual_cost_billion": 40},
"methodology": "Static, JCT-baseline aligned",
"year_published": 2024,
"reform_described": "SALT cap raised to $60K, no phase-out, vs current law"
}
If no think-tank has analyzed your exact reform, find the closest analog and document the distance (e.g., "$30K cap with phase-down" is structurally different from "$60K flat cap" — note the structural difference).
Output
{
"anchors": [
{
"tier": "pe-prior",
"title": "Restoration of the ARPA expanded CTC",
"url": "https://policyengine.org/us/research/...",
"year": 2023,
"magnitudes": {
"annual_cost_billion": 100.2,
"ten_year_cost_billion": null,
"child_poverty_pct_change": -37.0,
"overall_poverty_pct_change": -9.0,
"gini_pct_change": -1.9
},
"methodology": {
"dataset": "Enhanced CPS 2023",
"static_or_dynamic": "static",
"time_window": "single-year-2023",
"behavioral_assumptions": "no labor-supply response"
}
}
],
"official_scores": [...],
"thinktank_scores": [...],
"preferred_anchor_index": 0,
"anchor_notes": "PE 'Restoration' analysis matches reform parameters line-for-line. Single-year score must be extrapolated to 10-year window using +10-12% uprating."
}
The methodology object is structured so the reform-comparator can carry it forward into its methodology_carried_forward field and the final report's Methodology section gets a consistent shape.
If no PE prior exists, surface that explicitly — that's a signal the reform is novel for PE and the comparator should weight official/think-tank scores more heavily.
Completeness requirement
The output MUST include a tier_coverage block:
{
"tier_coverage": {
"tier_1_pe_priors": {"searched": true, "hits": 2, "note": "..."},
"tier_2_official": {"searched": true, "hits": 1, "note": "JCX-50-24 for OBBBA SALT provisions"},
"tier_3_thinktank": {"searched": true, "hits_per_source": {"crfb.org": 1, "taxfoundation.org": 1, "taxpolicycenter.org": 0}, "note": "TPC has no recent $60K-cap analysis"}
}
}
If a tier was not searched, this fails downstream — the comparator will refuse to issue PASS without external benchmark coverage.
Reform-shape specificity (REQUIRED for Stage 5.5)
Each thinktank_scores[] entry MUST include a reform_shape field specific enough that model-corroborator (Stage 5.5) can translate it into a mirror reform-dict. The minimum: explicit parameter values for every dimension the source touched (cap dollar values per filing status, phase-out thresholds, refundability flags, etc.) plus the source's stated baseline.
Bad (too vague to mirror):
"reform_shape": "raise the SALT cap"
Good (mirrorable):
"reform_shape": "$30K single / $60K joint / $30K HoH / $60K SS / $30K separate, no phase-out, no floor, baseline TCJA-extension ($10K flat)"
If a source publishes a range, capture the endpoints. If a source publishes only an overall total with no per-filing-status breakdown, infer using the standard TPC/CRFB convention (HoH groups with single, separate halves the joint cap) and note the inference in reform_shape_inference_notes.
Hand-off
Returns the anchor list. Downstream:
reform-comparator(Stage 5) uses the preferred anchor to bracket the expected microsim magnitude.model-corroborator(Stage 5.5) readsthinktank_scores[].reform_shapeto pick mirror candidates when no direct external comparator exists.calibration-diagnostics(Stage 6) readsmethodology_notesto identify whose calibration the prior was built on.
Reform Classifier
The Stage-2 gate of /analyze-policy. Takes the provisions + parameter-locator verdicts and returns one of three outcomes:
parametric— every provision maps to an existing PolicyEngine parameter. The microsim runner can execute the reform-dict as-is.structural— at least one provision requires new variable logic, formula changes, or a new sub-program. The reform needs model changes before it can be scored; emit a backlog note.not-possible— the reform falls outside PolicyEngine's modeling scope (e.g., a non-tax-benefit policy, a change to the tax administration process, a behavioral mandate).
Inputs
provisions(frompolicy-text-researcher)parameter_locator_verdicts(one per provision, fromparameter-locator)jurisdiction
Decision tree
For each provision:
- Did
parameter-locatorreturnverdict: "parametric"withconfidence: "high"? → parametric. - Did it return
verdict: "deployed-model-lag"? → deployed-model-lag for this provision (the parameter exists on master but not on the deployed API release; nothing structural to fix here — just wait for the next release). - Did it return
verdict: "no-parameter"with astructural_hint? → structural for this provision. - Does the provision describe something not in scope? → not-possible for this provision.
- Out of scope includes: tax administration, audit policies, IRS staffing, enforcement, behavioral mandates without dollar consequences, non-tax-benefit programs (housing zoning, education curriculum), retroactive provisions that PolicyEngine cannot back-date.
Aggregate across provisions (order matters — first match wins):
- If any deployed-model-lag and no structural/not-possible → reform is
deployed-model-lag(overall). Emitmissing_pathsandnext_action: "Wait for next PE-{country} release, or re-run with --skip-microsim for process-test mode."Stop the pipeline. - If any not-possible → reform is
not-possible(overall). Emit rationale; stop. - If any structural → reform is
structural(overall). Emit which provisions need model changes withmodel_change_estimate; stop the pipeline. - If all parametric → reform is
parametric. Proceed to Stage 3/4.
Output
{
"classification": "parametric",
"confidence": "high",
"provision_verdicts": [
{"label": "Federal CTC amount", "verdict": "parametric", "parameter_path": "gov.irs.credits.ctc.amount.base", "evidence_url": "..."},
{"label": "Full refundability", "verdict": "parametric", "parameter_path": "gov.irs.credits.ctc.refundable.fully_refundable", "evidence_url": "..."}
],
"reform_dict": {
"gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2035-12-31": 3000},
"gov.irs.credits.ctc.refundable.fully_refundable": {"2026-01-01.2035-12-31": true}
},
"next_stage": "find-priors"
}
For deployed-model-lag:
{
"classification": "deployed-model-lag",
"lag_provisions": [
{
"label": "Rhode Island CTC amount",
"parameter_path": "gov.states.ri.tax.income.credits.ctc.amount",
"on_master": true,
"on_deployed_api": false,
"deployed_release": "1.715.2",
"rationale": "Variable added to master 2026-06-12; deployed API doesn't yet include it."
}
],
"next_action": "Wait for next PolicyEngine-us release, or re-run with --skip-microsim for process-test mode using the anchor as predicted result."
}
For structural:
{
"classification": "structural",
"structural_provisions": [
{
"label": "Carbon dividend",
"rationale": "No `gov.us.carbon_dividend` parameter family exists. Requires new variable + formula + new agency entity.",
"model_change_estimate": "1-2 weeks: new variable file, new parameter family, integration with household_net_income"
}
],
"parametric_provisions": [...],
"next_stage": "stop-write-backlog-note",
"backlog_template": "Open issue in policyengine-us: 'Add carbon dividend support'..."
}
For not-possible:
{
"classification": "not-possible",
"rationale": "The reform changes IRS audit selection rules, which PolicyEngine does not model. PolicyEngine models tax/benefit calculations, not enforcement.",
"next_stage": "stop-write-not-modelable-note"
}
Heuristics for hard cases
- State CTC where no state CTC exists → structural (need new variable). Cite the missing directory.
- Adding a phase-out to a flat credit → check if
phase_outsub-directory exists in adjacent credits; if yes, structural-but-easy (1-day change). If no, structural-medium. - Bracket addition (e.g., add a 7th bracket to a 6-bracket schedule) → parametric — the bracket list is variable-length.
- Eligibility re-definition by age/income/disability → check if the eligibility variable is parameterized. EITC min/max age = parametric; "new disability category" usually = structural.
- Federal conformity changes (state ties to federal X) → check if state has
conformity.*parameters. If yes, parametric; if no, structural. - Removing entire programs → parametric (set rates/amounts to 0). The accounting works.
Hand-off
parametric→ invokeprior-scores-findernext.structural→ emit the backlog note, optionally open a GitHub issue against the country model, stop.not-possible→ emit the rationale to the/analyze-policyoutput, stop.
Reform Comparator
Stage 5 of /analyze-policy. Compares our microsim output to the prior-scores anchor and decides whether the result is consistent or needs investigation.
Inputs
microsim_result(frommicrosim-runner) — may be null in--skip-microsimmodeprior_anchor(fromprior-scores-finder—anchors[preferred_anchor_index])tolerance_band(default: ±25% on cost, ±5 absolute pp on poverty)
When microsim_result is null (--skip-microsim mode)
The /analyze-policy command invokes the comparator with microsim_result=null when the user passed --skip-microsim. In that case:
- Construct a synthetic predicted result from the anchor:
- Take the anchor's
magnitudesblock. - Normalize per Step 1 below (year alignment + 10-year extrapolation if needed).
- Emit the normalized values as the "predicted" result with
synthetic: trueflag.
- Take the anchor's
- Treat the synthetic result as if it were our microsim output for the comparison step.
- Return verdict — typically PASS by construction. This is intentional: process-test mode validates the pipeline plumbing, not the actual numerical agreement.
- In the output, set
verdict_caveat: "process-test — predicted result derived from anchor, not from live microsim"so the downstream report doesn't claim a real validation.
The synthetic-result schema mirrors microsim_result:
{
"synthetic": true,
"predicted_from_anchor": "Restoration of ARPA CTC",
"results": {
"budget": {"annual_cost_billion_year1": 110.2},
"poverty": {"child_pct_change": -37.0},
"distribution": {"gini_pct_change": -1.9}
}
}
Process
Step 1: Normalize the prior to our run
Most prior PE scores are reported as either single-year cost or 10-year cost, on a specific dataset version. To compare apples-to-apples:
-
Year alignment. If the anchor reports 2023 single-year cost and our run is 2026 multi-year, uprate the anchor:
- Wage growth: assume +2.5-3.5%/yr nominal (use CBO macroeconomic projections if explicit).
- Population growth: +0.5-0.7%/yr.
- Combined uprating factor: anchor_2023 × (1 + 0.035)^(2026-2023) ≈ × 1.10 for 2026 single-year.
-
Single-year vs 10-year normalization. If the anchor is a 10-year score and our run is single-year, normalize the ANCHOR to a per-year average (divide by 10). Do NOT extrapolate our single-year to 10 years by multiplication — this is banned pipeline-wide because per-year cost evolves nontrivially over any window with baseline changes (see the 2026-07-01 std-ded case: yr1×10 understated the real 10-year by 10%). If a real 10-year comparison is required, re-run the microsim with
--horizon 10. -
Dataset version note. Read
dataset,data_versionandmodel_versionfrom the microsim result and record them in the output (our_run_dataset,our_run_data_version,our_run_model_version). Preserve null fields; do NOT hardcode the dataset name. If the result'sdataset_matches_defaultis false, say so innormalization_notes. If it reads null, note that the response cannot verify the dataset identity or release. Regional API runs can report a release without identifying the file, and budget-window results report neither. When the anchor ran on another dataset, such as an earlier PolicyEngine data release, record it inanchor_datasetand flag the dataset difference as a known source of disagreement. -
CRITICAL — baseline-schedule alignment. Most published anchors are scored against a specific current-law baseline at the time of writing. If the law has changed since (e.g., SALT cap raised to $40K by OBBBA in 2025, then snapping back to $10K in 2030), comparing our 2026 run (against the OBBBA $40K baseline) to a 2023 prior (against the TCJA $10K baseline) overstates the reform's incremental impact. The 2030 snap-back also means single-year extrapolation across 2030 is biased.
Required: populate a
baseline_alignmentblock in the output:"baseline_alignment": { "anchor_baseline_law_year": 2023, "anchor_baseline_description": "TCJA $10K SALT cap", "our_baseline_law_year": 2026, "our_baseline_description": "OBBBA $40K cap (2025-2029), reverts $10K (2030+)", "alignment_caveat": "Our 2026 run measures repeal vs $40K; anchor measured repeal vs $10K. Incremental cost should be SMALLER in our run by roughly the value of the $40K-$10K headroom, ~30% of the anchor magnitude. Adjust expected band accordingly: expected_range × 0.7 not × 1.0." }If the anchor and our run use different baselines, widen the tolerance band by 50% (e.g., ±25% becomes ±37.5%) before applying the PASS/INVESTIGATE rule. Note this widening in
normalization_notes.
Step 2: Compute the comparison
For each headline metric:
| Metric | Our result | Normalized prior | Δ (abs) | Δ (pct) | Within tolerance? |
|---|---|---|---|---|---|
| 10yr cost | $1,450B | $1,100-1,300B | +$200B | +14% | YES |
| Child poverty Δ | -34.1% | -30-40% | within range | — | YES |
| Gini Δ | -2.0% | -1.9% | -0.1pp | -5% | YES |
Step 2b: External-benchmark agreement check (REQUIRED for PASS)
Before issuing a PASS verdict, evaluate agreement with at least 2 external sources from prior-scores-finder's official_scores (Tier 2) and thinktank_scores (Tier 3). External-source agreement is what distinguishes a PE-internal consistency check from an actual benchmark.
Frozen-registry rule: the benchmark set was pre-registered before the
microsim ran, and you know our result — so you may NOT search for new
sources, drop registered ones, or reinterpret a registered source's
magnitude. You compare our number against the registry exactly as frozen
(normalization for year/horizon is fine; re-reading a source to extract a
"better" figure is not). If coverage is incomplete, the remedy is the
BLOCKED path below — a blind re-run of prior-scores-finder with its
original inputs, never a search of your own.
Commensurability audit — required for EVERY source, especially the ones that agree. Two numbers that align while measuring different things are false validation; agreement earns zero evidentiary weight until the frames are shown to match. For each registered source, before computing any delta:
- Compare the source's registered framing (
baseline_frame,horizon,scope,method) against OUR run's framing, dimension by dimension. - Classify the comparison:
- commensurable — frames match, or the mismatch was explicitly normalized (e.g. we ran a separate framing-matched counterfactual, as with GA HB463's 20bp Run B built specifically to match OPB/GBPI's year-over-year frame). Document the normalization next to the delta.
- incommensurable — frames differ and cannot be normalized (a full-implementation scenario vs our single-year; dynamic vs static without a published static variant; different provision scope). Report the source WITH the reason, but it does NOT count toward the ≥2-source agreement requirement — in either direction. Excluding a divergent-but-incommensurable source and counting an aligned-but-incommensurable one are the same error.
- Interrogate agreement as hard as disagreement. When a source lands within ±25%, state WHY the agreement is meaningful: matched frame, matched year, matched scope — or what adjustment made them comparable. "OPB $778M vs our $721M (−7.3%)" is only evidence because both measure the 20bp year-over-year change for TY2026; the same numbers with mismatched frames would be coincidence. An unexplained agreement in the report is treated as an unverified claim by the interrogation stage (Phase 5.7) and by reviewers.
For each commensurable source, compute the percent difference between our run and the external estimate (using same year and same broad reform shape — note structural differences explicitly).
| Condition | Verdict effect |
|---|---|
| ≥2 external sources agree within ±25% | PASS-eligible |
| 1 external source agrees within ±25%, others diverge | PASS-WITH-NOTES — flag the divergent sources |
| All external sources diverge >±25% from ours | INVESTIGATE — exterior consensus disagrees with PE |
tier_coverage.tier_2_official.searched: false OR tier_3_thinktank.searched: false | BLOCKED — cannot issue PASS. Surface "benchmark coverage incomplete; re-run prior-scores-finder with full Tier 2 + Tier 3." |
| Tiers searched but no external sources found | PASS-eligible with explicit caveat in report: "novel reform; no external benchmarks available" |
Build a benchmark_agreement block in the output:
{
"benchmark_agreement": {
"pe_internal": {"source": "PE W&M $30K cap", "our_normalized": 18.4, "anchor_normalized": 21.0, "delta_pct": -12.4, "within_25pct": true},
"external": [
{"source": "CRFB", "url": "...", "their_estimate_billion": 22.0, "year": 2026, "framing": {"baseline_frame": "current law", "horizon": "single-year 2026", "method": "static"}, "commensurable": true, "why_agreement_is_meaningful": "same baseline, year, and static method as our run", "delta_pct": -16.4, "within_25pct": true},
{"source": "Tax Foundation", "url": "...", "their_estimate_billion": 19.5, "year": 2026, "framing": {"baseline_frame": "current law", "horizon": "single-year 2026", "method": "static"}, "commensurable": true, "why_agreement_is_meaningful": "same frame; TF table 3 static variant used, not their dynamic headline", "delta_pct": -5.6, "within_25pct": true},
{"source": "JCT JCX-50-24", "url": "...", "their_estimate_billion": null, "commensurable": false, "note": "JCT scored OBBBA SALT but not this $60K variant; structural distance noted — excluded from the agreement count"}
],
"external_sources_in_agreement": 2,
"external_sources_in_disagreement": 0,
"benchmark_verdict": "PASS-eligible"
}
}
Step 2c: Model corroboration via mirror-shape runs (Stage 5.5 trigger)
Trigger condition: any of the following. Stage 5.5 fires whenever it can add evidence, not only when Step 2b was ambiguous.
- No exact-shape external comparator exists for the original reform (the most common case) AND at least one external source has a numeric estimate for an ADJACENT-shape reform in the same parameter family (e.g., our reform is +$1K std ded, and OBBBA scored +$750/$1500; TCJA scored +$6K/+$12K).
- Step 2b returned PASS-WITH-NOTES or INVESTIGATE-due-to-external-disagreement (fewer than 2 external sources within ±25%).
- The reform is in a novel parameter family for PE (no PE prior) AND ANY adjacent externally-scored reform exists.
The stage does NOT fire only when the reform has a full external cluster within ±25% (Step 2b already CORROBORATED).
Rule of thumb — replace verbal reasoning with model runs. If the natural comparator section is written as "TCJA scored X, our reform is 1/6 the size, so linear scaling suggests Y" — that's a mirror candidate. Run TCJA's shape through our model and check we reproduce X. Linear scaling must not appear in a report, labeled or not: either run the comparator's shape through the model or leave the comparison out (see "Every reported number is a model run" in the policyengine skill).
When the trigger fires, invoke model-corroborator with the original reform context and the benchmark cluster. The corroborator picks 1-2 closest-shape candidates, builds mirror reform-dicts (and a baseline policy if the source uses a different baseline schedule like TCJA-extension vs OBBBA-current-law), submits them to the PE API, polls for completion, and computes per-candidate corroboration:
| Mirror-shape candidates corroborated within ±25% | Corroboration verdict |
|---|---|
| ≥2 | CORROBORATED — upgrade comparator verdict by one tier (INVESTIGATE→PASS-WITH-NOTES, PASS-WITH-NOTES→PASS-WITH-CORROBORATION) |
| 1 with explainable drift on others | PARTIAL-CORROBORATION — keep verdict at PASS-WITH-NOTES, document corroboration evidence |
| 0 corroborated (all mirror runs drift) | CORROBORATION-FAILED — force escalation to INVESTIGATE regardless of Step 2b result; calibration likely off in the parameter family |
Stage skipped (no suitable candidates, or --skip-microsim) | NO-CORROBORATION-POSSIBLE — fall back to Step 2b verdict, document the gap |
This is the independent-evidence layer: if the model can reproduce CRFB's $30K/$60K within ±25% AND TF's $62K/$124K-with-phase-out within ±25%, we have evidence the parameter family is correctly calibrated. The original reform's headline is then anchored on real model validation rather than on tolerance-band gymnastics.
Each mirror run costs ~6-10 min wall-clock. Default to 1-2 mirrors; escalate to 3 only when the original reform spans a wide cap range AND 3 well-shaped candidates exist.
Output:
{
"corroboration": {
"stage_fired": true,
"candidates_run": [
{"source": "CRFB Jan 2025", "shape": "$30K/$60K vs TCJA extension", "their_yr1_billion": 82.0, "our_yr1_billion": 78.4, "delta_pct": -4.4, "verdict": "CORROBORATED"},
{"source": "TF May 2025", "shape": "$62K/$124K w/ $500K phase-out vs current law", "their_yr1_billion": 52.6, "our_yr1_billion": 53.1, "delta_pct": 1.0, "verdict": "CORROBORATED"}
],
"candidates_corroborated": 2,
"overall_verdict": "CORROBORATED",
"verdict_upgrade_applied": true,
"implications": "SALT cap parameter family validated for $30K-$124K cap range; $140K extrapolation is anchored on model agreement."
}
}
Step 3: Verdict
The verdict combines the per-metric tolerance check (Step 2), external-benchmark agreement (Step 2b), and (if triggered) model corroboration (Step 2c). All three must align:
PASS— all headline metrics within 0–80% of the tolerance band AND Step 2b returns PASS-eligible. Proceed to write-up.PASS-WITH-CORROBORATION— would have been PASS-WITH-NOTES at Step 2b, but Stage 5.5 corroboration returnedCORROBORATED. The parameter family has been independently validated. Document the corroboration evidence in the write-up.PASS-WITH-NOTES— at least one metric within 80–100% of the band, OR Step 2b returned PASS-WITH-NOTES and Stage 5.5 returnedPARTIAL-CORROBORATION/NO-CORROBORATION-POSSIBLE. Proceed, list the close-call metrics in the write-up.INVESTIGATE— at least one headline metric outside the tolerance band, OR external benchmarks consensus-disagree with our run, OR Stage 5.5 returnedCORROBORATION-FAILED. Triggercalibration-diagnosticswith the deviation signature.BLOCKED— benchmark coverage is incomplete (Tier 2 or Tier 3 not searched). Pipeline must re-runprior-scores-finderwith full tier coverage before any PASS-family verdict can issue.
Example: tolerance is ±25% on cost. Δ within 0–20% → PASS. Δ within 20–25% → PASS-WITH-NOTES. Δ > 25% → INVESTIGATE.
Step 4: Build the deviation signature (for INVESTIGATE)
When triggering calibration-diagnostics, pass a precise signature so the diagnostics agent can hypothesize:
{
"verdict": "INVESTIGATE",
"deviation_signature": {
"primary_metric": "child_poverty_pct_change",
"our_value": -17.0,
"anchor_value": -34.0,
"magnitude_off_by": "half",
"direction": "under-states-impact",
"related_metrics_okay": ["10yr_cost"],
"related_metrics_also_off": [
{"metric": "gini_pct_change", "our_value": -0.9, "anchor_value": -1.9, "direction": "under-states-impact"}
],
"parallel_deviation_note": "child_poverty and gini both off by ~half in the same direction while cost is roughly correct — suggests a SINGLE upstream driver (one calibration target affecting both distributional and poverty outputs simultaneously) rather than two independent problems."
},
"hypothesis_seeds": [
"Cost is roughly correct but poverty + Gini both understate by half — dollars flow to the right households but their per-unit benefit is too small to lift them across thresholds. Likely: refundability switch not firing for non-filers, OR SPM unit aggregation is splitting beneficiaries across units."
]
}
Parallel deviations are diagnostic. When two related metrics are off in the same direction by similar magnitudes, that's strong evidence of one upstream cause. Always populate related_metrics_also_off for any metric outside tolerance, and write the parallel_deviation_note if a pattern emerges. The diagnostics agent uses this to rank hypotheses (single-driver hypotheses score higher when the pattern is parallel).
Output
{
"verdict": "PASS" | "PASS-WITH-NOTES" | "PASS-WITH-CORROBORATION" | "INVESTIGATE" | "BLOCKED",
"comparison_table": [...],
"normalization_notes": "Anchor 2023 single-year cost uprated 1.10x for 2026. Anchor 10-year normalized to per-year average for comparison (yr1×N extrapolation is banned).",
"deviation_signature": null | {...},
"methodology_carried_forward": {
"anchor_dataset": "Enhanced CPS 2023",
"anchor_static_or_dynamic": "static",
"anchor_url": "https://policyengine.org/us/research/...",
"our_run_dataset": "populace_us_2024", // read from result.dataset
"our_run_data_version": "populace-us-2024-spm-20260915",
"our_run_model_version": "2.2.1", // read from result.model_version
"our_run_mode": "api"
},
"next_stage": "write-report" | "diagnose-calibration"
}
The methodology_carried_forward block threads through to the reform-describer and the final report's Methodology section. Always populate it — if a value is unknown (e.g., process-test mode), set it to "unknown" rather than omitting.
Tolerance defaults
- Budgetary cost: ±25% of normalized prior.
- Poverty Δ (overall, child, deep, adult, senior): ±5 absolute percentage points OR ±20% relative, whichever is wider.
- For age-targeted reforms (EITC age-cap changes, child-credit reforms), require BOTH the targeted bucket (child or senior) and the overall metric to land within tolerance. If the targeted bucket lands but overall doesn't, that's a
PASS-WITH-NOTESflagging "primary effect is in subgroup X — overall metric is dominated by the larger non-targeted population".
- For age-targeted reforms (EITC age-cap changes, child-credit reforms), require BOTH the targeted bucket (child or senior) and the overall metric to land within tolerance. If the targeted bucket lands but overall doesn't, that's a
- Gini Δ: ±0.5 absolute pp.
- Top-decile share of benefit: ±10 absolute pp.
Auto-widening triggers (REQUIRED — do not leave for analyst to set manually)
Apply the wider band automatically when any of these conditions hold. Each widening multiplies the default tolerance by the factor shown; apply the largest factor that triggers.
| Trigger | Detection | Band multiplier |
|---|---|---|
| Small state (CPS sample <10k person-records) | jurisdiction.state ∈ {RI, VT, WY, AK, ND, SD, DE, MT, NH, ME, HI, ID, NM, NE, WV, UT} | ×1.5 |
| Narrow-population reform | see threshold guidance below — eligibility-narrow not just "less than 5% of households" | ×1.3 |
| Baseline-schedule mismatch | baseline_alignment.alignment_caveat is non-empty OR the only available anchor is from a different jurisdiction (structural analog) | ×1.5 |
| Single-year run compared against multi-year anchor | analyst chose horizon=1 but the anchor is a 10-year score; normalization to per-year average carries variance from baseline growth | ×1.4 |
| Anchor is from a different dataset version | methodology.dataset differs from our run's dataset by a major version | ×1.2 |
| Stage-6 SKILL coverage thin for this program | policyengine-calibration-diagnostics SKILL row has <3 sensitivities | ×1.3 |
| Reform is structurally self-offsetting | some recipients gain and others lose under the same reform (e.g., raising a match rate that cuts off bracket below the prior rate) | ×1.3 |
Narrow-population threshold guidance (the VT live test showed the original <5%-of-households rule was both too tight and too loose):
- Triggers narrow-population widening: reform affects a population that is BOTH (a) <10% of households AND (b) further narrowed by a non-population eligibility cut. Examples:
- SALT cap repeal: itemizers ~10% × top-decile concentration → triggers (effective population is a small subset).
- ARPA childless EITC: childless tax filers × age 19-24 or 65+ → triggers (newly-eligible cohort is small).
- Top-1%-only reforms: triggers.
- Does NOT trigger: broad-eligibility reforms even if total cost is small. Examples:
- State EITC match-rate change: ~15% of state filers — too broad.
- General CTC reform: ~30% of households — too broad.
- SNAP eligibility change: ~13% of households — too broad.
The test is "is the marginal-effect population small AND narrowly defined?", not "is the total cost small?".
Structural self-offset. If the reform raises benefits for one sub-population and cuts them for another (e.g., the VT EITC test where 38% match rate became 50% — childless adults previously received 100% of federal EITC under the enhanced structure but only 50% under the reform), the headline cost reflects NET impact. Surface this in normalization_notes so the comparator's verdict isn't a surprise when the cost lands lower than naive scaling suggests.
If two triggers fire (e.g., small state + thin SKILL coverage), multiply both factors (RI CTC would be ×1.5 × ×1.3 = ×1.95 — effectively doubling the band).
Always log the applied widening in normalization_notes so the report reader knows which guardrails were relaxed.
Hand-off
PASS→/analyze-policywrites the report and exits.INVESTIGATE→ invokecalibration-diagnosticswith the deviation signature.
Reform Describer
Takes a reform JSON (parameter paths + values, ideally with the original provisions from policy-text-researcher) and produces:
- A 1-2 sentence
description(conceptual; no specific values) - A
provisionsarray grouped by program (specific values, all mechanical)
CRITICAL: Writing style rules
All text must follow these rules. Load the policyengine-writing skill for the full standard.
- Facts only: state what changes mechanically — specific values, thresholds, rates.
- No adjectives or judgments: never use words like "significant", "modest", "key", "important", "major", "small".
- No predictions: never say who benefits, how much they benefit, or what the effect will be. (Distributional results live in the impact report, not the description.)
- No editorial language: never use "providing", "benefiting", "relief", "burden", "equitably", "improving", "support", "helping", "boosting".
- No characterizations of affected populations: never say "low-income families" or "working families". Reference mechanical eligibility criteria (e.g., "filers within the federal EITC eligibility range").
- No comparative statements: never say "one of the smallest", "among the largest".
- No author attribution: never include "Authored by..." or sponsor information in the description body.
- Verbose about mechanics: list every specific parameter change with exact values.
Self-check: before returning, re-read every explanation field. If any sentence could appear in a press release or advocacy document, rewrite it.
Inputs
policy_id,jurisdiction,title(frompolicy-text-researcher)reform_dict(PolicyEngine parameter changes fromparameter-locator)provisions(mechanical change list frompolicy-text-researcher)
Output format
description (1-2 sentences, conceptual)
Examples:
Rhode Island H 7127 establishes a refundable state Child Tax Credit beginning in tax year 2027.
Oklahoma HB2229 increases the state EITC match rate.
The American Family Act sets federal CTC amounts by age, makes the credit fully refundable, and adds a baby bonus.
Do not list parameter values here — provisions handle the specifics.
provisions (structured array)
Group parameters by program. Single-parameter provisions:
{
"label": "State CTC amount",
"program": "state-ctc",
"baseline": "$0 (current law); $330 effective 2027 per enacted H 7127",
"reform": "$250 per qualifying child",
"explanation": "Sets gov.states.ri.tax.income.credits.ctc.amount to $250 for tax year 2027 onward."
}
Multi-parameter provisions:
{
"label": "Federal CTC expansion",
"program": "federal-ctc",
"changes": [
{"parameter": "gov.irs.credits.ctc.amount.base", "baseline": "$2,000", "reform": "$3,000 ages 6-17, $3,600 ages 0-5"},
{"parameter": "gov.irs.credits.ctc.refundable.fully_refundable", "baseline": "false (2022+)", "reform": "true"}
],
"explanation": "Restores ARPA CTC structure: age-bifurcated amounts and full refundability."
}
Every parameter in the reform dict must appear in at least one provision.
Hand-off
Returns the {description, provisions} object. Downstream:
microsim-runnerdoes not need this — but the output is what eventually ships to the user/PR/blog/dashboard.- Used by
/analyze-policyto format the final report.
Report Logger
Final stage of /analyze-policy. Takes the assembled report and the verdict, and writes it to the right destinations.
Inputs
report_path: path to the final markdown report (e.g.,/tmp/analyze-policy-{policy_id}.md)frontmatter: structured metadata (policy_id, verdict, jurisdiction, anchor_url, tags, etc.)log_to: list of destinations (auto-derived from verdict unless overridden by--log-to)no_log: if true, skip logging entirelycommand_args: the original$ARGUMENTSpassed to /analyze-policy (for traceability)
Routing — runtime prompt, context-aware
Destinations depend on where the analyst is working and what the analysis is for. Rather than a single auto-route, the agent computes a context-aware destination shortlist and prompts the analyst to pick.
If --log-to was explicitly passed: skip the prompt, use the list verbatim.
If --no-log: skip the prompt, write nothing.
If --auto-confirm AND no --log-to: use the verdict default (local archive only) without prompting.
Otherwise:
Step 1: Detect context
Run these checks in parallel (cheap):
# Current repo
git rev-parse --show-toplevel 2>/dev/null # e.g., /Users/pavel/policyengine-app-v2
gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null # e.g., PolicyEngine/policyengine-app-v2
# Is there a posts/articles directory? (policyengine-app-v2 shape)
test -d app/src/data/posts/articles && echo "HAS_POSTS_DIR"
# Is there an analyses/ directory? (this repo's shape)
test -d analyses && echo "HAS_ANALYSES_DIR"
# Country from --country flag or from the reform's jurisdiction
echo "$COUNTRY"
Step 2: Build the shortlist
Always include:
- Local archive (path per resolution order in next section)
- Skip / no-log (cancel destination)
Append context-specific options:
| Context | Add this option |
|---|---|
Inside policyengine-app-v2 repo (HAS_POSTS_DIR) | "Save as draft research post: app/src/data/posts/articles/{slug}.md + update app/src/data/posts/posts.json" |
Inside policyengine-skills or any repo with analyses/ | "Just the archive in analyses/" (already covered by local archive but make the path explicit) |
Verdict is INVESTIGATE | "Open GitHub issue in the repo that builds the run's dataset (us and uk → PolicyEngine/microcosm; a uk run that named an Enhanced FRS or FRS file → PolicyEngine/policyengine-uk-data) — calibration hypothesis". None for ca, which has no microdata |
Verdict is structural | "Open GitHub issue in the country's model repo (PolicyEngine/policyengine-us, -uk or -canada) — model-change estimate" |
Verdict is PASS* AND inside policyengine-app-v2 | "Open draft PR to policyengine-app-v2 with the post body" |
| Always | "Custom path / repo — type a destination spec" |
The agent takes the country from the reform's jurisdiction (us, uk, ca) and, for uk, the dataset from the frontmatter's dataset_at_run, so the shortlist names each issue repo in full.
Step 3: Prompt the analyst
Show the shortlist and ask the analyst to pick one or multiple destinations. Default highlighted: local archive only.
Example prompt rendering:
Where should this analysis go?
[x] Local archive: /Users/pavel/policyengine-skills/analyses/2026-06-29-us-salt-cap-plus-100k.md (default)
[ ] GitHub issue: PolicyEngine/microcosm (verdict: INVESTIGATE → calibration; us data repo)
[ ] Draft PR: PolicyEngine/policyengine-app-v2 → app/src/data/posts/articles/salt-cap-plus-100k.md
[ ] Custom: --log-to <spec>
[ ] Skip / no-log
Select destinations (comma-separated, default = 1):
Capture the response and proceed to per-destination handling below.
Step 4: Preview before any non-local destination
For any destination other than local archive, show the full body that will be submitted (issue body, PR description, post markdown) and ask Y/N/edit. This is the safety rail against malformed issues or premature PRs. Bypass only with --auto-confirm.
If the analyst picks edit, drop them into $EDITOR (or open the file path if --no-tty) on a temp copy. Re-show the preview after edits.
Verdict defaults (what gets pre-selected in the prompt)
| Verdict | Pre-selected |
|---|---|
PASS / PASS-WITH-NOTES / PASS-WITH-CORROBORATION | local archive only |
INVESTIGATE | local archive + GH issue in the repo that builds the run's dataset (us and uk → microcosm); local archive only for ca |
structural | local archive + GH issue in the country's model repo (policyengine-us, -uk or -canada) |
not-possible / deployed-model-lag | local archive only |
The analyst can override the pre-selection in the prompt.
Destination: archive
Path resolution (CRITICAL for plugin installations)
Determine the archive directory in this order. The first match wins:
- Explicit override —
--log-to archive:<path>was passed. - Project-local —
$PWD/analyses/exists. Use it. - Environment variable —
POLICYENGINE_ANALYSES_DIRis set. Use it. Create if it doesn't exist. - Default home —
~/.policyengine/analyses/. Auto-create the directory if absent.
Always print the resolved path to the agent's output so the user can find the file:
[report-logger] Archived to: ~/.policyengine/analyses/2026-06-19-us-arpa-ctc-restoration.md
File naming
{YYYY-MM-DD}-{jurisdiction_slug}-{reform_slug}.md
jurisdiction_slug:us,uk,ca, orus-ri,us-vt, etc. for state.reform_slug: kebab-case 4-8 word distillation of the reform title.
File contents
-
YAML frontmatter. The full canonical schema is documented in
analyses/README.md— treat that as the source of truth. Minimum required fields:--- policy_id: 97759 date: 2026-06-19 jurisdiction: country: us state: null title: ARPA-style federal CTC expansion (2026-2035) verdict: PASS-WITH-NOTES # includes PASS-WITH-CORROBORATION, BLOCKED, structural, etc. tags: [ctc, federal, refundability, arpa] # Publication inputs (consumed by the CRM publication router; see analyses/README.md) # description = reform-describer's 1-paragraph neutral provisions summary. # reform_dict = the EXACT validated JSON from the Phase 2 classifier, minified # on a single line inside a block scalar. Omit for structural / not-possible runs. description: "Expands the federal CTC to $3,000 ($3,600 under age 6) with full refundability, effective 2026." reform_dict: | {"gov.irs.credits.ctc.amount.base[0].amount": {"2026-01-01.2026-12-31": 3000}} # Run metadata (thread these from microsim-runner output, do NOT hardcode) run_id: 97759 model_version_at_run: 1.745.0 data_version_at_run: populace-us-2024-cd-concept-budget-... dataset_at_run: populace_us_2024 # routes uk data issues (see "Repo selection") command_args: "ARPA-style federal CTC expansion: $3,000 ages 6-17, $3,600 ages 0-5, fully refundable" # Horizon (from Phase 0 prompt) horizon: 1 # or 10, or custom list horizon_note: "..." # Headline our_cost_billion_year1: 86.6 our_cost_billion_10yr_actual_federal: null # only populated when horizon > 1 our_child_poverty_pct_change_relative: -34.8 # Anchors + benchmarks anchor_url: https://policyengine.org/us/research/... anchor_normalized_cost_billion: 110.0 benchmark_sources: [...] external_sources_in_agreement: 2 external_sources_in_disagreement: 0 benchmark_verdict: PASS-WITH-NOTES # Stage 5.5 (only when corroborator ran) stage_5_5_corroboration: ran: true overall_verdict: CORROBORATED candidates: [...] # Auto-widening auto_widening_applied: 2.73 auto_widening_triggers: [...] # Destinations (populated after issues fire) issues_opened: [] ---model_version_at_run,data_version_at_runanddataset_at_runmust come from the microsim result'smodel_version,data_versionanddatasetfields. Never hardcode a dataset name or a country-model version in the frontmatter. -
The full report body verbatim (markdown).
-
A trailing
## Relatedsection with cross-links to related archived analyses (greppable from existing archive entries via tags).
Destination: GitHub issue
Repo selection (auto-routed)
INVESTIGATE→ the repo that builds the dataset the run used:us→PolicyEngine/microcosm. Microcosm builds the US datasets, and PolicyEngine archivedpolicyengine-us-data.uk→PolicyEngine/microcosm, unless the run named an Enhanced FRS or FRS file. policyengine.py loads Microcosm'smicrocosm_uk_2024_25whenever the caller names no dataset, and the API runs only that default. A local run can name an Enhanced FRS or FRS file instead (enhanced_frs_2024_25,frs_2024_25, and their 2023-24 and_tinysiblings), which policyengine.py serves as dataset overlays and policyengine-uk-data builds. That run's data issue goes toPolicyEngine/policyengine-uk-data.ca→ no issue, and the archive keeps the report. PolicyEngine has no Canada microdata: policyengine.py's bundle holds data forusandukonly, and microsim-runner supports only household calculations forca.--log-to issue:<repo>still files one in the repo the analyst names.
structural→ the live model repo:us→PolicyEngine/policyengine-us,uk→PolicyEngine/policyengine-uk,ca→PolicyEngine/policyengine-canada.- Explicit
issue:<repo>→ use that repo
CRITICAL: never hardcode a single data repo. Route by the reform's country and, for uk, by dataset_at_run. Microcosm builds both the US and the UK datasets, so the issue body names the dataset and its data version.
Issue body shape per verdict
INVESTIGATE issue body:
## Calibration deviation flagged by `/analyze-policy`
**Reform:** {reform_title}
**Policy ID:** {policy_id}
**Dataset:** `{dataset_at_run}` ({data_version_at_run})
**Deviation:** {deviation_signature_summary}
### Top hypothesis
{ranked_hypothesis_1.calibration_input}
- **File:** `{file_citation}`
- **Current value:** `{current_value_quoted}`
- **Expected direction:** {expected_direction_of_effect}
- **Expertise required:** {expertise_required}
### Test to run
> {test_to_run}
If this hypothesis explains the gap, the next step is {next_action}.
### Other hypotheses considered
{rank-2 and rank-3 hypotheses, abbreviated}
### Full analysis
See archived report: `{archive_relative_path}` (or attach if logger has the file).
---
*Filed by `report-logger` agent. Verdict: INVESTIGATE. Coverage note: {coverage_note}.*
structural issue body:
## Model extension needed (flagged by `/analyze-policy`)
**Reform:** {reform_title}
**Reason for non-parametric classification:** {structural_provisions[].rationale}
### What needs to be added
{model_change_estimate}
### Provisions that don't fit current model
{list each structural_provision}
### Provisions that DO fit (could be partial implementation)
{list each parametric_provision}
### Full analysis
See archived report.
Open issue mechanics
# Fill in the report's values. Quoted heredocs below hand the title and body
# to gh as written, with their quotes, apostrophes, $ and backticks intact.
# country: us, uk or ca, the jurisdiction of the reform.
# dataset: dataset_at_run from the archive, or empty for the bundle default.
country='{country}'
dataset='{dataset_at_run}'
# Send the data issue to the repo that builds the dataset the run used.
# `dataset` holds a dataset name or an hf:// URI. An empty value routes as the
# bundle default. Cut at the last @ and keep the last path segment, as
# policyengine.py's dataset_logical_name does before it drops the file
# extension, which a prefix match never reads.
dataset_name=${dataset%@*}
dataset_name=${dataset_name##*/}
case "$country" in
us) DATA_REPO="PolicyEngine/microcosm" ;;
uk)
# policyengine-uk-data builds the Enhanced FRS and FRS files; Microcosm
# builds the UK default and the bundle's other UK datasets.
case "$dataset_name" in
enhanced_frs_*|frs_*) DATA_REPO="PolicyEngine/policyengine-uk-data" ;;
*) DATA_REPO="PolicyEngine/microcosm" ;;
esac
;;
# policyengine.py's bundle holds data for us and uk only, so no repo takes
# a data issue for ca; the archive keeps the report.
ca) DATA_REPO="" ;;
*)
printf "report-logger: no data repo for country '%s'; expected us, uk or ca\n" "$country" >&2
DATA_REPO=""
;;
esac
if [ -n "$DATA_REPO" ]; then
# gh stops before it creates the issue when any --label names a label the
# repo lacks, so pass only the labels the repo has. gh ignores case when it
# matches label names, so compare them in lower case. --limit 1000: gh
# lists only 30 labels by default. `command grep` skips any shell function
# named grep, such as the one Claude Code defines.
REPO_LABELS="$(gh label list --repo "$DATA_REPO" --limit 1000 --json name --jq '.[].name | ascii_downcase')"
LABEL_ARGS=()
for label in calibration auto-filed; do
if printf '%s\n' "$REPO_LABELS" | command grep -qxF -- "$label"; then
LABEL_ARGS+=(--label "$label")
fi
done
IFS= read -r ISSUE_TITLE <<'END_TITLE'
{title}
END_TITLE
gh issue create --repo "$DATA_REPO" --title "$ISSUE_TITLE" "${LABEL_ARGS[@]}" --body-file - <<'END_BODY'
{body}
END_BODY
fi
gh looks up every --label name among the repo's labels before it creates the issue, and it stops without creating one when a name is missing (could not add label: '<name>' not found). So the block passes only the labels the repo has, and gh files the issue even in a repo with neither label. If gh label list fails, the block still files the issue, without the labels it could not confirm. File a structural issue, or one for an issue:<repo> destination, with this block from if [ -n "$DATA_REPO" ] on, after setting DATA_REPO to that repo in place of the case. Its heredocs hand gh the title and body intact, and its label check passes only labels the repo has. For a structural issue, change the loop to for label in auto-filed; do: policyengine-us and policyengine-uk use calibration for calibration work, and a structural issue asks for a change to the model.
Replace {country}, {dataset_at_run}, {title} and {body} with the report's values, and keep the quotes and heredoc lines around them. If the country or dataset holds an apostrophe, write it as '\''. gh reads the body from standard input (--body-file -), so the shell never parses the body's quotes or parentheses. A heredoc ends at the first line that matches its closing word, though, and the shell runs every line after it. So check that the title isn't END_TITLE and that no line of the body is END_BODY; if one is, rename that heredoc's closing word in both places. Run the block as written, without set -e or set -u: either can stop the block before gh files the issue.
Capture the returned issue URL/number and append to the archive's issues_opened frontmatter list. When DATA_REPO is empty (ca), the block files nothing: list the issue under skipped in the output, with the reason. For any other country the block prints an error and files nothing: report it under errors.
Confirm before opening unless --auto-confirm is set — the user may want to edit the body or pick a different repo.
Destination: tracker (Supabase)
Only available when running inside the state-legislative-tracker repo (Supabase credentials in .env). Routes through that repo's local db-writer agent. Out of scope for analyses that aren't state bills.
Skip this destination unless --log-to tracker is explicitly passed AND credentials are present.
Destination: draft (research PR)
--log-to draft:<repo>/<path> opens a PR with the report rendered as a blog-post-style markdown file. The canonical PolicyEngine research-post target is policyengine-app-v2 at app/src/data/posts/articles/{slug}.md (also register the slug in app/src/data/posts/posts.json). Note: policyengine-app v1 is archived — never target it.
- Use the
policyengine-writingskill for tone polish (the raw report uses neutral-mechanical language; a draft needs lead, sub-heads, and a TL;DR). gh pr create --draftagainst the target repo, branch namedanalyze-policy/{policy_id}.- Surface the PR URL in the output.
Output
{
"destinations_written": [
{"type": "archive", "path": "~/.policyengine/analyses/2026-06-19-us-arpa-ctc-restoration.md"},
{"type": "issue", "repo": "PolicyEngine/microcosm", "url": "https://github.com/.../issues/4823"}
],
"skipped": [],
"errors": []
}
If any destination fails (e.g., gh not authenticated), continue with the others and surface the error in errors. Always make sure the archive write succeeds — that's the durable record.
Hand-off
This is the last agent in /analyze-policy. Its output is the final user-facing "here's where everything went" summary, printed after the report itself.
Sponsors
Details
- Marketplace
- PolicyEngine/policyengine-claude
- Author
- PolicyEngine
- Category
- Analysis
- License
- MIT
- Keywords
- analysis, research, policy, impact, dashboards, plotly, notebooks