• Projects
  • Service
  • About
  • branding.bz
  • Podcast
  • Tips
  • FAQ
  • Recruit
  • Download
  • Contact
  • branding.bz(ブランド構築SaaS)
  • DESIGN NOW(デザインメディア)
  • X
  • LinkedIn
  • Spotify
  • Facebook

213-0011 神奈川県川崎市高津区久本3-6-7-303

© 2026 ID INC. All rights reserved

claude-skills/スキル
SKILLOfficialproductivity

carta-fund-modeling

プラグイン
carta-investors
引数
<firm name or Carta firm URL — required>
ソース
GitHub で見る ↗
説明

Carta Fund Admin のデータを使い、ファーム(運用会社)レベルのシナリオ・モデリング(投資判断に向けた複数ケースの分析)ができるインタラクティブなウェブコンソール(操作画面)をローカル環境で起動します。React で作られたアプリケーション で、複数のポートフォリオ企業の価格見直しと売却シナリオをモデル化できます。 **搭載される5つのタブ:** - **Overview(概要)**: ファンドファミリー全体の統計、投資実行ペース、ポジション集中度 - **Companies(企業情報)**: 企業ごとの価格見直し、出資者への返金順位表、キャリー(運用報酬)の試算 - **Exit & IRR(売却とリターン)**: 売却シナリオ、内部収益率、ファンドマネージャーと出資者の予想リターン、ファンド別の累積リターン推移グラフ - **Reserves(準備金)**: ファンド別の現預金計画 - **Cohort Standing(同期比較)**: 同時期のファンドとの比較、市場平均との比較 シナリオはローカルに保存されます。 **次のような場合に使用:** ファーム名を指定して実行します(例:「Demo Capital のファンドモデリング」「あるファームのポートフォリオシナリオをモデル化」) **制限事項:** - Fund Admin のみが対象です - Tactyc / Fund Forecasting は対象外です(Tactyc ファンドには carta-fund-forecasting を使用) - 単一企業の売却シナリオ分析には使用できません - ファンドデータの読み取り専用クエリには対応していません(carta-explore-data をご利用ください)

原文を表示

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), 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.

ユースケース
  • ファンドレベルの複数売却シナリオを比較分析するとき
  • ポートフォリオ企業の価格見直しをモデル化するとき
  • 投資判断に向けた複数ケースを検討するとき
  • ファンドの予想リターンを試算するとき
  • 同時期ファンドとの相対的なパフォーマンスを比較するとき
本文

<!-- 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:

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:

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" find-by-id "<parsed_id>"

match=<slug> (+ name, dashboard_dir, snapshot_age_days) → a hit; greet and go to the cache-age branch. match=none → BUILD (Step 1), carrying the parsed id/URL to the MCP resolve.

A firm NAME → cache check. Slugify the typed name and look for a matching cache (pass the name to the script — do not slugify it yourself). Caches are keyed by each firm's canonical name (Step 1), so an exact hit means the typed name already matches that firm's canonical name:

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" resolve "<firm name>"

It is read-only — it prints slug=…, cache_root=…, raw_dir=…, dashboard_dir=…, and snapshot_age_days=<N|none> without creating any dir. On a hit, use the printed dashboard_dir verbatim for the launch — never recompute a cache path in the shell. (The build paths come from Step 1's canonical resolve, not here.)

  • snapshot_age_days=<N> (cache hit) → greet, then the cache-age branch — a silent fast launch on fresh, no picker.
  • snapshot_age_days=none (no hit) 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:

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):

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:

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/emit_stem_sql.py" --raw "<raw_dir>" --stem gp_carry

Run the resulting SQL via dwh__execute__query with limit: 1 (override the emitted limit in the tool call).

2a. Query fails (e.g. Error in secure object) or returns 0 rows:

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" touch-empty "<raw_dir>/gp_carry.ndjson"

Tell the user: "GP partner carry data is not accessible for this firm — skipping." Then add --skip gp_carry to the Wave 1 emit command (step 3 below).

2b. Query returns rows — ask via AskUserQuestion:

"This firm has GP partner-level carry data (per-member names, accrued carry, carry shares). Include it in the GP Economics tab?" Options: "Yes, include it" / "No, skip it"

  • "Yes" → include gp_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:

# 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:
      uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_batch_result.py" <result_path> "<raw_dir>" --stems <comma-joined batch.stems>
      
    • Small INLINE result (returned in the tool response, no file) → Write the raw tool result verbatim to <raw_dir>/batch<N>.raw, then pass that file:
      uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_batch_result.py" "<raw_dir>/batch<N>.raw" "<raw_dir>" --stems <comma-joined batch.stems>
      
    save_batch_result.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:

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/save_query_result.py" <result_path> "<raw_dir>/<stem>.ndjson" --append

Repeat until the TRUNCATED line stops appearing — the helper clears the marker itself on the final page. offset is a dwh__execute__query argument, exactly like limit; do not put OFFSET in the SQL.

You get at most 5 pages per stem (50,000 rows). If a 6th page is still reporting next_offset, STOP and report needs_human: <stem> exceeds 50,000 rows — the fund-modeling schema does not expect a stem this large. Do not: raise limit above 10,000 (the server clamps it, so this changes nothing and silently re-truncates); delete the .truncated marker by hand; pass --no-strict to the builder; or narrow the query's fund_uuid / date range to duck under the cap. Each of those turns a loud, fixable truncation back into the silent wrong-data bug this gate exists to catch.

Capture inline results immediately — do not defer. For every INLINE stem, do the Write-to-.raw step in the same turn the result comes back, before issuing the next tool call. A long first build can trigger context compaction mid-fetch; anything still sitting only in conversation history (not yet written to <raw_dir>) is lost when that happens, forcing a re-fetch from Carta. Writing to disk immediately makes each stem durable the moment it lands, regardless of what happens to the conversation afterward. If you ever resume a build and an inline stem's data is no longer visible in context, treat it as never fetched — re-run its query and capture it via the helper. Never reconstruct rows from partial memory of an earlier result; a hand-reconstructed file is exactly the "0 funds / 0 companies" / silently-truncated-stem failure mode above.

Fetch the whole manifest in ONE batch — there is no second wave. Every stem is fund-scoped, so a single emit_stem_sql.py call returns all of them and there is no ordering dependency between any two: nav_latest, investments, cashflows, fund_metrics, accrued_carry, distributed_carry, waterfall, cohort, deal_irr, partners, gp_partners, gp_carry (if opted in — see the GP carry opt-in check above), ownership, financing (§11), captable (§15), corporations (§16). Issue them together, then normalize each returned result with save_query_result.py. Also run the §14 financials query in the same batch (it takes no fund_uuid list — it is firm-context-scoped). The whole build runs off the MCP DWH and these local helpers only.

Do not stop after the fund-level stems — financing supplies each company's last priced round, captable populates its cap table on the dashboard, and corporations is the id bridge those enrichments (cap table AND "Latest round" on Overview) resolve through. All three are file-required (see contract below): a missing <stem>.ndjson hard-fails the build, so skipping them can no longer silently yield "0 cap tables" / blank "Latest round" fields, indistinguishable from a firm that legitimately has none.

financing, captable and corporations filter by corporation, but they take only the fund_uuid list — their corporation scope is a subquery over FUND_CORPORATION_OWNERSHIP (see queries.md §11). Never rewrite one of them to take a pasted corporation_id IN-list: that resolves to ~1,150 UUIDs on a mid-size firm, which is too long for a single call, so it has to be hand-chunked into several — and each chunk costs minutes of token emission. On a 15-fund firm that one mistake cost 15 minutes for financing alone. Pass the emitter's object through unedited and the subquery handles it in one call.

Batching the fetch instead of running one stem at a time is the main first-build speedup: it collapses ~16 model turns into one, and the per-turn reasoning — not the network wait — is what dominates a serial build. (Correctness is unchanged: the emitted SQL is the manifest's verbatim query with the fund_uuid IN-list filled in, and the deterministic capture is identical; only the scheduling and templating change.)

The fetch is a contract, not a checklist — every DWH stem file MUST exist

Fetching is not optional and not LLM-discretionary. build_datadir.py is the deterministic gate: it refuses to build (exits 2) if any file-required stem's <stem>.ndjson is absent, listing what was never fetched. You cannot launch a dashboard that skipped a stem — so do not "skip for speed," and do not decide a firm "probably has none" and move on. Run every stem below.

The file must exist; it may be empty. When a query genuinely returns 0 rows, or fails with Error in secure object (a role that can't read that table), record the attempt by writing an empty file — do NOT leave the file absent:

uv run "${CLAUDE_PLUGIN_ROOT}/skills/carta-fund-modeling/scripts/fm_paths.py" touch-empty "<raw_dir>/<stem>.ndjson"

An empty cohort file is what makes benchmarks read as no_coverage_published ("genuinely none") instead of falsely blocking the build. A missing file means the query was never run — that is the exact bug this gate exists to stop.

Stems → queries.md section. The DWH stems below are file-required — the builder rejects a build that is missing any of their <stem>.ndjson files (write an empty file when truly none): nav_latest(§2, rows required), investments(§3, rows required), cashflows(§5 — the single 7-column query that feeds both LP IRR and the NAV/TVPI trend; do not run a separate §13), fund_metrics(§1/§12), accrued_carry(§7), distributed_carry(§7 — realized "Carried interest earned"; feeds the "Carry distributed" callout, $0→"—"), cohort(§8), deal_irr(§10), financing(§11), partners(§9), ownership(§4), captable(§15 — a present-but-empty file is fine for firms whose portcos aren't Carta cap-table customers, but the file itself must exist), corporations(§16 — the entity_link -> corporation_uuid bridge that captable and financing's "Latest round" enrichment depend on; same empty-file-OK, absent-file-fails rule). financials(§14, portfolio-company financials via Carta Data Collection) is fetched via its own §14 query below and is not gated by the builder. waterfall(§6, PROFIT_ALLOCATION_WATERFALL_CONFIG — real per-fund carry / preferred return / GP catch-up), gp_carry(§7b, ALLOCATIONS GP-entity Carried interest accrued) and gp_partners(§9, IS_GENERAL_PARTNER) are optional wave-1 stems the emitter includes automatically. waterfall seeds real carry/hurdle/catch-up (else the flat carryRate defaults). gp_carry is the primary feed for the GP Economics partner-carry table (real per-partner carry shares → gp-base.json); gp_partners supplies the GP commitment (snapshot.funds[].gpCommit, summed GP-partner commitment from the DWH) and enriches that table. None are gated — a firm with no automated waterfall / GP-entity carry / GP-partner rows just yields empty files and those features fall back gracefully.

GP commitment ($) is fully DWH-sourced (§6). build_datadir.py derives snapshot.funds[].gpCommit from the gp_partners stem (the GP partners' summed commitment), falling back to the GP's paid-in (nav_latest.cumulative_gp_contributions); null only when neither exists (app shows "—"). Never back-fill a modeled estimate (e.g. committed/99).

Company financials (optional) — §14. Portfolio-company financials (revenue / ARR / KPIs reported by the portfolio company, Carta Data Collection) come from the base FUND_ADMIN.COMPANY_FINANCIALS table (the legacy COMPANY_FINANCIALS_LATEST view is deprecated/empty). Run the §14 query (is_latest = TRUE AND instance_type = 'Actual'), saving the rows to <raw_dir>/financials.ndjson. COMPANY_FINANCIALS is row-scoped to the firm you set as context via set_context in Step 1 — do NOT add a firm_id filter (redundant with the context scope, and a mismatch silently returns zero rows); this scoping is also why the table looks "empty" if queried from another firm's context. See queries.md §14. All DWH reads are SELECT-only and bounded by the limit argument — never an inline LIMIT, and there is no schema argument (see queries.md intro). Accrued carry is the REAL booked figure (ALLOCATIONS §7). Cohort benchmarks (§8, TEMPORAL_FUND_COHORT_BENCHMARKS) are cross-firm-preaggregated on each fund's own row — NOT firm-context-scoped like COMPANY_FINANCIALS, so do not try to widen the firm context to get more. The newest quarter is often not-yet-benchmarked (all percentiles null), so §8 fetches a recent window and build_datadir.py picks the latest quarter that actually has a cohort. If every recent quarter is null the funds genuinely have no published peer cohort (build summary benchmarksReason: "no_coverage_published"). Cohort may also fail with Error in secure object for some firm roles — if so, still write an empty cohort.ndjson (fm_paths.py touch-empty "<raw_dir>/cohort.ndjson") to record the attempt; benchmarks degrade to the empty state (benchmarksReason: "no_coverage_published") and the build proceeds. Do not leave the file absent — a missing cohort file is a hard build failure (the fetch gate treats it as "never run"). Scenario-focused console: do not fetch tearsheets, schedule of investments, or cash-flow statements.

Step 2b — Company logos (optional — best-effort, never blocks the build)

What's happening: Fetching each portfolio company's real logo (if Carta has one on file) to replace the initials placeholder on the Overview activity feed.

This is not a DWH stem and has no fetch gate — a firm with no logos, or a portco-logo call that errors, degrades silently to the initials avatar every company already falls back to. It needs no fund_uuid/ corporation_id IN-list (the tool is firm-scoped) and no DWH output, so issue its call_tool in the same message as Wave 1's dwh__execute__queries batches rather than waiting for Wave 1 to finish first — the two have no ordering dependency, and serializing them only adds wall-clock time to every build:

  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):
    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:

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

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 · 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. 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.

原文・著作権は Anthropic および各プラグイン作者に帰属します。日本語訳は Claude API による自動翻訳です。