| name | esg-profile |
| description | Produce a sponsor-level ESG Profile (with fund-level rollup) for quarterly asset-management engagement — collates ESG questionnaire scorecards, energy/EPC, CRREM stranding, physical climate risk, regulatory exposure, peer benchmarks, materiality, and governance rights into asset-manager-style Sponsor Profile + Fund Overview deliverables. Data sources are swappable (live MCP or static extract) via a connector registry. Triggers on: "ESG profile", "sponsor ESG profile", "ESG asset management dashboard", "ESG scorecard profile", "quarterly ESG engagement", "fund ESG overview".
|
| version | 1.0.0 |
ESG Profile
You are producing a quarterly ESG monitoring deliverable — a Sponsor ESG Profile (and,
when the run is fund-scoped, a Fund ESG Overview rollup over its sponsors). This is NOT a
decarbonization engagement (decarb-plan) and NOT an acquisition screen (rsra) — it is the
asset-management / Q4 budget-season product: it takes data a firm already collected
post-GRESB and turns it into a decision-useful, benchmarked, gated report. You orchestrate
existing capabilities only — a connector registry that resolves nine input sources plus CRREM,
the Verifier plugin (verifier__*, findings ledger + fail-closed render gate), and the
esg-profile report template via fill_report.
Positioning vs. siblings: rsra = pre-acquisition screening from an OM. decarb-plan =
full multi-week retrofit engagement with two human gates. esg-profile = recurring quarterly
monitoring + reporting, deterministic and demo-safe (no interactive human gate mid-run). Do not
route a full decarbonization engagement here, and do not route a quarterly ESG scorecard request
to decarb-plan.
Ground rules — apply in every phase
-
No LLM arithmetic. Every reported number — pillar scores, the scorecard total, CRREM
stranding year, fine exposure, fund-level averages and rankings — comes from an engine, a
connector-resolved source value, or a cited extract. CRREM stranding comes from the crrem
MCP get_pathway call, never a hand-typed or eyeballed figure. BPS/regulatory fine exposure
comes from run_compliance_analysis or the cached compliance connector, never LLM estimation.
Fund-level rollup stats (averages, rankings, underperformer selection) are computed by engine
or deterministic aggregation code, never composed by you in prose. If a required engine or
tool is unreachable, say so and stop that field — never approximate a number to fill a slot.
-
Provenance on every value. Every datum in state.collected and every field that reaches
the rendered report carries a ProvenancedValue — {value, provenance: {source_id, mode, origin, period, retrieved_at}, status, notes}. mode is one of live | static | estimate and
must propagate honestly into the artifact: a static extract value is labeled as such, never
presented as if it were a live pull. This is the concrete form of the standing
never-fail-silently rule — a missing or degraded source is surfaced, never silently dropped
or backfilled with an invented number.
-
Anonymization is mandatory — and NOT pre-done in the source files. config.anonymize
defaults true and is enforced at every phase boundary: the real sponsor name, contacts, and
identifying locations never surface in state, the rendered artifact, or any export. Do not
trust the source extract's own "anonymized" label. The asset manager's own scrubbed-labeled
files can still leak the real sponsor identity — the source extract/notes may still contain
the real sponsor name, personnel names, contact emails, consultant names, city names, and
country. Scrub ALL such identifiers before any value from the extract enters
state.collected or the report; the demo denylist of previously-observed leak tokens lives in
the untracked skills/esg-profile/demo/.scrub-denylist.json (gitignored — it is regenerated
locally, never committed, because it necessarily contains real identifiers). Substitute the
run's pseudonym (config.sponsor, e.g. "Sponsor Sierra") for any real entity name and a
generic region label ("Southern Europe", not the named city) for any place name. Treat a
fresh, never-before-seen extract the same way — assume it is NOT pre-scrubbed until you have
personally verified it, because the anonymize flag governs OUR obligation, not a property of
the input file.
State ledger
Durable state lives at projects/<fund>/<sponsor>/esg-profile.json, conforming to
skills/esg-profile/state-schema.json — phase is one of kickoff | collect | reconcile | verify | render | export | done. Every phase below is idempotent against the ledger: on resume,
validate the file against the schema, resume at the recorded phase, and never redo a completed
phase. If the file is missing, this is a new run — start at kickoff.
Phase: kickoff
- Gather the run scope from the user (or from an existing config/comment if this is a repeat
run for the same fund):
scope (sponsor or fund), fund, sponsor (required when
scope: sponsor; omit — or supply the full sponsor list — when scope: fund),
reporting_year, and anonymize (default true; only set false with explicit, informed
user confirmation that this specific run is for internal, non-anonymized use). A scope: fund
run may be kicked off at a portfolio thread (no asset in context) rather than an asset
thread — that changes nothing about this scope-gathering step, but see Fund rollup below for
how the file-kind connector bindings differ when there is no asset to attach files to.
- Write the initial state file per
state-schema.json: phase: "kickoff", and the config
block (scope, fund, sponsor, reporting_year, anonymize, connectors).
- Load connector bindings from the registry, then apply overrides. Read
skills/esg-profile/connectors/registry.json — it lists every known source_id
(energy, green_street, physical_risk, bps, questionnaire, peer_benchmark,
materiality, investment_info, governance, crrem, fund_peers), the schema of what value must
contain (produces), the default_live_adapter, and whether it is a gap_filler (a source
the asset manager's own process currently leaves blank: green_street, physical_risk, crrem). For each
source_id, default config.connectors[source_id] to that entry's default_live_adapter
shape, then apply any run-specific override the user or a prior kickoff config supplied (e.g.
pointing at a static EU extract instead of the ESPM default because the demo sponsor
is Spain-based and ESPM is US-only). Persist the resolved binding set into .
is only resolved when — skip it entirely on a sponsor-scoped
run.
Phase: collect
This is the visible tool-streaming phase — for every source_id in state.config.connectors,
resolve it and record the result. Do not resolve sources silently in one giant batch and then
report a summary; stream each resolution as it completes (this is also the demo choreography —
see below).
For each source_id, resolve the binding by kind:
kind: mcp → call the named tool (e.g. physrisk get_hazard_exposure, crrem get_pathway, citizen-energy get_benchmarking) with the sponsor's actual attributes
(location, asset class, property type, region). Set provenance.mode: "live",
provenance.origin = the tool name.
kind: file → read the bound path (and sheet where the source is a spreadsheet tab —
most of the asset manager's static fields live on sheet 30_input_qualitative). Extract the fields the
registry's produces contract names for that source_id. Set provenance.mode: "static",
provenance.origin = the file path (+ sheet).
kind: manual → use the literal value from config. Set provenance.mode: "estimate"
unless the config explicitly marks it as a confirmed manual entry, in which case still record
it as static with origin: "manual".
Record every result into state.collected[source_id] as {value, status, notes, provenance}.
A source that errors or returns nothing is status: "missing" or status: "error" — never
silently omitted from state, and never backfilled with a guessed value.
Call the three gap-filler connectors explicitly and visibly — crrem, physical_risk, and
green_street are exactly the fields the asset manager's own process today ships as "not provided" or
"analysis underway." Resolving them live is the whole point of this skill's value proposition,
so surface each one as its own streamed line (e.g. "→ crrem get_pathway... stranding year 2034"),
not folded silently into a batch.
The connector swap rule: to go live, change the binding in registry.json (or the run's
config.connectors override); never edit this skill. If green_street moves from static to a
live API tomorrow, the fix is flipping its adapter — {"kind":"mcp","tool":"green-street get_metrics"} — in the registry or config, not touching a line of this workflow. The workflow
only ever calls resolve(source_id); it does not know or care whether the value came from an API
or a file, and it must stay that way.
Set phase: "reconcile" and save.
Phase: reconcile
-
Normalize units. Energy intensity to kWh/m²; absolute energy scaled to magnitude;
carbon to a single normalized unit (kgCO₂e/m² or tCO₂e). The source extract mixes kBTU/sq ft
and lb-vs-kg CO₂ across reporting years — this normalization is not optional cleanup, it is a
required reconciliation step every time, because a chart or table that silently plots
mismatched units across years is wrong on its face.
-
Apply source-precedence reconciliation to every conflicting input. Where two sources
disagree on a field (the extract's own notes_conflicts / DISCREPANCY entries, or a static
value that disagrees with a live pull), apply the precedence hierarchy: official
scorecard/measured value > authoritative slide (e.g. an LPAC deck) > extract > estimate.
This hierarchy produces a suggested resolution only — every conflict becomes a
verifier__record_finding (kind data-quality, verdict conflict, with the candidate
values, their sources, and a suggested_resolution field naming which hierarchy rule fired).
Never auto-resolve a conflict by silently picking the higher-precedence value and moving
on — the finding is the durable record, and it surfaces in the report's data-quality
section even though (per the batch-adapted, non-interactive discipline below) this run does
not stop to human-adjudicate it mid-run.
-
Run regression detection across scorecard.trend and initiatives. Regression is a
first-class output, not an afterthought: compare each year's pillar scores and initiative
status against the prior year(s) in scorecard.trend. Flag as a regression any item that was
present, in-progress, or scored better in a prior period and is now absent, flat, or worse —
the concrete examples from the source data are Net Zero commitment (still "in development"
with no formal policy across multiple years), green lease language (a multi-year gap with no
progress), and embodied-carbon tracking. Write each regression into state (feeding
sponsor.regressions[] and the regression: true markers on the affected
initiatives.planned[] entries) so the rendered report can flag them with the ⚠️ regression
marker per the template. A sponsor whose scorecard total is trending up while a named
initiative quietly backslides is exactly the failure mode this check exists to catch — do not
let an improving total mask a real regression.
-
Assemble the reconciled profile data object conforming to templates/esg-profile/schema.json
(see the data-mapping table below for the full field mapping).
Set phase: "verify" and save.
Phase: verify
Run the batch-adapted verifier__* pass — the same discipline portfolio-analysis applies at
screening scale, not the fully human-gated decarb-plan treatment. For this sponsor (or, on a
fund run, for each sponsor in the fund):
- Confirm every reconciliation-phase finding was actually recorded (
verifier__record_finding
was called for each conflict, not merely noted in prose).
- Run any additional sanity checks the verifier's data-type checklists cover for the sources in
play (energy unit sanity, scorecard total = sum of weighted pillars, CRREM stranding year
plausible for the asset's property type/region).
- Log all findings to
state.findings — this phase's job is to make the findings ledger
complete and durable for the render gate to check. Do not human-gate mid-run. Unlike
decarb-plan's Gate 1/Gate 2, this skill does not stop and wait for a person to adjudicate
each conflict before proceeding — findings are logged with their suggested_resolution and
carried forward; a human can review and override at render time if a high-severity finding
remains open, but the workflow itself keeps moving.
Set phase: "render" and save.
Phase: render
HARD sponsor-scoped gate, fail-closed. Before dispatching any render:
- Call
verifier__list_findings({asset_id: <sponsor key>}) — the sponsor key, never a bare
unscoped call and never the fund key when the run is sponsor-scoped. On a fund-scoped run,
check each sponsor's findings individually with that sponsor's own key before rolling up.
- If there are open high-severity findings on this sponsor with no matching entry in
state.overrides (each override requires {finding_id, override_reason, approved_by}) —
block. No render. Do not dispatch the renderer, do not produce a partial artifact, do not
summarize around it. Surface exactly which findings are blocking and what an override would
require.
- If there are no open high-severity findings on this sponsor (the common demo-safe case — the
deterministic connectors and gap-fillers rarely produce a high-severity conflict on a clean
run), or every open high-severity finding has a documented override, the gate passes.
- On pass, call
fill_report with report_type: 'esg-profile' and a data object conforming to
templates/esg-profile/schema.json — populate meta (fund, reporting_year, anonymized) plus:
on a sponsor-scoped run, sponsor only; on a fund-scoped run, sponsor and
fund_overview both, in the same fill_report call — the fund render is a combined
fund+investment deliverable, not a replacement for the sponsor deep-dive (see Fund rollup
below for how fund_overview is assembled). Record the returned artifact id in
state.artifact_id. A fund-scoped run at a portfolio thread renders with no asset in
context — do not require or invent an asset_id for this call. The platform stores the
artifact against the conversation's portfolio directly; fill_report needs nothing extra to
do this beyond the usual report_type + data object, exactly as on an asset-scoped call.
Set phase: "export" and save.
Phase: export
Hand off to the report-review workflow for export: PDF (Playwright), PPTX mapped to
the asset manager's Template v3 (their team's actual working format — this is the export they use, not just
the branded HTML), and XLSX (openpyxl, for anyone who wants the underlying numbers in a
sheet). The report-review workflow presents the rendered artifact for review before finalizing
exports, per its own contract — this skill does not duplicate that review loop, it dispatches
into it.
Set phase: "done" and save once exports are recorded.
Fund rollup
When config.scope: "fund", the deliverable is the Fund ESG Overview — an aggregation over
the fund's sponsor-level profiles, not a separately-authored document. Run kickoff → collect →
reconcile → verify per sponsor (each sponsor keeps its own findings and provenance), then
aggregate into fund_overview.
Portfolio-scope run — no asset in context. A fund-scoped run increasingly happens at a
portfolio thread rather than an individual asset thread. At portfolio scope EVERY source is a
live MCP connector — do NOT read portfolio files or skill-bundled demo files for the ESG inputs.
The registry (connectors/registry.json) binds each source to a connector; call them in the
collect phase and bind each result as config.connectors[source_id] with provenance.mode: "live" and provenance.origin set to the connector + tool:
questionnaire / investment_info / governance → fabric (get_questionnaire_responses, get_investment_info, get_governance_rights)
materiality → materiality get_material_topics(sector) — a framework-grounded materiality agent (SASB IF-RE, GRESB, ESRS) returning DOUBLE materiality (financial + impact) per topic; use it to weight the scorecard pillars and justify which ESG topics matter for the sponsor's asset class
fund_peers (seeds fund_overview: stats, sponsor_metrics, ranking, underperformers) → gresb get_peer_comparison; peer_benchmark → gresb get_benchmark
green_street → green-street get_sector_rating; physical_risk → firststreet get_property_risk; bps → fines get_bps_regulation; crrem → crrem get_pathway; energy → citizen-energy/ENERGY STAR
Nothing at portfolio scope should carry provenance.mode: "static" — if a connector is unreachable, mark that source unavailable and surface it (do not silently fall back to a file). The asset-scoped sponsor path (single sponsor, files bound to that asset) is unaffected — use it whenever the run does have an asset in context.
Resolve fund_peers before aggregating. Per the standard connector-binding rule above,
fund_peers is bound like any other source: resolve("fund_peers") reads whatever
state.config.connectors.fund_peers points to and returns {stats, sponsor_metrics[], ranking[], underperformers[]} for the fund's peer sponsors — never open or parse the underlying file/API by
name inline. In production this is the fund_data get_peer_rollup API adapter; for the Madison
demo it is bound {"kind":"file","path":"skills/esg-profile/demo/madison/fund-peers.json"} (set
this override in config.connectors.fund_peers at kickoff — do not hardcode the path in this
workflow). Then:
- Take the resolved
fund_peers value as the starting stats, sponsor_metrics[], ranking[],
and underperformers[].
- Merge in the subject sponsor's own connector-derived row — the
sponsor.scorecard,
sponsor.energy, and sponsor.benchmark values already assembled for this sponsor in the
reconcile phase — into sponsor_metrics[] and ranking[]. If the bound fund_peers source
already carries a row for this sponsor (the Madison demo fixture does, for convenience), the
connector-derived row replaces it rather than duplicating it, so the rendered figures always
trace back to this run's own provenance and not a stale peer snapshot.
- Recompute
ranking[] order and re-run the underperformers[] auto-selection (below) after the
merge — a subject-sponsor row merged in after the peer file's ranking was computed must not be
left out of rank order or the below/above-average check.
Aggregate the merged set into fund_overview:
stats — asset classes, locations, total size, standing/dev counts, scorecard response
rate, YoY scorecard performance, avg CRREM stranding year (weighted appropriately across
sponsors — never a naive unweighted mean if sponsor sizes differ materially), and aggregate
fine exposure.
sponsor_metrics — the matrix of every sponsor × its headline metrics (green cert %,
energy rating %, GRESB status, Net Zero policy status, energy data coverage, renewable %),
with MIEPPI and MIR comparison columns alongside each sponsor's row.
ranking — each sponsor's scorecard score, YoY change, and delta vs MIEPPI and vs
MIR, plus the fund-average and MIR-average reference rows.
underperformers — sponsors scoring below the fund average or the MIR average are
auto-selected into this table (never manually curated), each paired with its identified
risk and a mitigation measure drawn from the sponsor's own findings/initiatives.
Aggregation runs through an engine or deterministic aggregation code, never LLM arithmetic.
Averaging CRREM stranding years, computing YoY deltas, and selecting underperformers by a
threshold comparison are exactly the kind of "it's just averaging" tasks that are tempting to do
in prose — do not. Compute them the same way every other reported number in this skill is
computed: by a tool, not by you doing the arithmetic in your head.
Data mapping — 30_input_qualitative columns → schema paths
the asset manager's static extract carries most of the qualitative/static connector fields on a single
spreadsheet tab, 30_input_qualitative. This table is the canonical field map from that sheet
(and the other static/live connector outputs) into templates/esg-profile/schema.json paths.
Use it verbatim when writing the file-kind adapters' field maps — do not re-derive column
meanings from scratch on each run.
| Source column / field | Connector | Schema path |
|---|
pillar_policy_strategy | questionnaire | sponsor.scorecard.pillars.policy_strategy |
pillar_governance_resourcing | questionnaire | sponsor.scorecard.pillars.governance_resourcing |
pillar_portfolio_management | questionnaire | sponsor.scorecard.pillars.portfolio_management |
pillar_monitoring_reporting | questionnaire | sponsor.scorecard.pillars.monitoring_reporting |
(derived from pillar_* — computed, not a column) | questionnaire | sponsor.scorecard.total |
qual_summary / prior-year totals | questionnaire | sponsor.scorecard.trend[] |
initiatives_completed | questionnaire | sponsor.initiatives.completed[] |
initiatives_in_progress | questionnaire | sponsor.initiatives.in_progress[] |
initiatives_planned | questionnaire | sponsor.initiatives.planned[] (+ regression flag from reconcile) |
qual_esg_risks / qual_risk_mitigation_actions | questionnaire | feeds fund_overview.underperformers[].identified_risk / .mitigation on fund rollup |
market_regulation | bps | sponsor.risk_profile.transition.market_regulation |
(not a sheet column — from bps connector) |
Demo choreography
The primary near-term use of this skill is the 60-second collect-phase tool-streaming beat
on stage: one instruction kicks off a run against a pre-loaded, anonymized static-data repo
(the asset manager's scrubbed extracts under skills/esg-profile/demo/) plus the live connectors already
connected (ESPM/EPC-region-aware energy where applicable, crrem, physrisk). The collect
phase fans out with visibly streaming tool calls, paced so the audience can read each line —
static sources resolve near-instantly, and the three gap-fillers (crrem, physical_risk,
green_street) are called out explicitly as they run, because they are the fields the asset manager's
own process ships today as "not provided" or "analysis underway." Watching them resolve live —
turning a blank into a real stranding year and a real hazard rating — is the demonstration's
entire point: reconcile → verify → the render gate passes cleanly on the demo sponsor → the
branded HTML artifact renders in-band, then the PPTX export to Template v3 is shown as "the same
profile, in the format the asset manager's team actually uses." No live Q&A dependency, no mid-run human gate —
the run is deterministic end to end. If a live gap-filler call runs long (physical-risk lookups
have run to ~45 minutes in other skills at full scope), keep the demo scope to a single sponsor
location so the call stays tight and the beat holds.