Portfolio Ingest
Proven end-to-end on Cortland Batch 1 (2026-07-02): 1 org, 10 assets, 10 Audette
links (5 required property grouping), 9/10 ESPM links, 30/30 documents indexed
(7,406 chunks). Follow the stages in order — ordering constraints are real.
Relationship to the frontend onboarding wizard
The platform UI has an interactive flow (AssetOnboardingModal → AssetReviewTable
→ AssetCreationRunner in platform-web) that does drag-drop → LLM extraction →
geocode/footprint → fuzzy-match review → create+link+upload. This skill and the
wizard call the same soapbox-api endpoints and must stay convention-compatible:
- Same endpoints:
POST /api/assets, PATCH /api/assets/:id,
GET/POST /api/portfolios/audette|espm/properties[/refresh],
POST /api/assets/:id/files, POST /api/portfolios/files.
- Same auto-match threshold: ≥ 0.85 auto-link, 0.40–0.85 review, < 0.40 no match.
- Same metadata convention:
metadata.setup_complete: false +
metadata.ingestion_source — the wizard writes "asset-onboarding", this skill
writes "portfolio-onboarding". Both are recognized downstream; keep the
distinction so runs are attributable.
- Division of labor: the wizard assumes the org/portfolio already exist and works
asset-by-asset with a human reviewing matches. This skill additionally creates the
org/portfolio/members, does Audette property grouping (the wizard can only
link or open a "Create in Audette" thread), handles portfolio-level reference
files, splits oversized PDFs, retries failed indexing, and enriches asset
metadata from helper data. If the user is present and wants to eyeball matches,
you can stop after Stage 2 and hand them to the UI ("Add assets") instead.
- Footprint mapping ("📍 Map" chip — Overture footprint queue) is the wizard's specialty; skip
it here unless asked.
- BUT backfill the postal-address fields (
street_address, city, state_province, postal_zip_code, country) when you link Audette. Audette's building model (get_building_model_details) carries
the full address for every property, and you are already pulling that model to link the asset —
copy the address into those columns in the same asset-update call. Do NOT leave them null (the
Greystar ingest did: 37/39 had lat/lon from geocoding but 0/39 had street/city/state, so every
downstream run — BPS applicability, CRREM region, RUBS/VNM legislation, physrisk, brave lookups —
had to re-pull addresses from Audette). state_province especially gates the jurisdiction checks.
If Audette is unavailable, reverse-geocode from lat/lon (Overture) as a fallback and label (est.).
Prerequisites
- Service account: Vaultwarden item
Soapbox Service Account (claude@agents.soapbox.build) (vw get password "..."). Sign in via Supabase
auth REST (POST {SUPABASE_URL}/auth/v1/token?grant_type=password with the anon
key from ~/platform-web/.env.local). Tokens last 1h — re-auth before long stages.
- API:
https://soapbox-api-production.up.railway.app; every call needs
Authorization: Bearer <token> + x-organization-id: <org uuid> (tenant
middleware resolves the portfolio from the org).
- Supabase project
fplbvanvwvnviczozwhz (direct SQL via Supabase MCP) for the
steps with no API endpoint: org + member creation, connector-row copies,
metadata jsonb merges, and verification rollups. All asset fields go through
the API.
- Scripts (already built,
scripts/): portfolio_match.py (fuzzy matching),
document_classifier.py (doc typing), ll_allocation.py (LL/TT tree). Use them
when name matches aren't obvious; skip when filenames are unambiguous.
Stage 0 — Source discovery
Accept any of: zip/Taildrop drop, Google Drive/Box folder, typed register. Extract
zips excluding __MACOSX/ and .DS_Store. Dedupe by sha256 — client drops
routinely duplicate files across folders (Cortland: 31 of 60 were dupes). Prefer
the copy inside a typed subfolder (PCAs/, Audits/) as canonical.
Build the asset register: {name, address, city, state, zip, gfa, year_built, floors, units} per asset from helper data / spreadsheet / audits. Preserve any
confidence tags — mark everything not client-verified as such in metadata.
Stage 1 — Org + portfolio + members
- SQL:
insert into organizations (name) values ('<Client>'); add
organization_members rows for christopher@soapbox.build
(f243660a-6991-4f7a-97f4-fefce9e24873) and claude@agents.soapbox.build
(63025178-e0ac-4018-910d-55b876e441db), role owner.
- API:
POST /api/portfolios {name, organization_id} as the service account —
this provisions the Stripe customer, core plugins (plugin_catalog auto-
provision), and the caller's portfolio membership. Do NOT insert the portfolio
by SQL.
- SQL: add christopher as portfolio_members
admin (the API only adds the caller).
Stage 2 — Create assets (BEFORE installing the Audette connector)
Ordering constraint: POST /api/assets fires triggerAudettePropertyCreate,
which auto-creates Audette properties if plugin_audette is installed on the
portfolio. Creating assets first (no connector yet) makes the pipeline skip
(audette_pipeline_state: skipped_no_plugin) and prevents duplicate Audette
properties when records already exist.
Per asset:
POST /api/assets {name, lat?, lon?, num_floors?}.
PATCH /api/assets/:id {metadata} — include ingestion_source: "portfolio-onboarding", batch, setup_complete: false, num_buildings,
archetype, regulatory_driver, energy (with source + units), equipment_survey,
and confidence notes.
PATCH /api/assets/:id also accepts (since soapbox-api 962ab29, 2026-07-02):
street_address, city, state_province, postal_zip_code, country, property_type, gross_floor_area_m2 (ft² × 0.09290304), year_built, num_floors, tags (tag with
portfolio-onboarding + <client>-batch-N). No direct SQL needed for asset
fields — everything goes through the API.
Stage 3 — Audette linking + grouping
- Install the connector: copy the freshest
plugin_audette row in
asset_connectors (any portfolio; tokens are user-scoped and work across
portfolios; the connector proxy refreshes them) into a new row for this
portfolio.
GET /api/portfolios/audette/accounts → find the client account uid →
PATCH /api/portfolios {audette_account_id} (clears the properties cache).
POST /api/portfolios/audette/properties/refresh → per-building rows with
property_uid, property_name, building_uid, building_name.
- Match each asset by name (exact/fuzzy;
portfolio_match.py for ambiguous):
- Grouped (has
property_uid) → PATCH /api/assets/:id {audette_property_id}.
- Ungrouped (buildings match by name but
property_uid is null — common;
half of Cortland): collect the building uids, then call the Audette MCP
directly (https://mcp-server.prod.audette.io/mcp, bearer = connector
api_key; JSON-RPC tools/call):
switch_customer_account {customer_account_uid} then
create_property_for_building {building_model_uids: [...], property_name} →
returns property_uid → PATCH the asset. Also assign_property_to_building
exists for adding buildings to an existing property. Store the building uids
in metadata.audette_building_uids regardless (analysis tools take
building_model_uid).
- No match → leave null; the asset card shows no Audette badge; record as
follow-up.
- Re-run
.../audette/properties/refresh at the end so the cache reflects new
groupings.
Stage 4 — ESPM linking (always optional, never blocking)
- Copy a
plugin_energy_star connector row (shared ESPM account) to the portfolio.
POST /api/portfolios/espm/properties/refresh (~2,300 properties; cached on the
portfolio).
- Match by name against
property_name (watch for suffixed codes like
"(tn97)" and typo'd cities). PATCH /api/assets/:id {espm_property_id}.
- Expect true misses (Cortland Belmar). Record them in the completion report as
"needs ESPM property creation or client confirmation" — do not block.
Stage 5 — Upload + index documents
Routing: per-asset docs → POST /api/assets/:id/files (multipart file +
folder); portfolio-wide reference/helper files → POST /api/portfolios/files.
Folder by type: Audits (energy/water audit reports), PCAs (PCAs, PCRs,
surveys), Reference Data. Indexing is queued automatically for indexable MIMEs.
Hard-won mechanics (all hit on Cortland):
- 50MB API cap → split bigger PDFs with PyMuPDF (
insert_pdf page ranges,
"(Part N of M)" names). Lossless split beats lossy compression for source docs.
- curl
-F breaks on commas in filenames → always quote: -F 'file=@"<path>"; type=<mime>' (returns empty-body failure otherwise).
- Portfolio files endpoint 500s on non-ASCII filenames (em-dash) → rename to
ASCII first. It also 500s on
text/markdown / application/json MIME in prod →
send md/json/txt as text/plain (still indexes).
- Verify indexing: poll
files.indexing_status until all indexed. On
failed, check Railway deploy logs (project soapbox-platform, service
soapbox-api, deploys from main) and retry with
POST /api/assets/:id/files/:fileId/reindex. Known fixed bug: PDFs with NUL
bytes in extracted text (fixed ac77a12).
- Count embeddings chunks at the end as a sanity check (
embeddings table by
asset/portfolio id).
Stage 6 — Verify + report
- SQL rollup: assets / audette_linked / espm_linked / files / indexed / chunks.
- Optional but recommended: Playwright pass on
https://app.soapbox.build (service
account login → workspace switcher → client org) to confirm the portfolio
renders with Audette/ESPM badges; screenshot for the report.
- Completion report to the user: totals, per-asset link status, misses +
follow-ups (ESPM gaps, unmatched docs, missing financial params), and what was
NOT done (address mapping, LL/TT params if not collected).
metadata.setup_complete stays false until the client verifies helper data;
LL/TT allocation inputs (lease structure, metering config, jurisdiction) are
collected in the review pass per the 2026-06-27 design spec when running the
full conversational flow.
Idempotency
Before creating anything, check for an existing org/portfolio by name and existing
assets by name + client tag. Resume from the first incomplete step; never
re-upload a file whose name already exists under the same asset; never overwrite
existing audette/espm links.
1---2name: portfolio-ingest3description: Onboard a client portfolio into app.soapbox.build end-to-end: create the org + portfolio, bulk-create assets from a register or helper-data drop, link Audette (including grouping ungrouped buildings into properties) and ESPM, and upload + index all source documents (PCAs, audits, surveys, reference data). Headless, agent-driven counterpart to the frontend "Add assets" wizard — use it for zip/ Taildrop batch drops, new-client onboarding, or any run too big or too scripted for the UI. Spec 1 of 2 — portfolio-analysis (Spec 2) runs after this. Triggers on: "onboard [client]", "create a soapbox account for", "portfolio onboarding workflow", "ingest the [client] portfolio", "run portfolio ingestion", "set up [client] in soapbox", a batch/zip of PCAs+audits with an asset register.4---56# Portfolio Ingest78Proven end-to-end on **Cortland Batch 1 (2026-07-02)**: 1 org, 10 assets, 10 Audette9links (5 required property grouping), 9/10 ESPM links, 30/30 documents indexed10(7,406 chunks). Follow the stages in order — ordering constraints are real.1112## Relationship to the frontend onboarding wizard1314The platform UI has an interactive flow (`AssetOnboardingModal` → `AssetReviewTable`15→ `AssetCreationRunner` in platform-web) that does drag-drop → LLM extraction →16geocode/footprint → fuzzy-match review → create+link+upload. **This skill and the17wizard call the same soapbox-api endpoints and must stay convention-compatible:**1819- Same endpoints: `POST /api/assets`, `PATCH /api/assets/:id`,20 `GET/POST /api/portfolios/audette|espm/properties[/refresh]`,21 `POST /api/assets/:id/files`, `POST /api/portfolios/files`.22- Same auto-match threshold: **≥ 0.85** auto-link, 0.40–0.85 review, < 0.40 no match.23- Same metadata convention: `metadata.setup_complete: false` +24 `metadata.ingestion_source` — the wizard writes `"asset-onboarding"`, this skill25 writes `"portfolio-onboarding"`. Both are recognized downstream; keep the26 distinction so runs are attributable.27- Division of labor: the wizard assumes the org/portfolio already exist and works28 asset-by-asset with a human reviewing matches. This skill additionally creates the29 org/portfolio/members, does Audette **property grouping** (the wizard can only30 link or open a "Create in Audette" thread), handles portfolio-level reference31 files, splits oversized PDFs, retries failed indexing, and enriches asset32 metadata from helper data. If the user is present and wants to eyeball matches,33 you can stop after Stage 2 and hand them to the UI ("Add assets") instead.34- Footprint mapping ("📍 Map" chip — Overture footprint queue) is the wizard's specialty; skip35 it here unless asked.36- **BUT backfill the postal-address fields (`street_address, city, state_province, postal_zip_code,37 country`) when you link Audette.** Audette's building model (`get_building_model_details`) carries38 the full address for every property, and you are already pulling that model to link the asset —39 copy the address into those columns in the same asset-update call. Do NOT leave them null (the40 Greystar ingest did: 37/39 had lat/lon from geocoding but 0/39 had street/city/state, so every41 downstream run — BPS applicability, CRREM region, RUBS/VNM legislation, physrisk, brave lookups —42 had to re-pull addresses from Audette). `state_province` especially gates the jurisdiction checks.43 If Audette is unavailable, reverse-geocode from lat/lon (Overture) as a fallback and label `(est.)`.4445## Prerequisites4647- Service account: Vaultwarden item `Soapbox Service Account48 (claude@agents.soapbox.build)` (`vw get password "..."`). Sign in via Supabase49 auth REST (`POST {SUPABASE_URL}/auth/v1/token?grant_type=password` with the anon50 key from `~/platform-web/.env.local`). Tokens last 1h — re-auth before long stages.51- API: `https://soapbox-api-production.up.railway.app`; every call needs52 `Authorization: Bearer <token>` + `x-organization-id: <org uuid>` (tenant53 middleware resolves the portfolio from the org).54- Supabase project `fplbvanvwvnviczozwhz` (direct SQL via Supabase MCP) for the55 steps with no API endpoint: org + member creation, connector-row copies,56 metadata jsonb merges, and verification rollups. All asset fields go through57 the API.58- Scripts (already built, `scripts/`): `portfolio_match.py` (fuzzy matching),59 `document_classifier.py` (doc typing), `ll_allocation.py` (LL/TT tree). Use them60 when name matches aren't obvious; skip when filenames are unambiguous.6162## Stage 0 — Source discovery6364Accept any of: zip/Taildrop drop, Google Drive/Box folder, typed register. Extract65zips excluding `__MACOSX/` and `.DS_Store`. **Dedupe by sha256** — client drops66routinely duplicate files across folders (Cortland: 31 of 60 were dupes). Prefer67the copy inside a typed subfolder (`PCAs/`, `Audits/`) as canonical.6869Build the asset register: `{name, address, city, state, zip, gfa, year_built,70floors, units}` per asset from helper data / spreadsheet / audits. Preserve any71confidence tags — mark everything not client-verified as such in metadata.7273## Stage 1 — Org + portfolio + members74751. SQL: `insert into organizations (name) values ('<Client>')`; add76 `organization_members` rows for christopher@soapbox.build77 (`f243660a-6991-4f7a-97f4-fefce9e24873`) and claude@agents.soapbox.build78 (`63025178-e0ac-4018-910d-55b876e441db`), role `owner`.792. API: `POST /api/portfolios {name, organization_id}` **as the service account** —80 this provisions the Stripe customer, core plugins (plugin_catalog auto-81 provision), and the caller's portfolio membership. Do NOT insert the portfolio82 by SQL.833. SQL: add christopher as portfolio_members `admin` (the API only adds the caller).8485## Stage 2 — Create assets (BEFORE installing the Audette connector)8687**Ordering constraint:** `POST /api/assets` fires `triggerAudettePropertyCreate`,88which auto-creates Audette properties **if** `plugin_audette` is installed on the89portfolio. Creating assets first (no connector yet) makes the pipeline skip90(`audette_pipeline_state: skipped_no_plugin`) and prevents duplicate Audette91properties when records already exist.9293Per asset:94- `POST /api/assets {name, lat?, lon?, num_floors?}`.95- `PATCH /api/assets/:id {metadata}` — include `ingestion_source:96 "portfolio-onboarding"`, `batch`, `setup_complete: false`, num_buildings,97 archetype, regulatory_driver, energy (with source + units), equipment_survey,98 and confidence notes.99- `PATCH /api/assets/:id` also accepts (since soapbox-api `962ab29`, 2026-07-02):100 `street_address, city, state_province, postal_zip_code, country, property_type,101 gross_floor_area_m2 (ft² × 0.09290304), year_built, num_floors, tags` (tag with102 `portfolio-onboarding` + `<client>-batch-N`). No direct SQL needed for asset103 fields — everything goes through the API.104105## Stage 3 — Audette linking + grouping1061071. Install the connector: copy the freshest `plugin_audette` row in108 `asset_connectors` (any portfolio; tokens are user-scoped and work across109 portfolios; the connector proxy refreshes them) into a new row for this110 portfolio.1112. `GET /api/portfolios/audette/accounts` → find the client account uid →112 `PATCH /api/portfolios {audette_account_id}` (clears the properties cache).1133. `POST /api/portfolios/audette/properties/refresh` → per-building rows with114 `property_uid`, `property_name`, `building_uid`, `building_name`.1154. Match each asset by name (exact/fuzzy; `portfolio_match.py` for ambiguous):116 - **Grouped** (has `property_uid`) → `PATCH /api/assets/:id117 {audette_property_id}`.118 - **Ungrouped** (buildings match by name but `property_uid` is null — common;119 half of Cortland): collect the building uids, then call the Audette MCP120 directly (`https://mcp-server.prod.audette.io/mcp`, bearer = connector121 `api_key`; JSON-RPC `tools/call`):122 `switch_customer_account {customer_account_uid}` then123 `create_property_for_building {building_model_uids: [...], property_name}` →124 returns `property_uid` → PATCH the asset. Also `assign_property_to_building`125 exists for adding buildings to an existing property. Store the building uids126 in `metadata.audette_building_uids` regardless (analysis tools take127 building_model_uid).128 - **No match** → leave null; the asset card shows no Audette badge; record as129 follow-up.1305. Re-run `.../audette/properties/refresh` at the end so the cache reflects new131 groupings.132133## Stage 4 — ESPM linking (always optional, never blocking)1341351. Copy a `plugin_energy_star` connector row (shared ESPM account) to the portfolio.1362. `POST /api/portfolios/espm/properties/refresh` (~2,300 properties; cached on the137 portfolio).1383. Match by name against `property_name` (watch for suffixed codes like139 "(tn97)" and typo'd cities). `PATCH /api/assets/:id {espm_property_id}`.1404. Expect true misses (Cortland Belmar). Record them in the completion report as141 "needs ESPM property creation or client confirmation" — do not block.142143## Stage 5 — Upload + index documents144145Routing: per-asset docs → `POST /api/assets/:id/files` (multipart `file` +146`folder`); portfolio-wide reference/helper files → `POST /api/portfolios/files`.147Folder by type: `Audits` (energy/water audit reports), `PCAs` (PCAs, PCRs,148surveys), `Reference Data`. Indexing is queued automatically for indexable MIMEs.149150Hard-won mechanics (all hit on Cortland):151- **50MB API cap** → split bigger PDFs with PyMuPDF (`insert_pdf` page ranges,152 "(Part N of M)" names). Lossless split beats lossy compression for source docs.153- **curl `-F` breaks on commas in filenames** → always quote: `-F 'file=@"<path>";154 type=<mime>'` (returns empty-body failure otherwise).155- **Portfolio files endpoint 500s on non-ASCII filenames** (em-dash) → rename to156 ASCII first. It also 500s on `text/markdown` / `application/json` MIME in prod →157 send md/json/txt as `text/plain` (still indexes).158- **Verify indexing**: poll `files.indexing_status` until all `indexed`. On159 `failed`, check Railway deploy logs (project `soapbox-platform`, service160 `soapbox-api`, deploys from **main**) and retry with161 `POST /api/assets/:id/files/:fileId/reindex`. Known fixed bug: PDFs with NUL162 bytes in extracted text (fixed `ac77a12`).163- Count embeddings chunks at the end as a sanity check (`embeddings` table by164 asset/portfolio id).165166## Stage 6 — Verify + report167168- SQL rollup: assets / audette_linked / espm_linked / files / indexed / chunks.169- Optional but recommended: Playwright pass on `https://app.soapbox.build` (service170 account login → workspace switcher → client org) to confirm the portfolio171 renders with Audette/ESPM badges; screenshot for the report.172- Completion report to the user: totals, per-asset link status, misses +173 follow-ups (ESPM gaps, unmatched docs, missing financial params), and what was174 NOT done (address mapping, LL/TT params if not collected).175- `metadata.setup_complete` stays `false` until the client verifies helper data;176 LL/TT allocation inputs (lease structure, metering config, jurisdiction) are177 collected in the review pass per the 2026-06-27 design spec when running the178 full conversational flow.179180## Idempotency181182Before creating anything, check for an existing org/portfolio by name and existing183assets by name + client tag. Resume from the first incomplete step; never184re-upload a file whose name already exists under the same asset; never overwrite185existing audette/espm links.