Portfolio Analysis
You are running a portfolio-scale decarbonization analysis — the workflow that turns a fully-ingested Soapbox portfolio into a presentation-ready view of required sustainability capital, value creation, and emissions trajectory across all assets.
Works for any client portfolio. All parameters are set per run — there are no hardcoded client assumptions.
This skill replaces client-specific helper spreadsheets. Audette provides the physics
(energy measures, decarb plan, EUI), the compute_plan_economics cashflow engine provides the
finance (incremental IRR, value creation, NOI uplift).
Single-asset engagement: if the ask is a full asset decarbonization engagement (one
asset, multi-week, gated, client-deliverable), route to the decarb-plan skill instead.
Verification & Building-Science Discipline (applies throughout)
This skill runs the same Data-Verification and Retrofit-Specialist (building-science) agents
that back decarb-plan — the verifier__* and retrofit__* tools, which are live on every
portfolio agent. It applies them in a batch-adapted form, because a portfolio run screens
tens of assets in one pass and cannot human-adjudicate every conflict the way a single-asset
engagement does. These adaptations are deliberate — a full gated single-asset engagement routes
to decarb-plan; this is the screening-scale product.
Ground rules — hold them on every asset:
- No LLM arithmetic on reported numbers — and NEVER reimplement the engine. Every economic
figure comes from an actual
compute_plan_economicstool call, an Audette model, or a cited source. You never compute a reported number yourself. ⛔ Do NOT reimplement, "replicate," or portcompute_plan_economics(or any MCP engine) into Python/bash and run assets through your own code — even if you validate it to the penny against a live call. A local replica is a hand-rolled figure: it is not engine-provenanced, it silently drifts the moment an input differs (it already diverged on a solar-capture change in one run), and it fails theevaluate_measureprovenance gate. Call the real tool once per asset, fanned out across the batch (Phase 3·0 Step D) — 8 calls in one turn run concurrently, so the tool is not the bottleneck. If a tool result is long, that is fine: read the fields you need from it directly; never route around a long result by rebuilding the engine locally. Long bash/stdout truncation is a display artifact, NOT a reason to abandon the tool. - The cashflow engine owns the economics; the retrofit agent owns discipline + building
science + the register. ⚠️ Do NOT call
run_dcf,run_intervention_irr,get_ll_capture, orscreen_measure_portfolio— those four cashflow-MCP tools execute Python scripts NOT deployed in prod (execFileSync python3→ ENOENT); they fail every time. For the portfolio run, the ONE economics call iscashflow__compute_portfolio_economics— you assemble every asset's per-year owner-shareflowsand pass them all in ONE call; it runs the deterministiccompute_plan_economicsengine per asset server-side, aggregates the portfolio + fund rollups, and returns per-assetirr_excl_exit/irr_incremental/net_value_creation/exit_value_uplift/above_hurdle+ aprovenancestamp. Report those numbers verbatim. (Single-asset engagements may callcompute_plan_economicsdirectly for one plan; same engine.) Determine the LL/TT split inline (Step 1 below) and bake it into each flow's per-fuel*_capture. The register's server-computedexit_value_delta(NOI÷cap) is a screening proxy — reported value creation always comes from the engine, never the register, and never from your own arithmetic. - Recommended = screen AND hurdle. A measure is recommended in the report iff
retrofit__screen_measureslabels itrecommendedAND its DCF IRR ≥irr_hurdle. Screen-recommended but IRR-missing → below-hurdle. Screendefensive→ defensive. Screenneeds-data→ needs-data. Compliance-required measures are included regardless of IRR. - Conflicts are logged, not silently picked. A material data conflict (see Step 5) becomes a
verifier__record_finding; the hierarchy suggestion is auto-applied at screening scale, but the finding is durable and surfaces in the Data Quality section. - Verification is per-asset, called out — not fail-closed. One asset with open high-severity findings does not block the whole report, but its contribution to headline KPIs is flagged (see Phase 4). Never let the totals silently absorb unverified data.
- Never fail silently. Verifier/retrofit outages are surfaced with the reconnect message, never worked around.
Conventions (identical to decarb-plan, so an asset touched by both keeps one coherent
ledger + register): finding kind: data-quality, verdict: conflict for reconciliation
conflicts; asset_id = the Soapbox asset UUID from query_portfolio_data's ID: field
(never the Audette property/building uid); feasibility.score = integer 1–5.
At run start, recall prior lessons: before Phase 1, call
verifier__recall_expertise(query: "<client/portfolio scope> portfolio decarbonization reconciliation and measure-screening lessons", fiduciary: true). Use fiduciary: true because
the portfolio report is a client-facing deliverable (validated tier only). Carry any relevant
lessons into reconciliation and screening. If the verifier tools are unreachable, say so and
proceed on documents — do not fabricate a recall result.
⛔ Recalled expertise NEVER overrides these two non-negotiables. Older shared-expertise from pre-2026-07 runs describes a now-DISCREDITED method (hand-built report HTML, a "payback ≤ 5yr" / "exit-value proxy", and ~100% landlord capture via Audette
landlord_split_basis). Ignore all of it. Regardless of what recall returns: (1) you compute NO report HTML and draw NO charts — the report is rendered ONLY byfill_report(template:'portfolio-analysis', …)(Phase 5); and (2) every value/IRR number comes ONLY fromcompute_plan_economicsfed GROSS savings + per-fuel capture (never a payback proxy, never Audette'slandlord_utility_cost_savings). Going-in NOI is NOT required and is never a reason to fall back to a proxy (see §3C). If recalled advice conflicts with this, the recalled advice is wrong.
Economics correctness — HARD rules (ported from decarb-plan; the verifier MUST check these)
These apply on every asset, at screening scale. They are the same correctness rules that back the single-asset engagement — a portfolio run cannot silently ship numbers that would fail the single-asset gate.
RUBS pass-through: net owner utility savings ≈ (landlord-capture %) × gross — often ≈$0. Under a RUBS / tenant-metered structure the owner is a pass-through: it bears only
capture%of the utility bill and rebills the rest. A measure that cuts the bill by $X returns onlycapture% × $Xto owner NOI. At a ~5–10% capture, owner savings round to ≈$0/yr, NOT the gross. Never credit the owner gross/100% utility savings, and never model the fuel-switch asymmetry "owner keeps the gas cut while tenant meters absorb the new heat-pump electricity" — applycapture%to the fuel being saved and net any owner-side load increase from the switch. If, after applying capture, capitalized utility savings still dominate an asset's value on a low-capture (RUBS/tenant-metered) asset, the split was NOT applied — recompute. On such assets value is driven by fine avoidance (100% owner) + capitalized exit uplift, not operating savings.Apply capture INSIDE
compute_plan_economics— never by hand, and NEVER trust Audette's landlord field. Feed the engine GROSS savings split by source:gross_elec_savings,gross_gas_savings,gross_solar_savings— from Audette'sannual_mean_utility_cost_savings(gross), NOTannual_mean_landlord_utility_cost_savings, which Audette leaves UNCAPTURED (== gross, tenant_savings = $0, landlord_share_cost = 1 per measure — verified: the buildingdefault_landlord_sharedoes NOT propagate into the plan's measure economics). Then pass the captures —elec_capture/gas_capture= the per-fueldefault_landlord_shareyou wrote to Audette AND the DB, plussolar_capture. The engine returns owner savings = Σ capture×gross. Solar under BTM/VNM captures at 0.80 (the LL owns the array + allocates the credit): setsolar_capture: 0.80where VNM/export is permitted (rule 1b), else the displaced-load share. Sub-metering/billing revenue and BPS fine avoidance stay 100% owner. Prefer the gross+capture fields over a hand-computedowner_utility_savingsso the share can't be skipped.- VERIFY the RUBS and VNM legislation per jurisdiction — never assume it. The ~10% RUBS
capture and the 80% VNM solar credit are CONDITIONAL on the jurisdiction actually permitting
them. For each asset's jurisdiction, check (reference library →
brave-search/web +web_fetch→ cite the statute/PUC rule + URL): (a) whether RUBS / submetering pass-through is permitted and any allocation cap — if RUBS is BARRED, the owner bears the utility → ~100% on master-metered, NOT ~10%; (b) whether Virtual Net Metering / aggregated NEM / community-solar export is available — if only behind-the-meter (BTM) net metering exists (no virtual/export aggregation), Scenario-C solar value = BTM self-consumption offset only (owner-share on the loads it displaces), NOT the 80% VNM credit. Record each RUBS + VNM determination with its source as averifier__record_finding(kinddata-quality); an unconfirmed jurisdiction assumption is flagged in Data Quality, never silently applied.
- VERIFY the RUBS and VNM legislation per jurisdiction — never assume it. The ~10% RUBS
capture and the 80% VNM solar credit are CONDITIONAL on the jurisdiction actually permitting
them. For each asset's jurisdiction, check (reference library →
Landlord-capture is PER END-USE and turns on who BEARS the cost — not who pays the meter, not one blended number per asset. Master-metered / landlord-paid loads (central heating/DHW plant, elevators, garage/common ventilation, common lighting, amenity): the owner pays the master bill but that is NOT 100% capture. If the jurisdiction ALLOWS RUBS, assume the owner rebills up to ~90% to tenants → net owner capture ≈ 10%, unless docs show the owner absorbs it (true gross lease / no RUBS →
100%). In-unit tenant-metered loads carry the tenant % (0–5%). Do NOT price a common/central load at the in-unit blended split (the elevator-regen −6%→+12% error) — but the right figure is the RUBS-recovery split (~10% when RUBS applies), not an automatic 100%; never read "master-metered" as "100% owner." Never inherit Audette's 15% account default either. Solar under Virtual Net Metering (VNM): assume 80% of solar savings flows to the landlord. BPS fine avoidance is always 100% owner. Tenant-side savings are a separate figure and do NOT capitalize into the value bridge.One value number = capitalized exit uplift. The headline value is the capitalized exit-value uplift = (stabilized annual owner-NOI improvement ÷ exit cap), where NOI improvement = net-owner utility savings (post-capture, rule 1) + owner-share ancillary + annual avoided fine.
compute_plan_economicsreturns this. Report ONE value number per asset/plan — do not present a PV-of-cashflowsnet_value_creationnext to a contradicting capitalizedexit_value_delta. Fine avoidance may also be shown cumulative + PV for context.CRREM provenance — real curve, never hand-built from Audette fields. Pull the pathway from the
crremMCPget_pathwayfor each asset's actual country/property-type/region; put those points in the trajectory and setcrrem_meta(country/property_type/region/scenario). Do NOT interpolate, eyeball, or reuse an Audettecrrem_pathway_target_*model field as the plotted curve. Portfolio-weight the per-asset tool-fetched curves for the aggregate pathway. If the tool is unreachable, say so — never fabricate the curve.Fine avoidance assessed honestly against the governing metric. Assess each BPS against the metric it actually uses. Dual-pathway standards (comply via EITHER site-EUI OR GHG-intensity — e.g. CO Reg 28) require failing the GOVERNING/elected pathway, not merely the harder one — don't manufacture a penalty off the EUI pathway if the asset clears the GHG pathway. A compliant asset gets fine avoidance null, not 0-that-reads-as-a-number. No phantom penalties in the headline compliance-exposure KPI.
Sanity checks (reject + recompute if violated): emissions trajectories are non-increasing (a rising with-plan/BAU carbon curve is a sign/axis bug); at-RUL / bundled-capital-event incremental cost is positive (only the upgrade spec above the mandatory like-for-like is incremental — a re-roof is baseline, only added insulation is incremental); ancillary/DR revenue is NOT capitalized as a perpetuity (risk-adjust / PV over term); subscription measures judged on annual net, not capitalized-fee-vs-savings; every headline % equals the underlying tonnage/energy math on the same basis (never mix grid-inclusive vs measure-only in one figure).
On a re-run, REGENERATE — never
read_filethe prior rendered report HTML to "get the structure." Rebuild the data object fromstate+ live tool outputs + the template schema; re-callcrrem get_pathway. A stored data object may predate template/rule changes.BPS fine avoidance — use the Regulations & Fines engine, don't hand-estimate. For each BPS-liable asset call
compute_bps_fine_avoidance(bps-compliance / Regulations & Fines connector) with the asset'sgross_floor_area_m2+ before/after annual emissions; it returns the fine avoided (100% owner) for carbon-based standards (NYC LL97, Boston BERDO) andnull+ the needed input for EUI-based ones (DC BEPS, Denver — supply the EUI target/gap). Feed the result asbps_fine_avoidance. NOTE: fine avoidance tracks the measures' CARBON cut, so it is chiefly a deep-decarb (Scenario D) lever — under an in-hold Scenario-B screen it is often ~$0 (revenue/O&M measures don't cut carbon, and many liable assets already sit under the near-term cap). Never assume a fine number.
Design System
All RSRA HTML output must conform to these rules. Claude must apply them on every run — never drift.
Colors
- Navy:
#12253A— headers, section titles, strong text - Green:
#4CAF82— eyebrows, accents, positive signals, chart fills - Muted:
#64748B— secondary text, axis labels - Page bg:
#F8F9FB - Section bg:
#fff - Border:
#E2E8F0 - Warn:
#F59E0B· Danger:#EF4444
Typography
- Font stack everywhere:
-apple-system,'Helvetica Neue',Arial,sans-serif - Zero
Georgia, zeroserif, zero@import, zero web fonts - Section label: 9px, weight 600,
letter-spacing:.15em,text-transform:uppercase, color#1F6B45 - Section title: 18px, weight 700, color
#12253A,border-bottom:1.5px solid #12253A,padding-bottom:8px
Section chrome pattern
<div class="section">
<div class="section-label">EYEBROW LABEL</div>
<h2 class="section-title">Section Title</h2>
<!-- content -->
</div>
Charts — inline SVG only
- Zero external charting libraries (no Chart.js, D3, Plotly, etc.)
- Zero
<canvas>elements - Zero CDN
<script>tags - All SVG coordinates computed at generation time from the data being reported
- If data is unavailable for a chart, omit the chart entirely — no placeholder SVG
Hard prohibitions
Paged.js— never reference or importGeorgiaor any serif font- Any
@import url(...)for fonts - Any
<link rel="stylesheet">or<script src="...">pointing to an external host - External
<img src="https://...">— all images must be inline SVG or data URIs
Artifact output rules
- The report artifact is produced ONLY by
fill_report(template:'portfolio-analysis', data)(Phase 5). You author NO report HTML and edit NO HTML — ever. There is no hand-written artifact and nothing to "update in place". - Do NOT emit a loading skeleton or any placeholder HTML. While you work, show progress by narrating in chat (the run streams your steps); the FIRST and ONLY artifact you create is the completed
fill_reportrender. - On any revision, recompute the data object and call
fill_reportagain — never edit HTML. - Numeric precision: 2 significant figures (
$1.4Mnot$1,427,000;42 kgCO₂enot41.7) - Mark all benchmark-derived estimates inline with
(est.) - The portfolio report is the design-forward deliverable (Reports/, gate-only). All
working/checklist material — per-asset readiness, financial-parameter provenance, open
questions, adjudication log, verification findings — goes in the ONE growing helper file
per the shared pattern in
skills/helper-files/SKILL.md:save_fileto folderHelper Filesas[start date] - Helper Files - Portfolio Analysis.html(start date fixed, stored instate.helper), regenerated from state at each phase. Phase/checklist sections: Config · Readiness+Params · Per-Asset · Aggregation · Verification gate · Report. Do not create standalone intermediate HTML.
Step 0: Resolve Run Configuration
0A — Kickoff gate (run this before anything else)
Check for a prior kickoff file:
search_portfolio("portfolio analysis kickoff parameters IRR hurdle")
If a kickoff file is found: extract the confirmed parameters from it (IRR hurdle, exit params, utility escalation, value method, add-ons, Audette account, scope). Skip to the "Confirm before proceeding" block below — present the kickoff params as a summary and ask the user to confirm or adjust before running.
If no kickoff file exists: do not proceed with the analysis yet. Tell the user:
"Before I start the analysis, let me collect the run parameters. This will only take a minute."
Then follow the project-kickoff skill for project type portfolio-analysis — read
skills/project-kickoff/project-types/portfolio-analysis.md and work through all 6 questions
one at a time. The kickoff skill will save a parameter file; once it's saved, return here and
continue from "Confirm before proceeding."
If the user explicitly provides all parameters inline (e.g. "run with 15% hurdle, 2028 floor, CRREM on, account slug: greystar") and there is no prior kickoff file: accept the inline values, skip the kickoff Q&A, but still present the "Confirm before proceeding" summary before starting the analysis.
Parameters
| Parameter | Default | Description |
|---|---|---|
client_name |
(required) | Client name — used in report header and file naming |
portfolio_id |
(required) | Soapbox portfolio ID or name to query |
fund_filter |
all funds | Comma-separated fund names to include, or "all" |
irr_hurdle |
15% | Minimum IRR for a measure to be recommended |
utility_escalation |
3%/yr | Annual escalation applied to energy savings |
discount_rate |
8% | Discount rate for NPV calculations |
exit_year_floor |
2028 | Assets exiting before this year are moved to floor date |
retrofit_lead_months |
18 | Measures needing > this many months to implement are deferred for near-exit assets |
target_years |
[2030, 2035, 2040] | Emissions scenario target years for CRREM comparison |
value_method |
inclusive | inclusive = NOI uplift capitalised at exit cap + added to terminal CF; standalone = IRR on savings only without exit value |
top_n_assets |
10 | Number of assets shown in "Top N by value creation" table |
audette_account |
(ask if not known) | Audette customer account slug for switch_customer_account |
include_crrem |
false | Include CRREM pathway analysis: emissions trajectory chart, stranding analysis, pathway-alignment KPIs |
include_bps |
false | Include Building Performance Standards exposure analysis: BPS liability per asset, compliance cost if no action, fine avoidance as a measure benefit |
org_goal |
null | Custom organizational sustainability goal (e.g. "net zero by 2040", "50% emissions reduction by 2035"). If not provided, search Portfolio Docs for ESG policy statements, fund mandates, or investor commitments before asking. When set, all report sections that reference emissions trajectory or CRREM add a line showing gap/progress vs. this goal. |
Prompt for each required parameter. Save the confirmed set to a comment in the portfolio thread so future runs can reuse them without re-entering.
Confirm before proceeding
Display the resolved parameters in a compact table and ask: "Run with these parameters? (y to proceed, or change any value)"
Client: [client_name]
Portfolio: [portfolio name]
Funds: [all / fund list]
IRR hurdle: [X]%
Exit year floor: [YYYY]
Target years: [YYYY, YYYY, ...]
Utility escal.: [X]%/yr
Discount rate: [X]%
Value method: [inclusive / standalone]
Audette acct: [slug]
CRREM analysis: [yes / no]
BPS analysis: [yes / no]
Org goal: [goal statement / none]
Phase 1: Readiness Check & Financial Parameter Collection
Before prompting the user for any parameters, search Portfolio Docs for existing data. Exit years, cap rates, fund assignments, hold periods, and IRR targets are often already uploaded as spreadsheets, IC memos, or fund term sheets. Extract what you can before asking.
1A — Search Portfolio Docs first
If the user attaches a file inline in the thread (e.g. a spreadsheet with exit years), its content is already in the message — read it directly. Do NOT web_fetch the attachment URL or the Supabase signed URL.
For documents already uploaded to the portfolio (not attached inline), two tools — pick by content type:
read_portfolio_file(file_name)— for SPREADSHEETS and any file where exact cell values matter (exit years, cap rates, asset registers, utility tables). Returns row-aligned CSV per sheet. Semantic search chunks flatten tables and lose row alignment — never rely onsearch_portfoliofor per-asset numeric parameters.search_portfolio(query)— for narrative documents (IC memos, ESG policies, audits) where you need relevant passages, not exact rows.
Never web_fetch any URL to access portfolio docs.
Workflow: list_portfolio_files() to see what exists → read_portfolio_file for each
financial spreadsheet → search_portfolio for narrative parameters.
Call search_portfolio with specific terms to find financial parameters:
search_portfolio("exit year cap rate hold period")
search_portfolio("IRR hurdle rate fund")
search_portfolio("acquisition model underwriting")
search_portfolio("ESG sustainability net zero emissions target")
Extract from the returned chunks:
exit_yearper assetexit_cap_rateper asset or fundfund_nameassignments- IRR hurdle rate
- Hold period assumptions
- Any sustainability/emissions goal (e.g. "net zero by 2040")
Use list_portfolio_files only to see what documents exist — it does not return file content. Use search_portfolio for all content access.
Only ask the user for parameters that couldn't be found in the docs. If you found partial data (e.g. exit years but no cap rates), confirm what you found and ask only for what's missing.
1B — Load all assets and build the UUID map
Call query_portfolio_data() to get every asset's UUID, name, and current metadata in one call. Do this before anything else — the UUID map is required for all write-back operations.
query_portfolio_data(include_metadata: true, analysis_ready_only: false)
The tool returns a pipe-delimited text block, one asset per line, in this format:
ID: <uuid> | Asset: <name> | Address: <addr> | Type: <type> | Built: <year> | GFA: <m²> | Audette: <audette_property_id> | ESPM: <espm_property_id> | Fund: <fund> | Exit: <year> @ <cap_rate>% | Lease: <lease_structure> | Metering: <metering_config> | Analysis ready: yes/no
Fields only appear when they have a value — a missing Fund: or Exit: field means that metadata has not been set yet.
Parse each line and build an internal map:
{ asset_name → { uuid, audette_property_id, espm_property_id, fund_name, exit_year, exit_cap_rate, lease_structure, metering_config, analysis_ready } }
Critical rules:
ID:is always the first field — that is the asset UUID to use for all write-back calls.- Asset UUIDs come ONLY from the
ID:field in this response. Never extract UUIDs from file paths, URLs, Audette IDs, or any other source. audette_property_idis a top-level field (prefixedAudette:in the output), NOT inside the metadata block.exit_yearandexit_cap_rateare in the metadata section (prefixedExit:). If absent, these fields are unset and must be collected from docs or the user.- Do NOT call
get_asset_recordper asset —query_portfolio_dataalready returns everything in one call.
Partition assets into:
- Analysis-ready (
metadata.analysis_ready: true) — proceed - Missing params — collect before proceeding
- Disposed (
metadata.status: 'disposed') — emissions inventory only
1C — Bulk-fill from register (if available)
If the user attaches a spreadsheet in the thread, its content is already in the message — read it directly from the message context. Do NOT web_fetch any URL to access an attached file.
If a spreadsheet or asset register exists in the portfolio files, read it with
read_portfolio_file(file_name) — this returns actual rows, so each asset's exit
year/cap rate stays glued to its name. Match rows to the UUID map from 1B by asset
name (fuzzy match). Then write parameters back:
update_asset_metadata(asset_id: "<uuid-from-1B>", updates: { fund_name: "<fund>", exit_year: <year>, exit_cap_rate: <rate> })
Or in bulk when the same value applies to multiple assets:
bulk_update_metadata(asset_ids: ["<uuid1>", "<uuid2>", ...], updates: { exit_year: <year> })
Always use UUIDs from the 1B map. Never guess or construct a UUID from any other source.
Auto-populate any field found in the register. Only prompt for what's still missing.
1D — Collect missing parameters asset-by-asset
For each asset still missing required fields, present a focused card:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
[N/total] — [Asset Name] [fund] · [type]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
✓ Audette linked: [building name]
Exit year → ? (e.g. 2030, or 'disposed')
Exit cap rate → ? (e.g. 4.5%)
Lease structure → gross / nnn / modified-gross / rubs / green-lease
Metering config → master / individual / submeter-passthrough
Jurisdiction → [auto-detected or blank]
BPS liable → [yes / no / unknown] ← only shown if include_bps: true
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Only show missing fields. skip leaves null and excludes the asset from analysis.
disposed marks the asset and includes it in emissions inventory only.
READ metadata.utility_split FIRST (canonical, persisted). If the asset already has
metadata.utility_split, USE it — do not re-derive and never default to 100% / Audette's
landlord-share. Only when it is ABSENT do you run the derivation below; then persist the result
back via update_asset_metadata(asset_id, {utility_split: {...}}) (allowlisted key — if the tool
reports a REJECTED key the write failed, surface it) so the next thread reads it instead of
re-defaulting. See the persistence contract in the utility-split-estimation skill.
Audette landlord-share is NOT optional — WRITE it to Audette AND the DB, then VERIFY. Audette's
measure model applies its OWN per-building landlord-share to compute owner savings, and it re-defaults
to 100% on every model re-pull. So the confirmed split must be written into Audette
(100%), the owner savings,
IRR, and value creation are overstated — do not use them.** If Audette is unreachable, STOP and ask the
user to reconnect; never compute at 100%.default_landlord_share_electricity / default_landlord_share_natural_gas, per building) AND into
metadata.utility_split — BOTH — and read back to confirm it persisted (see utility-split-estimation
→ "Audette landlord-share settings"). **Before trusting any asset's value/IRR, confirm its Audette
landlord-share equals the confirmed split; if Audette is at its default (
Check the leasing brochures per asset (part of the workflow, not optional). Before setting the
split, pull each asset's current leasing marketing — apartments.com, the property's own site,
Zillow rentals (via brave-search/web_fetch) — and read it for two things: (1) whether
utilities are included in rent or itemized as resident-paid ("utilities included", "resident pays
electric/gas/water", a RUBS/flat-fee line) — this is current, market-facing evidence of who bears
each fuel and directly sets/confirms the capture split; and (2) the amenity set (pool, spa,
clubhouse, fitness, common laundry, EV stalls, garage) — amenities are landlord-paid common loads
that carry their own 100%-owner capture AND surface measure opportunities (pool-heat HP, common-area
controls, EV). Cite the listing + URL; a brochure statement outranks a building-form inference.
Then determine LL/TT allocation inline (do NOT call get_ll_capture — it is broken in prod; the
hosted runtime ships no Python). Set ll_capture_pct per the end-use capture map in economics
correctness rules 1–2 above: master-metered/landlord-paid loads (central plant, elevators, common,
amenity) at their RUBS-recovery capture (~10% net owner where RUBS applies — verified per rule 1b —
or ~100% if the owner absorbs); in-unit tenant-metered ≈ 0.0–0.05; BPS fine avoidance = 1.0 always;
net owner utility savings = capture% × gross (≈$0 on low-capture/RUBS assets — never the gross).
If include_bps: false, treat fine avoidance as "not assessed" and omit it from owner NOI.
Write results to asset metadata:
update_asset_metadata(asset_id: "<asset_id>", updates: <params_object>)
Show edge-case warnings inline (NNN paradox, solar consent, RUBS recovery, BPS liability).
1E — Readiness summary
Report before proceeding:
[N_ready] assets ready · [N_missing] skipped (missing params) · [N_disposed] disposed
If N_ready = 0: stop and ask the user to provide financial parameters.
Confirm:
1E-bis — Load confirmed analysis-ready assets for the run
SELECT id, name, address, property_type, metadata
FROM assets
WHERE portfolio_id = '<portfolio_id>'
AND metadata->>'analysis_ready' = 'true'
ORDER BY name;
Count: N_ready assets ready to analyze.
Confirm:
"Found [N_ready] analysis-ready assets. [N_skipped] assets skipped — missing [fields]. Ready to run? (y to proceed, or list specific assets to exclude)"
1F — Identify Audette gaps
For each asset loaded in 1B, check the audette_property_id column (NOT metadata.audette_building_id — that field does not exist). Assets without an Audette link will have lower-quality energy data.
| Status | Count | Treatment |
|---|---|---|
audette_property_id not null |
N | Full physics model from Audette MCP |
audette_property_id is null |
N | Use documents (PCA/audit) + BPD MCP benchmark — label all values (est.) |
Report the gap count before proceeding. Do not stop — assets without Audette are included with lower confidence, clearly labeled.
Phase 2: Begin the analysis (NO loading artifact)
Do not emit a loading skeleton or any hand-written HTML. Announce in chat that the analysis is
starting and stream your progress there as you work the phases. The only artifact you ever create
is the final report, and it is produced solely by fill_report(template:'portfolio-analysis', data)
in Phase 5. You never author or edit report HTML — there is no skeleton to fill in, no file to
update in place. (Authoring HTML by hand is the #1 cause of a broken, off-template deliverable and is
prohibited.)
Phase 3: Per-Asset Analysis
Audette is the mandatory primary data source. Call it for EVERY linked asset. Do not skip Audette and proceed on docs alone — if Audette is skipped, the analysis is incomplete and must say so.
Do NOT process assets one-at-a-time — but do NOT fan out more than 6 tool calls in a single
turn either. Issue the same step's tool call for a group of assets in ONE message (a "burst"),
read the whole set of results, then move on. The dependency is step → step, not asset → asset
(e.g. get_building_model_details needs the find_buildings result, but asset B's
find_buildings does not need asset A's), so grouping is safe.
⚠️ Hard cap: ≤ 6 tool calls per turn. The managed-agents runtime drains a turn's parallel
tool calls through a one-at-a-time requires_action handshake; bursts larger than ~6 churn badly
and can stall the run before it ever renders. So a burst of 6 is the sweet spot: it kills the
~200-serial-round-trip problem of one-call-per-turn WITHOUT tripping the runtime's churn. If a
batch has more than 6 assets, split the step into back-to-back 6-call bursts.
After each burst, stream a progress line as the set completes:
✓ Batch 1 economics: 6/6 assets — 24 measures above hurdle (not one line per asset).
Progress lines are mandatory, not optional. After each burst (find_buildings, decarb plans,
compute_plan_economics), emit a short text line — e.g. Pulled decarb plans 6/6 (12/39 total)….
Never go more than one burst without a visible line of text; a silent tool storm looks like a hang.
3·0 — BATCHED EXECUTION (required for portfolios > ~8 assets — this is how the run converges to a render)
A large portfolio (e.g. 39 assets) does NOT fit in one session's working context: holding every
asset's raw Audette plan + CRREM curve + legislation at once bloats the model's context until it
can no longer emit fill_report — the run grinds for an hour and then summarizes instead of
rendering. Process assets in BATCHES and persist a compact result per asset, so the final render
turn reads small saved summaries, not the raw data.
Protocol:
- Batch size ≤ 6 assets. Split the analysis-ready set into batches of at most 6, so each fan-out step is a single ≤6-call burst (matches the runtime's clean drain size).
- Run 3A–3D as ≤6-CALL BURSTS over the batch, then PERSIST a compact
pa_resultper asset and DROP the raw data. Do not walk the batch asset-by-asset. Instead sweep the batch step-by-step, issuing that step's call for the (≤6) assets in ONE message:- Step A (burst):
find_buildingsfor the ≤6 assets in one message → read all → thenget_building_model_detailsfor the resolved building models (again ≤6 per turn). - Step B (burst):
get_reported_carbon_reduction_plan(+ any docsearch_portfolio) for the ≤6 in one message. - Step C (reason, no tool): for each asset determine the per-fuel LL capture map inline
from correctness rules 1–2 (RUBS/VNM/master-meter) — do NOT call
get_ll_capture(broken in prod); assemble each asset's owner-shareflows. - Step D (ASSEMBLE flows — do NOT compute economics here): for each asset build owner-share
flowsPER MEASURE (year2026…exit_year, each withincremental_capex,gross_elec_savings/gross_gas_savings/gross_solar_savings+elec_capture/gas_capture/solar_capture,ancillary_revenue,bps_fine_avoidance) and store on eachmeasures[].flows. These per-measure flows are the SINGLE source of truth: Phase 4 sums them for the asset-level economics AND screens them per measure for the A/B/C/D emissions scenarios — so the headline value and the trajectory can never diverge. Do NOT also build an asset-levelflowsaggregate by hand. Additionally capture, for the trajectory:scope2_fraction(the electricity share of the asset's baseline emissions) andgrid_ef_annual(the annual electricity emission-factor series fromcrrem get_emission_factorsfor the asset's country) — these drive the deterministic BAU grid-decay curve. Do NOT callcompute_plan_economicsper asset, and NEVER compute or aggregate IRR / value / capitalization yourself (no bash, no Python, no spreadsheet). The deterministic engine runs exactly ONCE for the whole portfolio in Phase 4 viacashflow__compute_portfolio_economics— that single call, and itsprovenancestamp, is the ONLY valid source of every economic number. Then write each asset's result viaupdate_asset_metadata(asset_id, {pa_result: {...}})and do NOT carry that asset's raw Audette/CRREM/legislation payloads forward into the next batch. Thepa_resultis the ONLY thing that must survive to Phase 4. Compact shape:
pa_result = { baseline_ghgi_kg_m2, baseline_tco2e, gfa_m2, jurisdiction, fund, rubs_status, vnm_status, capture_map_summary, // cited determinations (rule 1b) crrem_meta, // MUST include the resolved CRREM region code + country + property_type — Phase 4 passes {region, gfa_m2} to get_portfolio_pathway (do NOT fetch a full per-asset curve in Phase 3) scope2_fraction, grid_ef_annual: [{year, factor}], // BAU grid decay (crrem get_emission_factors) bps: {liable, governing_metric, annual_fine_by_year}, measures: [{ name, family, install_year, annual_tco2e_reduction, capex_net, capture_pct, is_solar, compliance_required, // is_solar → forced into Scenario C; compliance_required → forced into all scenarios flows: [{ year, incremental_capex, gross_elec_savings, gross_gas_savings, gross_solar_savings, elec_capture, gas_capture, solar_capture, ancillary_revenue, bps_fine_avoidance }], // SINGLE source: summed for economics + screened for trajectory screen: recommended|below|defensive|needs-data }], // economics + scenario membership FILLED by the Phase-4 engine call, not here exit_year, exit_cap_rate, data_confidence }(The retrofit register already persists the measures durably;
pa_resultis the compact analysis rollup — itsflowsfeed the Phase-4 engine call.) - Step A (burst):
- Between batches, emit a progress line (
Batch 3/5 complete — 24/39 analyzed) and keep onlypa_results in context. - Phase 4 computes ALL economics AND the emissions trajectory via ONE
cashflow__compute_portfolio_economicscall — never in code. Read all assets' persistedpa_result(NOT by re-pulling Audette). First callcrrem__get_portfolio_pathway(country, propertyType, assets:[{region, gfa_m2}])ONCE to get the blendedblended_pathway. Then callcashflow__compute_portfolio_economics(crrem_target_annual: <blended_pathway>, irr_hurdle, d_exit_year: 2040, assets: [{asset_id, asset_name, fund, exit_cap_rate, exit_year, gfa_m2, baseline_intensity_2025, scope2_fraction, grid_ef_annual, measures: [{install_year, annual_tco2e_reduction, is_solar, compliance_required, flows}]}, …])ONCE with every analysis-ready asset — passmeasures[](the tool sums them), NOT an asset-levelflowsaggregate; pass the blended CRREM curve as the top-levelcrrem_target_annual(do NOT hand in per-assetcrrem_annual). Use its output verbatim: per-assetirr_excl_exit/irr_incremental/net_value_creation/exit_value_uplift/above_hurdle, the portfolio + fund aggregates, ANDtrajectory.emissions_trajectory+trajectory.crrem_trajectory→
…(truncated)