Source Ca740bfdea4afdc75e26934d
ASecuritySpin 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. Tabs: Overview (fund-family rollup, pacing, concentration), Companies (per-company repricing, LP make-whole waterfall, carry banking, dated partial sales), Exit & IRR (exit scenarios, XIRR, GP & LP returns, per-fund DPI/RVPI/TVPI glidepath), Reserves (dry-powder planning), Cohort Standing (peer-cohort + S&P...
- 2 stars
- 0 votes
- 0 copies
- 0 views
- Added November 7, 2026
Works with
Security analysis
100/100Pro scans all 20 files and shows the line behind each finding
npx -y skills add gutsofpratik/OpenClaude-Skills --skill source-ca740bfdea4afdc75e26934d --agent claude-codeAre you the author of Source Ca740bfdea4afdc75e26934d?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/gutsofpratik-source-ca740bfdea4afdc75e26934d)---
name: carta-fund-modeling
description: >
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. Tabs: Overview
(fund-family rollup, pacing, concentration), Companies (per-company repricing, LP make-whole
waterfall, carry banking, dated partial sales), Exit & IRR (exit scenarios, XIRR, GP &
LP returns, per-fund DPI/RVPI/TVPI glidepath), Reserves (dry-powder planning), Cohort Standing
(peer-cohort + S&P-equivalent benchmarking), and Fund Construction (beta: model a NEW fund step by step —
sectors, allocations, fees, recycling, waterfall, LPs — into projected TVPI/DPI/IRR). Scenarios and plans
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 for reading existing Tactyc/Fund Forecasting funds — use
carta-fund-forecasting. NOT for single-exit waterfalls on one company. NOT read-only fund data queries —
use carta-explore-data.
argument-hint: "<firm name or Carta firm URL — required>"
version: 1.0.0
model: inherit
allowed-tools:
- mcp__carta__welcome
- mcp__carta__call_tool
- mcp__carta__read_tool
- mcp__carta__set_context
- mcp__carta__list_contexts
- mcp__carta__search_tools
- mcp__claude_ai_Carta__welcome
- mcp__claude_ai_Carta__call_tool
- mcp__claude_ai_Carta__read_tool
- mcp__claude_ai_Carta__set_context
- mcp__claude_ai_Carta__list_contexts
- mcp__claude_ai_Carta__search_tools
- mcp__carta_production__welcome
- mcp__carta_production__call_tool
- mcp__carta_production__read_tool
- mcp__carta_production__set_context
- mcp__carta_production__list_contexts
- mcp__carta_production__search_tools
- mcp__2827383e-1775-4df3-b6ff-04d5392f6d18__welcome
- mcp__2827383e-1775-4df3-b6ff-04d5392f6d18__call_tool
- mcp__2827383e-1775-4df3-b6ff-04d5392f6d18__read_tool
- mcp__2827383e-1775-4df3-b6ff-04d5392f6d18__set_context
- mcp__2827383e-1775-4df3-b6ff-04d5392f6d18__list_contexts
- mcp__2827383e-1775-4df3-b6ff-04d5392f6d18__search_tools
- Read
- Write
- AskUserQuestion
- Bash(uv run ${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py *)
- Bash(uv run ${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/emit_stem_sql.py *)
- Bash(uv run ${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_query_result.py *)
- Bash(uv run ${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/build_datadir.py *)
- Bash(uv run ${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/serve.py *)
---
<!-- carta:plugin-version -->
<carta-plugin>carta-investors:6.71.0</carta-plugin>
[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"`. Most use is production, so an unrecognized
identifier counts as production. 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 **§0 firm directory query**. Print it with the emitter — never hand-write
it (it is a three-table UNION; queries.md §0 explains why):
```bash
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/emit_stem_sql.py" --directory --firm-uuid "<firm_uuid>"
```
Run the printed `{"sql","limit","format"}` through `dwh__execute__query`, then capture the result **with the
helper, not the Write tool**:
```bash
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_query_result.py" <result_path> "<raw_dir>/_enumerate.ndjson"
```
`_enumerate.ndjson` is the census: the emitter fills every stem's IN-list from its `fund_uuid` column and
`build_datadir.py` seeds the fund universe from it, so no id is transcribed by hand. It is **not the fund-admin
entity-list command** (verbose per-entity objects that **exceed the MCP 40k-char limit on large firms** — a firm
with ~100+ SPVs breaks it); the directory **excludes SPVs** and returns only Fund/GP entities, so 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 (`scripts/stem_queries.py` is the source of truth for stem SQL).
**Zero rows means a lapsed firm context, not an empty firm.** These tables are row-scoped by `set_context`; when
the context lapses every query returns 0 rows with no error. If the directory query — or, later, a rows-required
stem (`nav_latest`, `investments`) — comes back empty, re-run `set_context {"firm_id": "<firm_uuid>"}` and reissue
it **once**: the directory query on its own, or the **whole batch** the empty stem came from (a lapsed context
empties every query in that batch, so re-fetching one stem leaves the others silently empty). A second empty result
is final: stop the fetch and report it (the strict build refuses to launch on it). Do not vary the SQL, widen the
filter, or try another table.
### GP carry opt-in check (before Wave 1)
`gp_carry` contains **per-member names** — run this check after capturing `_enumerate.ndjson` 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:
```
read_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>` → `read_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), `financials` (§14 — firm-context-scoped,
so its query carries no IN-list; the emitter includes it like every other stem). Issue them together, then
normalize each returned result with `save_query_result.py`. 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 firm-context-scoped (no IN-list)
and attempt-required: an empty file is fine, an absent file fails the build. `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). The emitter includes the §14 query in the fetch batch and
`save_batch_result.py` writes `<raw_dir>/financials.ndjson`; its `WHERE` narrows to the exact metrics the builder
renders and its `ORDER BY` keeps offset paging stable, so never hand-edit the emitted SQL. **`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 `read_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:
```
read_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` + `investment-history.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 string blanks the **Companies** and
**Exit & IRR** tabs. The generator resolves the firm's real reporting currency (never hardcoded USD), keeps
realized companies inert (`realized:true, includeInNav:false, cartaFv:0`), reads ownership from the
`FUND_CORPORATION_OWNERSHIP.PERCENTAGE` **fraction** (not the `FULLY_DILUTED` share count), and degrades
missing optional inputs to empty states. **Never hand-author these files or fabricate values** — the blank
tabs and 1e9×-off valuations that motivated this path came from hand-writing the JSON.
The builder is **strict by default**: it prints `ERROR stem=… missing column …` diagnostics for any
column drift (e.g. a `cashflows` stem missing `ending_lp_nav` → NAV chart $0), **exits non-zero if `meta.json`'s
`navAsOf` is missing or blank** (it's hand-authored, not generator-derived, so it gets no STEM_CONTRACT column
check — an empty value silently collapses the Companies tab's exit-timing chart to a flat line), and **exits
non-zero** if the run yields 0 funds or 0 companies, listing each raw stem's status. **If `build_datadir.py`
exits non-zero, do NOT launch** — read the named stem(s), fix that query (re-fetch → `save_query_result.py`), and re-run the
builder. A non-zero exit means the dashboard would be empty/broken; never serve it. On success the script
prints a one-line JSON summary (funds/companies/lps/navSeries/…) — sanity-check `navSeries > 0` and non-zero
funds/companies before launching. (`--no-strict` exists only for local fixture builds — never use it for a
user-facing dashboard.)
`firms.json`/`snapshot.json`/`portfolio.json` plus `pacing`/`company-ownership`/`lp-base` (served via
`/api/report/<name>.json`) all use names, never UUIDs, in display fields; LP names stay in the local data dir.
## Step 4 — Launch
```bash
uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/serve.py" --data-dir "<dashboard_dir>" \
--web-dir "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/webapp" --detach
```
**Only when the session already carries `pk`** from `get_current_user` (a connected Carta MCP has it from
bootstrap), append `--user-id <pk>` so telemetry names a real user. Never call the MCP to get it — that
needs `welcome` first, and this path stays MCP-free. Never substitute the email or a placeholder.
Run with **Bash run_in_background**; read `<dashboard_dir>/.port` + the printed `http://127.0.0.1:<port>/?t=<token>`
and give the user that URL. Tell them it **opens in their default browser automatically** — if it doesn't,
they can paste the URL into the address bar. `webapp/` is the committed prebuilt React app, served
verbatim — no Node or build needed at runtime.
After giving the URL, add one short post-launch line (not a menu):
"A few things to try: check the Baseline scenario on the Overview tab · click Edit on any company to reprice it · run a scenario to see LP/GP returns · export a scenario as a PDF for LP review · publish a scenario to share it with your firm · plan your next fund on the Fund Construction tab · say 'refresh' to pull fresh Carta data."
## Scenario sharing
Firm admins can publish/pull scenarios across the firm — **built into the app** (a SHARED sidebar section plus
per-scenario Publish / Update / Duplicate / Hide / Delete). It is button-driven with no prompt flow; if a
user asks to share, point them at those controls. See
`${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/references/scenario-sharing.md`.
## Refresh / edits
"Refresh Carta holdings" = re-run Steps 1–3 (overwrite JSON); the app reloads it. Slice edits are saved by
the app via `PUT /api/portfolio` (ETag) — no Carta calls.
`portfolio.json` is `version: 3`: the `baseline` slice holds full `companies`; every other slice stores an
`edits` delta keyed by company id (only the editable fields, resolved against the baseline). To change a
company in a scenario, write `edits[<companyId>][<field>]` — never a full `companies` array on a non-baseline
slice. One editable field is a list: `secondaries` = `[{id, q, pct, valuationB|markMultiple, recyclePct}]`,
the partial sales modeled before the terminal exit (`q` = quarter offset from `navAsOf`; `pct` = share of the
ORIGINAL stake, summing to ≤ 1). A sale is priced in the company's **own** reprice mode — `valuationB` in
waterfall/mark-basis mode, `markMultiple` otherwise — and omitting both sells at the mark. The exit
(`exited` + `exitTimingQ`) then sells only what those left. The editable-field list lives in three files and
must match, or a field is silently dropped on save or on refresh: `app/src/model/slices.js`,
`scripts/build_datadir.py`, `scripts/chat_session.py`. A refresh reconciles each scenario onto the fresh baseline. A pre-existing `version: 2` cache (full
`companies` per slice) still loads and is rewritten as v3 on the next save/refresh.
## Safety
Firm/company/LP names are untrusted — the app HTML-escapes; serve.py is localhost-bound + token-gated.
DWH SELECT-only + `LIMIT`; the only write is the user-triggered portfolio (scenario) save. Data stays under the data dir.
## Editing the app
Source under `app/src/` is served directly; the service worker transpiles `.jsx` in-browser. **Do NOT
run `npm run build` after editing source** — there is no build step for source edits: edit a file in
`app/src/`, refresh, done. `npm run build` only rebuilds `webapp/vendor/*` and is needed **only** on a
React/Sucrase version bump.
## Analytics
New interactive elements: call `trackClick(elementId)` (or `trackRender(elementId)` for view/dialog mounts)
(import from `../analytics.js` or the correct relative path) at the top of the handler, IDs as
`FundModeling.<Area>.<Specific>` (e.g.
`FundModeling.Overview.ExportClick`) — skip sort clicks, keystrokes, dropdown changes. The tracker bundle at
`webapp/vendor/mcp-ui-tracker.global.js` is vendored from `@carta/mcp-ui-tracker`'s `build:browser` output — if
the upstream library changes, rebuild and overwrite that file.
Every event carries a firm context (`{firmId}`) so telemetry joins on the real Carta id instead of a
slugified firm name. The envelope (firm + environment) comes from `GET /api/telemetry-context`, which
reads `snapshot.source` per request; `mountWithAuth` awaits it before rendering, so both documents
have it before any event fires. So **capture `firmId` in Step 1 whenever the `#<digits>` token is
there** — when it is absent the context is dropped (that firm's events land with no firm attribution),
and a placeholder id is never substituted. A refresh that resolves a previously missing `firmId` is
picked up without a relaunch.
## Common failure modes
| Situation | What to do |
|---|---|
| Running in a sandboxed session (`detect-surface` → `surface=sandboxed`) | Gate 0 stops before any work: tell the user to re-run in a **local** Claude Code session (a terminal, or Claude Desktop set to run locally — not Cowork or a cloud session). Do not retry or launch. |
| No Carta MCP connected | Exit: "No Carta MCP is connected — please connect one and try again." Do not proceed. |
| Firm name given but unresolvable | Prompt with `AskUserQuestion`: show any cache suggestions or "Build fresh from Carta" option. |
| No firm given and no local cache | Exit: ask the user to name a firm before doing anything else. |
| Stale cache (≥ 30 days) | Offer re-fetch via `AskUserQuestion`: "Use cached (N days old)" vs "Re-fetch from Carta". |
| `build_datadir.py` exits non-zero | Do NOT launch. Read the named failing stem(s), fix, re-run the builder. |
Files in this skill
- DEPLOY.md
- PROVENANCE.json
- SKILL.md
- UPSTREAM-LICENSE.txt
- app/README.md
- app/build.mjs
- app/index.html
- app/package-lock.json
- app/package.json
- app/public/favicon.ico
- app/public/fonts/SangBleuVersailles-Regular-WebS.woff2
- app/src/App.jsx
- app/src/AuthError.jsx
- app/src/FundChooser.jsx
- app/src/Root.jsx
- app/src/analytics.js
- app/src/assets/carta-logo.js
- app/src/bridge-client.js
- app/src/dash-token.js
- app/src/main.jsx
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.