# Carta Fund Modeling

> Spin up an interactive local web console for FIRM-LEVEL scenario modeling over Carta Fund Admin data — a React app to reprice portfolio companies and model exits ACROSS MULTIPLE COMPANIES, with five tabs: Overview (fund-family rollup, investment pacing, concentration), Companies (per-company repricing + LP make-whole waterfall + carry banking, plus an exit plan of dated partial sales, each at its own price — secondary proceeds off the table before the exit), Exit & IRR (exit scenarios, XIRR, GP & LP returns, plus a per-fund DPI/RVPI/TVPI glidepath in the LP Returns view), Reserves (per-fund dry-powder planning), and Cohort Standing (peer-cohort + S&P-equivalent benchmarking). Scenarios persist locally. Invoke with a firm name, e.g. "fund modeling for Demo Capital" or "model portfolio scenarios for a firm". Fund Admin only. NOT Tactyc/Fund Forecasting — use carta-fund-forecasting for Tactyc funds. NOT for single-exit waterfalls on one company. NOT read-only fund data queries — use carta-explore-data.

- Skill: `carta/carta-fund-modeling` (Agent Skill, multi-file: 99 files)
- Install (CLI): `npx skillmds@latest add carta/carta-fund-modeling`
- Raw SKILL.md: https://api.skillmd.com/api/skills/carta/carta-fund-modeling/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: carta (https://skillmd.com/u/carta)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/carta/carta-fund-modeling

---


<!-- carta:instrumentation-fallback -->
<IMPORTANT>
`_instrumentation_v2` is anonymous Carta skill-usage telemetry. ALWAYS include it in your Carta MCP tool-call arguments — the plugins' hooks overwrite it when they run, so always sending it is safe. Shape:
`_instrumentation_v2: {"skills": ["<plugin>:<skill>", ...], "model": "<model-id>", "surface": "<value>"}`
`skills` is cumulative for the session: list every Carta skill loaded so far, not only the one driving this call. Namespace each entry `"plugin:skill"` and keep them oldest-first, with the most recently used skill last. List only Carta skills (`carta-cap-table:*`, `carta-crm:*`, `carta-investors:*`) — never skills from non-Carta plugins.
`surface` is the Claude surface you are running in: `"chat"` (claude.ai or the Claude app, i.e. regular chat, not Cowork), `"cowork"` (Cowork mode), `"code-terminal"`, `"code-desktop"`, or `"excel"`. Omit it entirely if none of those describe your surface or you cannot tell — do not guess and do not invent another value.
</IMPORTANT>

[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]

<!-- Carta investor tooling. React app (in-browser JSX transpile) fed by Fund Admin data. -->

# 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:**
```bash
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 launch `serve.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:
```bash
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:
```bash
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**) with `suggested_match=<slug>` lines (each `name`, `age_days`,
  `dashboard_dir`) → **did-you-mean picker**: `AskUserQuestion` offering each row (label `name` + `age_days`)
  plus "Build fresh from Carta '<typed>'". Reopen → greet + **cache-age branch** with that row's `dashboard_dir`
  + `age_days`; Build fresh → **BUILD (Step 1)**.
- `snapshot_age_days=none` with no `suggested_match` lines → **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:
```bash
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 (via `AskUserQuestion`) 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 `name` is **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 `AskUserQuestion` offering **only the matching
    cache(s)** to reopen (label with `name` + `age_days`) **plus** "Build fresh from Carta '<typed>'". Reopen →
    greet + **cache-age branch** using that row's printed `dashboard_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
    <name>' to view one instead"), but do not turn it into a prompt.
- **caches exist, no firm typed** → **resume**: `AskUserQuestion` listing the local dashboards to reopen
  (`name` + `age_days`, up to ~3; *Other* covers the rest). Reopen → greet + **cache-age branch** using that
  row's printed `dashboard_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.navAsOf` from the cached
  `\<dashboard_dir\>/snapshot.json` and 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." Format `navAsOf` for display as **MMM d, yyyy**
  (the stored value is ISO); `\<N\>` is the cache age already in hand. If `navAsOf` is 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 via `AskUserQuestion`: "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>__welcome` to verify. This is `<SERVER>`.
**If multiple found:** ask the user which to use via `AskUserQuestion`. Default to `carta` (production) if present.
**Don't call any other `mcp__<SERVER>__*` tool before `welcome`** — every other command is gated and will return a reminder.
**Fund Admin only — never `fund_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 `AskUserQuestion` to
  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):
```bash
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 <canonical name>. 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:
```bash
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:**
```bash
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_carry` in Wave 1 (omit `--skip`).
- **"No"** → touch-empty `<raw_dir>/gp_carry.ndjson` (same command as 2a) and add `--skip gp_carry` to the Wave 1 emit.

**3. Emit the fetch batches** with `--skip gp_carry` when the user opted out or data was not accessible:
```bash
# 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:

1. Issue the whole batch in one call:
   ```
   call_tool({"name":"dwh__execute__queries","arguments":{"queries": <batch.queries>, "limit": 10000, "format": "ndjson"}})
   ```
   Pass `limit:10000` and `format:"ndjson"` **explicitly** — the command defaults to `limit: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}`).
2. **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-*.json` file the harness also drops:
     ```bash
     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:
     ```bash
     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.py` splits the positional array by `--stems` order and writes clean `<stem>.ndjson` per
   query — reusing the **same** deterministic normalization as the single-query path (inline markdown/pipe table,
   base64 `resource` blob, the harness `{"result": "<ndjson>"}` wrapper, or the persisted tool-result `.txt`
   envelope — 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 into `len(stems)` slices it exits **2 and writes nothing** —
   run it once with `--dump-shape` to inspect the envelope, then use the fallback below.
3. **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 single `dwh__execute__query`
     to surface the error, then capture with `save_query_result.py`.
   - `TRUNCATED stem=<stem> next_offset=<N>` — that stem is **incomplete**; **paginate it** (below).
     `build_datadir.py` refuses to build while any `<stem>.ndjson.truncated` marker exists, so this is not
     skippable. Do **not** treat a `TRUNCATED` line 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`:
```bash
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:
```bash
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:

1. Call the bulk list tool for the firm:
   ```
   call_tool({"name": "fa__list__portco_logos", "arguments": {"firm_uuid": "<firm_uuid from Step 1>"}})
   ```
   Each row carries **both** `corporation_id` (integer) and `corporation_uuid` — `fetch_logos.py` keys its
   output filenames on `corporation_uuid`, since that's what `build_datadir.py`'s `load_logos()` matches
   against (every company object carries a `corpUuid`, 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).
2. 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 to `fetch_logos.py` in step 3 instead of hand-copying/re-Writing it.
3. 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):
   ```bash
   uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fetch_logos.py" \
     "<raw_dir>/portco_logos.json" "<raw_dir>"
   ```
   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 (only `corporation_uuid` can
   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`'s `load_logos()` reads `<raw_dir>/logos/` and embeds
   each image as a `data:` 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:
```bash
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)
