[PATTERN carta-writing-style v0.0.2] [PATTERN etiquette v0.0.6] [PATTERN text v0.0.8] [PATTERN tables v0.0.12] [PATTERN carta-watermark v0.0.10]
Fund Modeling (firm-level React console)
Builds a firm's baseline from Carta Fund Admin data, writes it to a local data dir in the
fund-modeling console JSON schema, and launches the prebuilt React app via serve.py. The browser
never calls the Carta MCP — this skill fetches the data; the server only serves JSON + the built app. The
repricing/waterfall/IRR is a transparent estimate (the ported model/), not Carta's official engine.
Runs locally only. Because it starts a localhost web server and opens your browser, this skill works only in a Claude Code session running on your machine (a local terminal, or Claude Desktop set to run locally). It can't run in a sandboxed session — Cowork, or a Claude Code cloud session — where that server is unreachable and no local browser exists. Gate 0 stops early and tells the user to switch.
No demo data — real firm required
Never fabricate, synthesize, sample, or fall back to demo/placeholder data, and never launch against an empty or partial data dir. Every dashboard runs against one real Carta firm's Fund Admin data — either fetched fresh or served from a prior local fetch (cache). If the invocation includes no firm (name or URL), do not auto-pick, list, or guess — stop and ask the user to name a firm, then proceed only once they answer. A missing or unresolved firm is a graceful exit, not a reason to invent data.
Launch order — cache-first, MCP-lazy
Building a dashboard needs the Carta MCP; launching a warm cache does not. Resolve the firm name and check the local cache before touching any MCP — a fresh cache launches with no MCP call. Only a build/refresh (Step 1 onward) identifies the MCP and resolves the firm over it.
Gate 0 — Surface check (run first, before anything else)
Step 4 launches serve.py, which binds 127.0.0.1 and opens the user's default browser. That only works when
Claude Code runs on the user's own machine (a local terminal, or Claude Desktop set to run locally). In a
sandboxed session — Cowork, or a Claude Code cloud session (Claude Desktop can run sessions in the
cloud, which is the default) — the server runs in a remote container the user can't reach and there is no local
browser to open, so the dashboard URL goes nowhere. The skill must not run there.
Before Step 0 — before any cache scan, MCP call, or greeting — run this once and route on it:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" detect-surface
Output contains
surface=sandboxed→ stop immediately. Do not scan caches, resolve a firm, touch the MCP, or launchserve.py. Reply with this message (substance verbatim), then end the turn:Fund Modeling launches an interactive web app on your own machine — a local server plus your browser — so it only works in a Claude Code session running locally. It can't run in a sandboxed session: Cowork, or a Claude Code cloud session (in Claude Desktop, running in the cloud is the default — switch it to run locally). Please re-run from a local session, e.g. "fund modeling for <firm>".
This is a graceful exit. Do not retry
detect-surface, do not try to launch anyway, and do not fall back to another surface or tool — a sandboxed verdict will not change on retry.Otherwise (
surface=local, the normal case) → continue to Step 0 silently. Say nothing about this check — it stays quiet, like the rest of Step 0.
Step 0 — Resolve identity + check the local cache
Firm typed? ──Yes──► Cache hit? ──Yes──► Fresh (<30d)? ──Yes──► Launch (Step 4)
│ │ │
│ │ No──► Offer: Use cached / Re-fetch
│ │
│ No──► Suggestions? ──Yes──► Did-you-mean picker
│ │
│ No──► List all local caches → picker / Build fresh
│
No──► Local caches exist? ──Yes──► Picker (resume where you left off)
│
No──► Ask user to name a firm
Everything here is a local dir scan + Read — no MCP call yet. Read what the invocation gives you and route:
Run this silently — the user's first line should be the greeting. Don't narrate the steps ("Step 0", "no MCP
yet", "resolving the firm") or echo fm_paths output — no raw field=value (snapshot_age_days=none,
slug=…) or cache paths. Cache age is fine in words ("3 days old"), not as a raw field.
A pasted Carta firm URL / UUID → identity lookup. Parse the id locally (the firm id from a
…/investors/firm/<id> URL, or a bare firm UUID) and match it against your caches:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" find-by-id "<parsed_id>"
match=<slug> (+ name, dashboard_dir, snapshot_age_days) → a hit; greet and go to the cache-age branch.
match=none → BUILD (Step 1), carrying the parsed id/URL to the MCP resolve.
A firm NAME → cache check. Slugify the typed name and look for a matching cache (pass the name to the script — do not slugify it yourself). Caches are keyed by each firm's canonical name (Step 1), so an exact hit means the typed name already matches that firm's canonical name:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" resolve "<firm name>"
It is read-only — it prints slug=…, cache_root=…, raw_dir=…, dashboard_dir=…, and
snapshot_age_days=<N|none> without creating any dir. On a hit, use the printed dashboard_dir verbatim
for the launch — never recompute a cache path in the shell. (The build paths come from Step 1's canonical
resolve, not here.)
snapshot_age_days=<N>(cache hit) → greet, then the cache-age branch — a silent fast launch on fresh, no picker.snapshot_age_days=none(no hit) withsuggested_match=<slug>lines (eachname,age_days,dashboard_dir) → did-you-mean picker:AskUserQuestionoffering each row (labelname+age_days) plus "Build fresh from Carta ''". Reopen → greet + cache-age branch with that row'sdashboard_dirage_days; Build fresh → BUILD (Step 1).
snapshot_age_days=nonewith nosuggested_matchlines → check for other local caches below.
No firm in the invocation → check for other local caches below (resume where you left off).
Check for other local caches
A miss on the typed name doesn't mean there's nothing cached — the user may have typed a variant of a firm already built, or nothing at all. Scan once:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" list-dashboards
dashboards=none(no caches at all) → if the invocation supplied a firm (name typed, or URL/UUID pasted), go straight to BUILD (Step 1) — this is a clean first build, no picker. If no firm was supplied, hard-stop: ask the user (viaAskUserQuestion) for a firm name or Carta firm URL and stop until they answer.- caches exist, and a firm was typed → decide by relevance, do not list unrelated caches. Judge whether
any cached
nameis plausibly the same firm the user typed (a variant/abbreviation/legal-suffix difference — e.g. "Demo Capital" ↔ "Demo Capital Partners LP"; but "Acme Ventures" is not a match for a cached "Demo Capital Partners LP"):- a plausible match → show the did-you-mean picker: an
AskUserQuestionoffering only the matching cache(s) to reopen (label withname+age_days) plus "Build fresh from Carta ''". Reopen → greet + cache-age branch using that row's printeddashboard_dir+age_days; Build fresh → BUILD (Step 1). - no plausible match → the user named a distinct firm; go straight to BUILD (Step 1), no picker. You may add one non-blocking aside naming the other cached dashboards ("You also have N cached — say 'open ' to view one instead"), but do not turn it into a prompt.
- a plausible match → show the did-you-mean picker: an
- caches exist, no firm typed → resume:
AskUserQuestionlisting the local dashboards to reopen (name+age_days, up to ~3; Other covers the rest). Reopen → greet + cache-age branch using that row's printeddashboard_dir+age_days; Other / a new name → BUILD (Step 1).
Never fabricate or auto-pick a Carta firm. Offering the user's own local caches for a pick is allowed; never auto-launch one without a pick, and never invent or guess a Carta firm.
Greet the user (single message). When a firm/cache is settled (exact match, picked cache, or a firm to
build), present the welcome below as one message, then proceed. Show it on first use even if the user
immediately entered a task — do not suppress it. For <Firm Name>: on a picker or find-by-id hit use the
printed cached name (the canonical snapshot.source.firm); on a name-resolve hit the typed name is fine
(it is slug-equal to the canonical); when building, the typed name (Step 1's canonical name once resolved).
Welcome to Carta Fund Modeling. This skill builds a React app that lets you run scenarios on portfolio companies and evaluate returns. You'll start from a Baseline scenario representing the valuation marks Carta currently holds on your companies, and can build new scenarios to change company valuations and see the impact on firm- and fund-level performance metrics.
Here's how it works:
- Pull your fund holdings, valuations, and cash flows from Carta
- Build a local snapshot on your machine
- Launch an interactive dashboard in your browser
Then append one cache-status sentence (substitute the real firm name for <Firm Name>):
- a cache exists (exact match or picked cache): read
source.navAsOffrom the cached\<dashboard_dir\>/snapshot.jsonand state the data recency alongside the cache age — e.g. "Since a cache for <Firm Name> already exists locally, this should be quick — let me reload your dashboard. It reflects Carta's marks as of <MMM d, yyyy>, pulled <N> days ago." FormatnavAsOffor display as MMM d, yyyy (the stored value is ISO);\<N\>is the cache age already in hand. IfnavAsOfis missing, drop the "as of" clause and keep just the cache age. - no cache (building): "It looks like this is the first time you're running this skill on this firm. Let me take a few minutes to pull the latest data and build the infrastructure to customize the app for this firm."
Cache-age branch (for a resolved cache):
snapshot_age_days < 30(fresh) → skip the build entirely, go straight to Step 4 (Launch) — it serves the cached snapshot and makes no MCP call.snapshot_age_days ≥ 30(stale) → ask viaAskUserQuestion: "Cached data for <Firm Name> is <N> days old. Use it or re-fetch from Carta?" Options: "Use cached" / "Re-fetch". On "Use cached" → Step 4; on "Re-fetch" → BUILD (Step 1).
Authorization on cache launch: a user who lost firm access can still view the local cached snapshot (data they already exported to disk). Accepted, signed-off risk — a cache launch never re-touches Carta; any refresh goes through live MCP auth, which is the natural re-check.
Step 1 — BUILD: identify the Carta MCP + resolve the firm (only when building)
Reached only on a build/refresh (cache miss, stale re-fetch, or an explicit "Refresh Carta holdings"). A warm-cache launch skips this step entirely — no MCP.
Identify the Carta MCP server. Scan the tools available in the conversation for any matching mcp__*__welcome. Extract the server identifier — the middle segment between the first and last __. Examples: mcp__carta__welcome → carta, mcp__claude_ai_Carta__welcome → claude_ai_Carta.
If none found: stop and tell the user (do not fabricate data):
"No Carta MCP is connected. Building/refreshing needs one — connect a Carta MCP (the carta-investors plugin provides it). Your cached dashboards still open without it." If exactly one found: call
mcp__<SERVER>__welcometo verify. This is<SERVER>. If multiple found: ask the user which to use viaAskUserQuestion. Default tocarta(production) if present. Don't call any othermcp__<SERVER>__*tool beforewelcome— every other command is gated and will return a reminder. Fund Admin only — neverfund_forecasting:*.
Classify the environment from <SERVER>'s name. A name containing test/sandbox/demo/preprod/
preproduction (case-insensitive) → cartaEnvironment = "nonprod". Everything else — carta,
carta_production, any other name, or an opaque UUID (some connectors expose one instead of a name, per
carta-home-build/SKILL.md's Step 0) — → "production". This is a customer-facing plugin, so the common
case by volume is real production usage; an unrecognized identifier is far more likely to be a production
connector we haven't named yet than a staff test session, and staff noise is filterable downstream (the
server already knows is_staff per request). Carry cartaEnvironment to Step 3's meta.json.
Resolve the firm via list_contexts. Call list_contexts {firm_name: "<typed firm name>"} — always pass
the typed name; never call it bare (bare can return an already-active firm instead of the one asked for).
The result is one firm per line; don't rely on exact punctuation (the UUID may be in [...] or (...), an
active firm suffixed (active)). Per line: firm name = leading text, firm_uuid = the hex UUID token.
- One firm → use it.
- Multiple → match the typed name (case-insensitive); single match → proceed, else
AskUserQuestionto pick. Matching can be fuzzy, so confirm on any ambiguity. - Zero → tell the user no firm matched and ask them to re-enter — don't fall back to anything.
set_context {firm_id: <firm_uuid>} with the chosen UUID. carta_id (integer firm ID) is optional — when a line
carries a #<digits> token, capture it as firmId, else set "firmId": null. All DWH queries use firm_uuid.
Key the cache on the canonical firm name. Resolve the build paths from the canonical name (not the
typed name):
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" resolve "<canonical name>"
Use its printed slug/raw_dir/dashboard_dir as the build target (Steps 2–3 write there). Because every
build of a firm keys on its canonical name, all invocations — any typed variant, a pasted URL, a re-fetch —
land on the same directory: a rebuild refreshes that one cache in place, and a firm can never spawn a
duplicate. (When the typed name already equals the canonical name, this slug matches Step 0's — a plain
refresh.) Persist carta_id/firm_uuid as firmId/firmUuid in the snapshot (Step 3) so a later URL/UUID
invocation finds this cache via find-by-id (Step 0) without a fetch.
Tell the user: "✅ Resolved . Starting data fetch..." — this is the only checkpoint between Step 1 and Step 2 the user sees before the (potentially multi-wave, multi-minute) fetch below begins.
Step 2 — Fetch the baseline (Fund Admin) → raw query files
What's happening: Fetching the firm's fund holdings, partner data, valuations, and financials from Carta's data warehouse in parallel waves. Results land as raw JSON files in the local cache — nothing is sent back to Carta.
Preflight, before issuing any query: confirm the Carta MCP connection is live (Step 1's welcome call
already did this), the cache dir is writable (fm_paths.py resolve from Step 0/1 already created it), and the
firm UUID is resolved (Step 1's set_context). All three are already true by the time this step starts — no
extra call needed — but if the firm resolution above ended in a zero firms matched/ambiguous state, do not
proceed into the fetch; surface that to the user first.
Read ${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/references/queries.md. Substitute the Step-1 canonical
raw_dir for <raw_dir> in every command below. The dir is created on demand by the first writer that
touches it (the Write tool, save_query_result.py, or touch-empty); there is no shell CACHE/RAW variable
to set.
Enumerate the firm's entities with the compact DWH directory query in queries.md §0 (a firm-scoped
MONTHLY_NAV_CALCULATIONS SELECT) — not the fund-admin entity-list command, which returns verbose per-entity objects and
exceeds the MCP 40k-char limit on large firms (a firm with ~100+ SPVs breaks it). The §0 query excludes SPVs
(entity_type_name NOT ILIKE '%SPV%') — single-deal SPVs are out of scope and are what blow the limit — so it
stays tiny and returns only Fund/GP entities. Then write that query's fund_uuid column to
<raw_dir>/fund_uuids.txt (one uuid per line) with the Write tool — this is the only value you extract by hand;
because SPVs are already filtered out of the directory, no SPV is ever fetched. From here the queries
are generated deterministically: do NOT hand-write SQL or paste an IN-list. Get every stem's ready-to-run
query from the emitter, which fills the fund_uuid / corporation_id IN-list from the manifest
(scripts/stem_queries.py, the source of truth for stem SQL).
GP carry opt-in check (before Wave 1)
gp_carry contains per-member names — run this check after writing fund_uuids.txt and before emitting Wave 1:
1. Probe for data access. Emit the gp_carry query and run it with limit: 1 to check both permission and data presence:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/emit_stem_sql.py" --raw "<raw_dir>" --stem gp_carry
Run the resulting SQL via dwh__execute__query with limit: 1 (override the emitted limit in the tool call).
2a. Query fails (e.g. Error in secure object) or returns 0 rows:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" touch-empty "<raw_dir>/gp_carry.ndjson"
Tell the user: "GP partner carry data is not accessible for this firm — skipping." Then add --skip gp_carry to the Wave 1 emit command (step 3 below).
2b. Query returns rows — ask via AskUserQuestion:
"This firm has GP partner-level carry data (per-member names, accrued carry, carry shares). Include it in the GP Economics tab?" Options: "Yes, include it" / "No, skip it"
- "Yes" → include
gp_carryin Wave 1 (omit--skip). - "No" → touch-empty
<raw_dir>/gp_carry.ndjson(same command as 2a) and add--skip gp_carryto the Wave 1 emit.
3. Emit the fetch batches with --skip gp_carry when the user opted out or data was not accessible:
# Default (opted in):
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/emit_stem_sql.py" --raw "<raw_dir>" --batch
# Opted out or not accessible:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/emit_stem_sql.py" --raw "<raw_dir>" --batch --skip gp_carry
It prints a JSON list of batches, each {batch, format:"ndjson", limit:10000, stems:[...], queries:[...]} —
at most 10 queries per batch (the dwh:execute:queries cap), with stems[i] aligned to queries[i]. Every stem
is fund-scoped and independent, so fetch each batch with one parallel dwh:execute:queries call — do NOT fetch
stems one at a time. Serial per-stem fetching is the single biggest reason a first build is slow: each stem
costs a full model turn whose reasoning dwarfs the query itself. Issue all batches' calls together (in one
message) so the two batches don't serialize needlessly.
For each batch, two mechanical moves:
- Issue the whole batch in one call:
Passcall_tool({"name":"dwh__execute__queries","arguments":{"queries": <batch.queries>, "limit": 10000, "format": "ndjson"}})limit:10000andformat:"ndjson"explicitly — the command defaults tolimit:1000/format:markdown, both wrong for us. The queries run in parallel server-side and return a positional JSON array, one element per query ({index, total_rows, result}or{index, error}). - Capture the whole batch into per-stem files via the batch helper — never hand-split, hand-decode, or
hand-author ndjson. Two cases, same shapes as a single query:
- Large result → the harness persisted it and prints the absolute path in its result message ("Output has
been saved to …"; the location is client/config-dependent — read it from the message, don't reconstruct it).
Pass that printed path directly. The helper unwraps that envelope itself (the positional array is a base64
blob behind an embedded JSON string) — do not hunt for the separate
*-blob-*.jsonfile the harness also drops:uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_batch_result.py" <result_path> "<raw_dir>" --stems <comma-joined batch.stems> - Small INLINE result (returned in the tool response, no file) → Write the raw tool result verbatim to
<raw_dir>/batch<N>.raw, then pass that file:uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_batch_result.py" "<raw_dir>/batch<N>.raw" "<raw_dir>" --stems <comma-joined batch.stems>
save_batch_result.pysplits the positional array by--stemsorder and writes clean<stem>.ndjsonper query — reusing the same deterministic normalization as the single-query path (inline markdown/pipe table, base64resourceblob, the harness{"result": "<ndjson>"}wrapper, or the persisted tool-result.txtenvelope — a[pointer, base64-blob]list). It writes an empty file for a stem that returned 0 rows or{index,error}(so the contract's "the file must exist" holds), and prints per-stem status. If it can't split the response intolen(stems)slices it exits 2 and writes nothing — run it once with--dump-shapeto inspect the envelope, then use the fallback below. - Large result → the harness persisted it and prints the absolute path in its result message ("Output has
been saved to …"; the location is client/config-dependent — read it from the message, don't reconstruct it).
Pass that printed path directly. The helper unwraps that envelope itself (the positional array is a base64
blob behind an embedded JSON string) — do not hunt for the separate
- Read the helper's per-stem output:
save_batch_result: <stem> N row(s)— captured.save_batch_result: <stem> 0 rows (empty file)— genuinely empty (or a failed query). Fine unless it's a rows-required stem (nav_latest,investments), in which case re-fetch that stem singly (fallback below).ERROR stem=<stem>: <msg>— that query failed inside the batch; re-run it as a singledwh__execute__queryto surface the error, then capture withsave_query_result.py.TRUNCATED stem=<stem> next_offset=<N>— that stem is incomplete; paginate it (below).build_datadir.pyrefuses to build while any<stem>.ndjson.truncatedmarker exists, so this is not skippable. Do not treat aTRUNCATEDline as success.
Fallback — per-stem serial fetch. If dwh__execute__queries is unavailable (Unknown tool / NotFoundError
on an older MCP) or save_batch_result.py can't split the envelope, fall back to fetching each stem singly:
emit_stem_sql.py --stem <name> → call_tool({"name":"dwh__execute__query","arguments": <that {sql,limit,format}>})
→ save_query_result.py <result_path> "<raw_dir>/<stem>.ndjson". Same pagination and contract rules apply. This
is the pre-batch path; it is correct but slower (one serial round-trip per stem).
Pagination — when you see the TRUNCATED sentinel (from either the batch helper's TRUNCATED stem=<stem>
line or a single fetch). Page that one stem via the single-query tool — re-run its query
(emit_stem_sql.py --stem <stem> if you no longer have it) with offset set to the reported next_offset, then
capture it with --append:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_query_result.py" <result_path> "<raw_dir>/<stem>.ndjson" --append
Repeat until the TRUNCATED line stops appearing — the helper clears the marker itself on the final page.
offset is a dwh__execute__query argument, exactly like limit; do not put OFFSET in the SQL.
You get at most 5 pages per stem (50,000 rows). If a 6th page is still reporting next_offset, STOP and
report needs_human: <stem> exceeds 50,000 rows — the fund-modeling schema does not expect a stem this large.
Do not: raise limit above 10,000 (the server clamps it, so this changes nothing and silently re-truncates);
delete the .truncated marker by hand; pass --no-strict to the builder; or narrow the query's fund_uuid /
date range to duck under the cap. Each of those turns a loud, fixable truncation back into the silent wrong-data
bug this gate exists to catch.
Capture inline results immediately — do not defer. For every INLINE stem, do the Write-to-.raw step
in the same turn the result comes back, before issuing the next tool call. A long first build can trigger
context compaction mid-fetch; anything still sitting only in conversation history (not yet written to
<raw_dir>) is lost when that happens, forcing a re-fetch from Carta. Writing to disk immediately makes each
stem durable the moment it lands, regardless of what happens to the conversation afterward. If you ever resume
a build and an inline stem's data is no longer visible in context, treat it as never fetched — re-run its
query and capture it via the helper. Never reconstruct rows from partial memory of an earlier result; a
hand-reconstructed file is exactly the "0 funds / 0 companies" / silently-truncated-stem failure mode above.
Fetch the whole manifest in ONE batch — there is no second wave. Every stem is fund-scoped, so a single
emit_stem_sql.py call returns all of them and there is no ordering dependency between any two:
nav_latest, investments, cashflows, fund_metrics, accrued_carry, distributed_carry, waterfall,
cohort, deal_irr, partners, gp_partners, gp_carry (if opted in — see the GP carry opt-in check above),
ownership, financing (§11), captable (§15), corporations (§16). Issue them together, then normalize each
returned result with save_query_result.py. Also run the §14 financials query in the same batch (it takes
no fund_uuid list — it is firm-context-scoped). The whole build runs off the MCP DWH and these local helpers only.
Do not stop after the fund-level stems — financing supplies each company's last priced round, captable
populates its cap table on the dashboard, and corporations is the id bridge those enrichments (cap table AND
"Latest round" on Overview) resolve through. All three are file-required (see contract below): a missing
<stem>.ndjson hard-fails the build, so skipping them can no longer silently yield "0 cap tables" / blank
"Latest round" fields, indistinguishable from a firm that legitimately has none.
financing, captable and corporations filter by corporation, but they take only the fund_uuid list —
their corporation scope is a subquery over FUND_CORPORATION_OWNERSHIP (see queries.md §11). Never rewrite
one of them to take a pasted corporation_id IN-list: that resolves to ~1,150 UUIDs on a mid-size firm, which
is too long for a single call, so it has to be hand-chunked into several — and each chunk costs minutes of
token emission. On a 15-fund firm that one mistake cost 15 minutes for financing alone. Pass the emitter's
object through unedited and the subquery handles it in one call.
Batching the fetch instead of running one stem at a time is the main first-build speedup: it collapses ~16
model turns into one, and the per-turn reasoning — not the network wait — is what dominates a serial build.
(Correctness is unchanged: the emitted SQL is the manifest's verbatim query with the fund_uuid IN-list filled
in, and the deterministic capture is identical; only the scheduling and templating change.)
The fetch is a contract, not a checklist — every DWH stem file MUST exist
Fetching is not optional and not LLM-discretionary. build_datadir.py is the deterministic gate: it
refuses to build (exits 2) if any file-required stem's <stem>.ndjson is absent, listing what was never
fetched. You cannot launch a dashboard that skipped a stem — so do not "skip for speed," and do not decide a
firm "probably has none" and move on. Run every stem below.
The file must exist; it may be empty. When a query genuinely returns 0 rows, or fails with
Error in secure object (a role that can't read that table), record the attempt by writing an empty file —
do NOT leave the file absent:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" touch-empty "<raw_dir>/<stem>.ndjson"
An empty cohort file is what makes benchmarks read as no_coverage_published ("genuinely none") instead of
falsely blocking the build. A missing file means the query was never run — that is the exact bug this gate
exists to stop.
Stems → queries.md section. The DWH stems below are file-required — the builder rejects a build that
is missing any of their <stem>.ndjson files (write an empty file when truly none):
nav_latest(§2, rows required), investments(§3, rows required),
cashflows(§5 — the single 7-column query that feeds both LP IRR and the NAV/TVPI trend; do not run a
separate §13), fund_metrics(§1/§12), accrued_carry(§7), distributed_carry(§7 — realized "Carried interest
earned"; feeds the "Carry distributed" callout, $0→"—"), cohort(§8), deal_irr(§10), financing(§11),
partners(§9), ownership(§4), captable(§15 — a present-but-empty file is fine for firms whose portcos
aren't Carta cap-table customers, but the file itself must exist), corporations(§16 — the entity_link ->
corporation_uuid bridge that captable and financing's "Latest round" enrichment depend on; same
empty-file-OK, absent-file-fails rule).
financials(§14, portfolio-company financials via Carta Data Collection) is fetched via its own §14 query
below and is not gated by the builder. waterfall(§6, PROFIT_ALLOCATION_WATERFALL_CONFIG — real per-fund
carry / preferred return / GP catch-up), gp_carry(§7b, ALLOCATIONS GP-entity Carried interest accrued) and
gp_partners(§9, IS_GENERAL_PARTNER) are optional wave-1 stems the emitter includes automatically.
waterfall seeds real carry/hurdle/catch-up (else the flat carryRate defaults). gp_carry is the primary
feed for the GP Economics partner-carry table (real per-partner carry shares → gp-base.json); gp_partners
supplies the GP commitment (snapshot.funds[].gpCommit, summed GP-partner commitment from the DWH) and enriches
that table. None are gated — a firm with no automated waterfall / GP-entity carry / GP-partner rows just yields
empty files and those features fall back gracefully.
GP commitment ($) is fully DWH-sourced (§6). build_datadir.py derives snapshot.funds[].gpCommit
from the gp_partners stem (the GP partners' summed commitment), falling back to the GP's paid-in
(nav_latest.cumulative_gp_contributions); null only when neither exists (app shows "—"). Never back-fill a
modeled estimate (e.g. committed/99).
Company financials (optional) — §14. Portfolio-company financials (revenue / ARR / KPIs reported by the
portfolio company, Carta Data Collection) come from the base FUND_ADMIN.COMPANY_FINANCIALS table (the legacy
COMPANY_FINANCIALS_LATEST view is deprecated/empty). Run the §14 query (is_latest = TRUE AND instance_type = 'Actual'), saving the rows to <raw_dir>/financials.ndjson. COMPANY_FINANCIALS is row-scoped to the firm
you set as context via set_context in Step 1 — do NOT add a firm_id filter (redundant with the context scope, and a mismatch
silently returns zero rows); this scoping is also why the table looks "empty" if queried from another firm's
context. See queries.md §14.
All DWH reads are SELECT-only and bounded by the limit argument — never an inline LIMIT, and there is
no schema argument (see queries.md intro). Accrued carry is the REAL booked figure (ALLOCATIONS §7). Cohort
benchmarks (§8, TEMPORAL_FUND_COHORT_BENCHMARKS) are cross-firm-preaggregated on each fund's own row —
NOT firm-context-scoped like COMPANY_FINANCIALS, so do not try to widen the firm context to get more. The
newest quarter is often not-yet-benchmarked (all percentiles null), so §8 fetches a recent window and
build_datadir.py picks the latest quarter that actually has a cohort. If every recent quarter is null the
funds genuinely have no published peer cohort (build summary benchmarksReason: "no_coverage_published").
Cohort may also fail with Error in secure object for some firm roles — if so, still write an empty
cohort.ndjson (fm_paths.py touch-empty "<raw_dir>/cohort.ndjson") to record the attempt; benchmarks degrade to the empty state
(benchmarksReason: "no_coverage_published") and the build proceeds. Do not leave the file absent — a
missing cohort file is a hard build failure (the fetch gate treats it as "never run"). Scenario-focused
console: do not fetch tearsheets, schedule of investments, or cash-flow statements.
Step 2b — Company logos (optional — best-effort, never blocks the build)
What's happening: Fetching each portfolio company's real logo (if Carta has one on file) to replace the initials placeholder on the Overview activity feed.
This is not a DWH stem and has no fetch gate — a firm with no logos, or a portco-logo call that errors,
degrades silently to the initials avatar every company already falls back to. It needs no fund_uuid/
corporation_id IN-list (the tool is firm-scoped) and no DWH output, so issue its call_tool in the same
message as Wave 1's dwh__execute__queries batches rather than waiting for Wave 1 to finish first — the two
have no ordering dependency, and serializing them only adds wall-clock time to every build:
- Call the bulk list tool for the firm:
Each row carries bothcall_tool({"name": "fa__list__portco_logos", "arguments": {"firm_uuid": "<firm_uuid from Step 1>"}})corporation_id(integer) andcorporation_uuid—fetch_logos.pykeys its output filenames oncorporation_uuid, since that's whatbuild_datadir.py'sload_logos()matches against (every company object carries acorpUuid, never the integer id). The row also carries a presigned image URL; presigned URLs expire, so this result is only ever used once, immediately, to download the bytes — never store the URL itself in the app's JSON (see step 3 below for why). - Capture the result to
<raw_dir>/portco_logos.json: a small firm's list returns inline — Write it verbatim (same "capture immediately, don't defer" rule as the DWH stems in Step 2: a compaction between the call and the write loses it). A large firm's list may instead come back as a persisted-result envelope (the "Output has been saved to …" message, same shape as an oversized DWH stem) — in that case pass the printed path straight tofetch_logos.pyin step 3 instead of hand-copying/re-Writing it. - Download every image into
<raw_dir>/logos/(clearing it first, so a stale file from a prior run never lingers alongside a fresh one for the same company):
Fetches run concurrently (a small thread pool, not one round-trip at a time). Intentionally best-effort per row: a broken/expired URL, a network error, a row whose id isn't UUID-shaped (onlyuv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fetch_logos.py" \ "<raw_dir>/portco_logos.json" "<raw_dir>"corporation_uuidcan ever match a company — see step 1), an oversized download (>500KB — this is a 32px avatar, never a multi-MB asset), or a download that doesn't sniff as a real image all just skip that one company (logged to stderr) rather than failing the run.build_datadir.py'sload_logos()reads<raw_dir>/logos/and embeds each image as adata:URI on its matching company by corporation UUID — the browser never re-fetches from Carta, so an expired presigned URL after this point doesn't matter.
If fa__list__portco_logos isn't available on the connected MCP (older environments), skip this step entirely
— do not substitute fa__get__portco_logo in a per-company loop as a fallback; that's one round-trip per
company on a firm that may hold hundreds, for a cosmetic enhancement.
Step 3 — Build the data dir (deterministic — do NOT hand-write the JSON)
What's happening: Transforming the raw query files into the structured JSON the React app consumes — portfolio companies, fund metrics, LP data, and benchmarks. A script handles this deterministically; no manual JSON writing.
Write <raw_dir>/meta.json = {"name":"<canonical name>","slug":"<slug printed by Step-1 resolve>","navAsOf":"<latest month_end_date, ISO>", "mark":{"text":"<≤3-char initials>","bg":"<hex>","fg":"<hex>"},"firmId":<carta_id from Step 1, or null if absent>,"firmUuid":"<firm_uuid from Step 1>","cartaEnvironment":"<production|nonprod from Step 1>"}
(optional "carryRate", default 0.20). name and slug are both the canonical firm identity from Step 1
(slug = the canonical-name slug, the cache key; name = the canonical name), and firmId/firmUuid are
the canonical ids — the builder writes them (and cartaEnvironment) to snapshot.source so a later
URL/UUID invocation finds this cache via find-by-id without a fetch (Step 0), and serve.py can serve
them to the browser's Snowplow tracker (/api/telemetry-context). Then
run the firm-agnostic generator — it transforms the <raw_dir> files into every console-schema file the app needs:
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/build_datadir.py" \
--raw "<raw_dir>" --out "<dashboard_dir>" --meta "<raw_dir>/meta.json"
It writes firms.json, snapshot.json, portfolio.json, pacing.json, and — when the inputs exist —
company-ownership.json + lp-base.json, in the exact shapes src/model/* consume. It also embeds a
logoDataUri on any company matched against <raw_dir>/logos/ (Step 2b) — omitted entirely when that
company has no logo, in which case the app falls back to its initials avatar. In particular it emits
snapshot.source as an object ({firm,firmId,firmUuid,navAsOf,marksAsOf,marksPulledAt,currency,mixedCurrency,cartaEnvironment}); the app
runs source.navAsOf.slice(0,4), so a source written as a bare stri
…(truncated)