From carta-investors
Generates firm-wide consolidating P&L (Income Statement) across all entities for one month, producing two linked Excel tabs: detailed P&L with Month/YTD Actual/Budget/Variance/% and executive summary. Optional tag-view breaks Actuals down by reporting tags.
How this skill is triggered — by the user, by Claude, or both
Slash command
/carta-investors:carta-consolidating-pnlsonnetThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
[PATTERN carta-writing-style v0.0.2]
[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] [PATTERN menus-and-flows v0.0.7] [PATTERN base v0.1.0]
Generates a firm-wide consolidating Income Statement for a single month, as two linked tabs:
P&L - <FIRM-SHORT> <MMM-YY>) matching the
"P&L- with comments" format, with Month and YTD blocks of Actual /
Budget / Variance / %.The data is pulled live from Carta's DWH via the Carta MCP connector — nothing is embedded in the skill.
This skill runs primarily inside the Claude for Excel add-in. The audience is an accountant working in their workbook, not an engineer running CLI commands.
This skill ships as a standalone Claude for Excel skill — the global carta-skill-ux-rules SessionStart hook covers currency formatting, status vocabulary, no-UUID display, and plain-English speech. Skill-specific deviations:
[B1:Q72](<citation:P&L - Acme Mar-26!B1:Q72>).This skill does not call carta auth-status — that command isn't
available inside the Excel add-in. Instead, the active Carta environment
is detected at Gate 0 from the connected MCP server's prefix
(mcp__claude_ai_Carta_<Env>__fetch).
Trigger on any request shaped like:
<FIRM> for <MONTH>" — with or without "with executive summary"<FIRM>" / "firm-wide income statement"Do NOT use this skill for:
carta-consolidating-balance-sheetBefore running, confirm with the user:
Acme Ventures.YYYY-MM (e.g. 2026-03 for March 2026). Used
for both the month block and the YTD-through-month block.If the user gave both in the request, proceed without re-asking.
Execute all gates silently. Do not narrate tool calls, intermediate results, or status updates. Only speak at explicit decision points: Gate 4 (pre-build review, build chooser, and budget source), Gate 4b (if budget source requires a file or tab), Gate 5 (output destination if workbook is non-empty), and Gate 9 (post-action menu).
Mandatory telemetry beacon — run this before any skip-gate check: If <SERVER> and <FIRM_UUID> are already in context, call mcp__<SERVER>__set_context(firm_id=<FIRM_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}) as your very first action. Do not skip this even when all data is already available — it records this skill invocation. If either is unknown, it fires in Gate 0 below.
Before Gate 0, check whether these context variables are already set from an earlier skill call in the same session (e.g. chained from carta-consolidating-balance-sheet):
<SERVER> — connected Carta MCP server prefix<FIRM_NAME> and <FIRM_UUID> — the resolved firmIf both are in context: skip Gate 0. Call mcp__<SERVER>__set_context(firm_id=<FIRM_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}) to re-anchor the session scope and record this skill invocation, then proceed from Gate 2 (pull period blocks) using the firm already in context and the month from the user's prompt.
If either is missing (fresh session or cold invocation): run Gates 0 and 1 in order.
Do not ask "which firm?" when it is already established from the skill the user just ran.
refresh_mcp_connectors. Filter servers[] to name matching Carta / Carta (…) / carta with status: "connected". Drop failed.mcp__claude_ai_Carta__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}) , mcp__carta_production__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}), and mcp__carta__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}). First success = <SERVER>.mcp__<SERVER>__* tool before welcome — every command is gated.If none connected, list failed connectors and stop. If multiple, default to Carta (production).
mcp__<SERVER>__list_contexts(firm_name="<FIRM>", _instrumentation={"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}). Do not use call_tool for list_contexts — call the granular tool directly with _instrumentation as shown. Multiple matches → AskUserQuestion. Wait for confirmation.mcp__<SERVER>__set_context(firm_id=<FIRM_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}). Do not use call_tool for set_context — call the granular tool directly with _instrumentation as shown.DWH param-name traps: dwh:execute:query takes sql: not query:. dwh:get:table_schema takes table_name: not table:. format accepts "ndjson" / "markdown", not "csv".
Do NOT call fa:list:entities — firm-wide consolidation aggregates via SQL.
The schema and sign conventions for the Carta DWH journal-entries
table are documented in references/schema.md. Load that file now
and apply its rules.
Compute the period boundaries:
month_start = first day of the monthmonth_end = last day of the monthytd_start = <YYYY>-01-01Single query, both periods at once, summed across all entities under the firm:
SELECT
ACCOUNT_TYPE,
ACCOUNT_NAME,
SUM(CASE WHEN EFFECTIVE_DATE BETWEEN '<month_start>' AND '<month_end>' THEN AMOUNT ELSE 0 END) AS MONTH_AMT,
SUM(CASE WHEN EFFECTIVE_DATE BETWEEN '<ytd_start>' AND '<month_end>' THEN AMOUNT ELSE 0 END) AS YTD_AMT
FROM <journal_entries_table>
WHERE FIRM_ID = '<firm_uuid>'
AND ACCOUNT_TYPE >= '4000'
AND EFFECTIVE_DATE BETWEEN '<ytd_start>' AND '<month_end>'
GROUP BY 1, 2
HAVING SUM(CASE WHEN EFFECTIVE_DATE BETWEEN '<ytd_start>' AND '<month_end>' THEN AMOUNT ELSE 0 END) <> 0
ORDER BY 1, 2
Queries > 50 rows: request format: "ndjson", bucket into a blob. Don't paste large results — triggers context_snip. Use "markdown" only for ≤50-row previews.
Run via call_tool({"name": "dwh__execute__query", "arguments": {"sql": "...", "_instrumentation": {"plugin": "carta-investors", "skills": ["carta-consolidating-pnl"]}}}).
SELECT-only.
Critical: no FUND_NAME filter. The GROUP BY on ACCOUNT_TYPE, ACCOUNT_NAME automatically rolls up the same COA account across every
entity into a single row.
The HAVING ... <> 0 clause filters out accounts with no YTD actuals.
That's correct for this stage — but do not treat that filtered set as
the final row list for the workbook. If a budget is loaded in Gate 4b,
accounts with budget but zero actuals must still appear as rows (with -
or 0 in the Actual columns). The row-set merge happens at the start of
Gate 6 — see "Row set: union of actuals + budget" there.
Done when: the period dataset is loaded — Month + YTD amounts for every P&L account with non-zero YTD activity, aggregated firm-wide.
Always run this gate — even if the user hasn't asked for tag-view. The result determines whether the Gate 4 build chooser shows the "tag-view" option or hides it. Silent — no user-facing output.
Tag-view layout (when chosen at Gate 4) shows all firm tag categories side by side under each period band — no "which dimension?" picker. This probe's job is to discover whether the firm has a tag taxonomy in REPORTING_TAGS_JSON (or falls back to the flat REPORTING_TAGS column).
SELECT
COUNT_IF(REPORTING_TAGS_JSON IS NOT NULL) AS json_rows,
COUNT_IF(REPORTING_TAGS IS NOT NULL) AS flat_rows
FROM <journal_entries_table>
WHERE FIRM_ID = '<firm_uuid>'
AND EFFECTIVE_DATE BETWEEN '<ytd_start>' AND '<month_end>'
json_rows > 0 → JSON path. Skip Probe 2 — go directly to Probe 3 (JSON path). Probe 3 returns both category names and cardinality in one query, making a separate category-discovery query redundant.json_rows == 0 AND flat_rows > 0 → flat path. Set <TAG_CATEGORIES> = ["Reporting Tag"], <TAG_PATH> = "flat", and continue to Probe 3.<TAG_CATEGORIES> = [], <TAG_PATH> = "none". Gate 4 will omit the tag-view chooser option.Only needed if <TAG_CATEGORIES> is non-empty. Returns the value count per category for the wide-vs-long decision in Gate 4's dimension picker.
JSON path:
SELECT f.key::TEXT AS category, COUNT(DISTINCT f.value::TEXT) AS n_values
FROM <journal_entries_table>,
LATERAL FLATTEN(input => REPORTING_TAGS_JSON) f
WHERE FIRM_ID = '<firm_uuid>'
AND REPORTING_TAGS_JSON IS NOT NULL
AND EFFECTIVE_DATE BETWEEN '<ytd_start>' AND '<month_end>'
GROUP BY 1
ORDER BY 1
JSON path — after query: Store <TAG_CATEGORIES> as the sorted list of distinct category values returned, set <TAG_PATH> = "json", and store <TAG_CARDINALITY> as the map of category → n_values.
Flat path:
SELECT 'Reporting Tag' AS category, COUNT(DISTINCT REPORTING_TAGS) AS n_values
FROM <journal_entries_table>
WHERE FIRM_ID = '<firm_uuid>'
AND REPORTING_TAGS IS NOT NULL
AND EFFECTIVE_DATE BETWEEN '<ytd_start>' AND '<month_end>'
Flat path — after query: <TAG_CATEGORIES> is already set to ["Reporting Tag"] (from Probe 1). Keep <TAG_PATH> = "flat". Store <TAG_CARDINALITY> as {"Reporting Tag": n_values}.
No AskUserQuestion here. This gate only probes. The tag-view choice happens in Gate 4 if the user picks tag-view mode; the wide-vs-long choice (if needed) is asked there too.
Done when: <TAG_CATEGORIES> and <TAG_PATH> are populated.
Classify by leading digit of ACCOUNT_TYPE, per references/schema.md:
4xxx → Revenue — multiply MONTH_AMT and YTD_AMT by -1 for
positive display (credits stored as negative)5xxx – 9xxx → Expenses — keep as-is (debits stored as positive)Then load references/section-map.md and apply its keyword table to assign
each expense account to a section. Order matters — first match wins.
Sort within each section by ACCOUNT_TYPE ascending.
Revenue forms its own section — do NOT run revenue accounts through the
expense map. Revenue subtotal label is Investment Income.
Other is the catch-all and is always included even if empty.
Note: this gate only classifies accounts that have non-zero actuals. Budget-only accounts (loaded in Gate 4b) are classified separately using the same rules at the start of Gate 6, then merged into the row set.
Done when: every non-zero actuals account is in exactly one section.
Before touching the user's workbook, show a plain-English preview so the accountant can sanity-check the build and edit if anything looks off. This is the pre-flight checkpoint — no Excel changes happen until the user explicitly confirms.
Present a short, scannable summary:
Ready to build the P&L — please review.
- Firm: Acme Ventures
- Period: March 2026 (Month + YTD through Mar 31, 2026)
- Scope: firm-wide consolidating across all entities
- Revenue accounts: 4 (Bank interest, Monitoring income, Flow-through distributions, Unrealized gains)
- Expense accounts: 32, grouped into Human Capital, Contractor Expenses, Occupancy & Office, Professional Services, Travel & Marketing, Technology & Data, Other
- Sheets to write:
P&L - Acme Mar-26(detail)Summary P&L(executive summary, first tab)Note: if you pull from a budget source below, accounts that have a budget but no actuals this period will also be added as rows (with
$0actuals) — final count may be slightly higher than the totals above.
If any expense accounts landed in Other, surface them here:
⚠ 3 accounts landed in
Other: "Carried interest expense", "Foreign exchange", "Misc operating". Confirm or adjust the section mapping before building.
Then ask with AskUserQuestion. The available options depend on whether Gate 2.5 found tag data (i.e. whether <TAG_CATEGORIES> is non-empty):
1 - Build both tabs (detail + Summary) ← recommended
2 - Build the detail tab only (no Summary)
3 - Build a tag-view tab — Actuals broken down by all reporting tag categories ← only if <TAG_CATEGORIES> is non-empty
4 - Change the firm or period
5 - Cancel
If <TAG_CATEGORIES> is empty, omit option 3 from the chooser entirely.
If the user's prompt explicitly asked for tag/department/cost-center breakdown,
make option 3 the ← recommended default (and demote option 1 to a plain option).
Handle each branch:
build_mode = "tag-view". Skip the budget source question entirely (no Budget/Variance columns in tag-view mode). All firm tag categories from <TAG_CATEGORIES> are shown side by side under each period band — no dimension picker. The only follow-up is the wide-vs-long question below, and only if cardinality exceeds the threshold.Compute the total column count per period block as: sum(n_values for each category in <TAG_CATEGORIES>) + len(<TAG_CATEGORIES>). The + len(...) term covers the per-category Total columns. Multiply by 2 for the Month + YTD blocks.
If the combined total exceeds 24, ask via AskUserQuestion. The ← recommended marker depends on which side of the 36-column threshold the run falls on (mirrors the Cardinality guard table in references/tag-view.md):
The tag-view tab would have
<N>columns across<C>categories (<cat1>,<cat2>, …). With that many, should I build wide (one column per tag per category per period) or long (one row per tag per account)?
| Total tag columns (Month + YTD combined) | Recommended option |
|---|---|
| 25–36 | Wide — one column per tag ← recommended |
| > 36 | Long — one row per tag per account ← recommended |
Always offer both options regardless of which is recommended — accountants sometimes want the wide layout even past 36 columns if they're going to filter it down in Excel.
Store <TAG_LAYOUT> (wide | long). Default wide for ≤ 24 (no question asked).
Loop until the user picks a build option or cancels. Never write to Excel based on inferred intent.
Do not surface internal field names (ACCOUNT_TYPE, MONTH_AMT,
EFFECTIVE_DATE) or UUIDs in this review — translate to plain accountant
language.
Hard rule: no workbook-write tool (Excel-add-in cell write, execute_office_js that mutates state, write_workbook.py, or any equivalent) runs before this gate's AskUserQuestion returns the user's explicit "Approve and write" choice. If you find yourself about to call a workbook-write tool without that approval recorded, stop and run this gate first. This is not negotiable — silently writing without approval breaks user trust.
Ask the build chooser and the budget-source chooser in a single
AskUserQuestion call with both questions in the questions array.
This saves a user round trip — the chooser UI in Claude for Excel
renders two stacked dropdowns from one call. Do NOT make two separate
AskUserQuestion calls for these.
If the user picks "Cancel" on question 1, ignore their answer to question 2 and stop the skill cleanly — the budget-source answer is only acted on when the build choice is option 1 or 2.
Framing for the second question:
Where should I get the Budget figures?
| # | Option | What happens |
|---|---|---|
| 1 | Pull from Carta ← recommended | Fetches the ManCo budget live from Carta before building. |
| 2 | Import from an Excel file | Asks for a file path; reads the file now. |
| 3 | Import from another tab in this workbook | Deferred — picked up in Gate 5 once the workbook is identified. |
| 4 | Leave Budget blank | Columns E + N stay empty; Variance / % will render n/a. |
Mark ← recommended based on context — Carta by default; Skip if the
user's prompt explicitly excluded budget (e.g. "build without budget").
Record the budget source choice. Proceed to Gate 4b.
Done when: the user has confirmed the build, with their chosen firm/period locked in, the Summary-tab opt-in/opt-out recorded, and the budget source choice recorded.
Skip this gate entirely if build_mode == "tag-view". Tag-view mode
writes Actuals only — there are no Budget/Variance/% columns to fill. The
Carta fa:list:budgets API has no tag dimension, so a per-tag Budget
column would be mathematically misleading (the same budget value
duplicated across every tag column).
Fetch the budget rows now — before any Excel writes — so Gate 6 can write Budget columns E + N in the same pass as the detail build.
Read references/budget-fetch.md now and
follow Part A (entity picker) + Part B (fetch). Then return here with the
budget rows in the output shape that file documents.
Narrow the date window in the fa:list:budgets call — pass start_date = <ytd_start> and end_date = <month_end> (the same YTD window Gate 2 uses). An un-narrowed call returns the full annual budget (~44KB for a typical ManCo), which forces an extra round-trip through code_execution and burns context. The YTD-window response is small enough to handle inline.
Source label: Carta Fund Admin (live) — <ManCo name>.
Set scope = "single-entity", entity_name = "<ManCo name>" — the
single-entity-vs-firm-wide flag in fill-budget-columns.md step 1 will
fire because this P&L is firm-wide consolidating.
After the budget rows are loaded, call context_snip on the raw fa:list:budgets response — you only need the normalized {gl_code, account_name, month_budget, ytd_budget} rows downstream.
Ask the user for the budget workbook via AskUserQuestion. In Claude for
Excel, the user attaches the file to the conversation; use the add-in's
file-read capability to load it. Don't shell out to Python — this skill
runs entirely inside the Excel add-in.
Parse the loaded workbook for budget rows. Header-detection heuristic:
the first row whose columns include both an account-code-like value
(numeric string) AND a month-like header (Jan, Jan 2026, 2026-01)
is the header row. Read data rows beneath it.
Pivot to the same shape budget-fetch.md documents at the bottom:
rows: [{gl_code, account_name, month_budget, ytd_budget}, ...].
Source label: Imported from <filename>. Set scope = "single-entity"
if the file's title block names a single entity; otherwise ask the user
which scope applies.
Defer: record budget_source = "workbook_tab" and proceed to Gate 5.
Gate 5 will list the open workbook's tabs and ask which one holds the
budget. Then parse and store the rows before Gate 6 writes.
No fetch. Set budget_source = "skip" and proceed to Gate 5.
Done when: budget rows are loaded in memory (or skip is recorded)
and source_label is set for Gate 8's report.
This skill is designed to run inside the Claude for Excel add-in. Before writing anything, decide whether to write into the user's currently open workbook or to create a new one.
Check for an active workbook. Use the Excel add-in's "active workbook" / "current workbook" tool (whatever name the add-in exposes at runtime) to see if there is a workbook open in front of the user.
Decide the destination using this matrix — there are three cases, not two. The empty-workbook case is the one most often mishandled:
| Case | Trigger | Action |
|---|---|---|
| A. No workbook open | Add-in reports no active workbook | Create a new workbook silently. Tell the user in one sentence that you created P&L - <FIRM-SHORT> <MMM-YY>.xlsx because nothing was open. |
| B. Empty workbook open | One sheet, maxRows == 0, no data, no other tabs (e.g. a fresh Book1.xlsx / Sheet1) | Use it without asking. Announce the rename in one sentence before writing: "I'll use the empty workbook you have open and rename Sheet1 to P&L - <FIRM-SHORT> <MMM-YY>." No AskUserQuestion is required for the empty case — asking adds friction with no decision to make. |
| C. Non-empty workbook open | Any sheet has data, OR more than one sheet exists | Run the COA label scan described below, then ask. |
COA label detection (Case C only). Before asking the user anything,
scan every existing tab for matching P&L content. For each tab, read
non-empty values from column B via execute_office_js and compare them
against the ACCOUNT_NAME values in the Gate 3 dataset. A tab is a
COA-label match if ≥ 5 account labels from the current query appear
in that tab's column B. An exact-name match is a tab whose name
equals the proposed detail-tab name (P&L - <FIRM-SHORT> <MMM-YY>).
AskUserQuestion, naming the matched tab explicitly:
"I found an existing tab
<matched_tab_name>that appears to contain P&L data for this firm. What would you like to do?" Options:
- "Update the existing
<matched_tab_name>tab" — clear and rebuild it in place (also rebuildSummary P&Lif present). ← recommended- "Create new tabs instead" — adds
P&L - <FIRM-SHORT> <MMM-YY>(andSummary P&L) as new tabs (with a numeric suffix like(2)if those names also already exist; truncate to 31 chars after suffixing).- "Cancel" — stop the skill. If the user picks "Update existing tab", clear the matched tab's used data range before writing:
const sheet = context.workbook.worksheets.getItem("<matched_tab_name>");
sheet.getUsedRange().clear();
await context.sync();
Proceed to Gate 6 using <matched_tab_name> as the detail-tab target
(the sheet already exists — do not call create_sheet). Also clear
and reuse the existing Summary P&L tab if it is present; otherwise
create it fresh.AskUserQuestion:
"You have <workbook>.xlsx open with N tabs. May I add
P&L - <FIRM-SHORT> <MMM-YY> and Summary P&L to it?"
Options: Add P&L tabs to this workbook / Create a new workbook instead / Cancel.… Mar-26 (2)) and
mention the rename in Gate 8's report. Truncate to Excel's 31-character
limit after suffixing.If the user cancels or denies edit permission for the active workbook and picks "Cancel": stop the skill cleanly. Don't fall back silently to creating a new file.
The hard rule from Gate 4 still applies — no workbook-write tool runs before this gate has either (a) returned an explicit "Yes" answer for case C, or (b) explicitly announced the rename for case B / new workbook for case A. Case B removes the dialog but does NOT remove the announcement.
Lock the chosen <destination workbook> and the two target sheet names
(P&L - <FIRM-SHORT> <MMM-YY> for the detail, and Summary P&L for the
executive summary) and use them through Gates 6, 7, and 8.
If budget_source = "workbook_tab" (deferred from Gate 4b):
List the open workbook's tabs and use AskUserQuestion to ask which
one holds the budget. Same header heuristic as Gate 4b option 2. Parse
and store the rows now, before Gate 6 writes.
Source label: Imported from tab "<TAB_NAME>" in this workbook.
Ask the user about scope (single-entity vs firm-wide).
Done when: the destination workbook + both target sheet names are known and the user has explicitly consented to any edit to a pre-existing workbook.
build_mode == "tag-view")Stop reading the rest of Gate 6 and switch references. Load
references/tag-view.md now and follow it
verbatim. That file documents:
P&L by Reporting Tag - <FIRM-SHORT> <MMM-YY>).<MMM-YY> / YTD <MMM-YY>); row 5 = category band (merged per category within each period block); row 6 = tag header (account + tag values + per-category Total per block).LATERAL FLATTEN + CROSS JOIN to produce one row per (entry × category); flat path uses a synthetic 'Reporting Tag' category.<TAG_CATEGORIES>) — no dimension picker.Currency-format verification (REQUIRED, observable, excel-addin only). After the cell-write call (and before the brand block), run this readback as a separate execute_office_js call:
const sheet = context.workbook.worksheets.getItem("<TAG_VIEW_TAB_NAME>");
const cell = sheet.getRange("<sample_amount_cell>"); // e.g. first data cell, typically C7
cell.load("numberFormat");
await context.sync();
return cell.numberFormat[0][0];
The returned string MUST contain a locale-specific currency token matching the resolved fund currency (one of [$$-en-US] USD, [$€-x-euro2] EUR, [$£-en-GB] GBP, [$CA$-en-CA] CAD). If it returns _($* #,##0..., $#,##0, "$"#,##0, or anything without a [$...-...] locale token, Excel will render currency in the user's local symbol. Halt, re-apply the format for the resolved currency (_-<CCY_TOKEN>* #,##0.00_-;_-<CCY_TOKEN>* (#,##0.00);_-<CCY_TOKEN>* "-"??_-;_-@_-) to the full amount range, and re-verify. Without this readback in your tool history, Gate 6 is not complete.
After the tag-view tab is built and branded, skip Gate 7 (no Summary
tab in tag-view mode) and jump straight to Gate 8 with the tag-view
verification + report variant in tag-view.md.
The rest of Gate 6 below applies.
Before calling execute_office_js with state-mutating code, setValues, write_workbook.py, or any other workbook-write tool, look back at your tool history. Find the most recent AskUserQuestion you sent. Does its answer correspond to one of the Gate 4 build-approval choices — "Build both tabs (detail + Summary)", "Build the detail tab only", or "Build a tag-view tab"? If NO, Gate 4 did not pass — send the Gate 4 approval menu now and wait for the explicit answer.
Do not interpret upstream answers as approval. A budget-source response from the batched chooser, a firm-pick answer, a destination-picker answer from Gate 5, or any prior AskUserQuestion whose answer is not literally one of the three build-approval choices listed above does NOT clear this gate.
execute_office_js calls (excel-addin runtime)The most common failure mode is bundling cell writes + formatting + logo into one writeSheet(...) function — the model writes the cells, returns, and forgets the logo. Do not combine the cell-write call with the brand block in a single office.js block.
execute_office_js. Return.CartaLogo exists.Returning from Call 1 does NOT finish Gate 6. The verification call must appear in your tool history before Gate 7.
Read references/formatting.md AND references/branding-and-header.md now and apply both verbatim. branding-and-header.md reserves rows 1–4 for the firm/title/source/context band and places the Carta logo at column E, rows 1–3 height. formatting.md documents the +4 row shift this introduces — all data row numbers downstream are offset accordingly.
The detail tab is not "built" until it carries a CartaLogo shape sized to the E1:E3 row band. Paste the block below verbatim — never substitute a hardcoded pixel height (e.g. image.height = 48), never anchor to a single cell (getRange("E1")). Both shortcuts produce a logo the user has to resize and reposition by hand. Substitute only <TAB_NAME> = <DETAIL_TAB_NAME>:
const base64 = blobs.getText("assets/powered_by_carta.b64.txt").trim();
const sheet = context.workbook.worksheets.getItem("<TAB_NAME>");
const shapes = sheet.shapes;
shapes.load("items/name");
await context.sync();
for (const s of shapes.items) {
if (s.name === "CartaLogo") s.delete();
}
await context.sync();
const rows = sheet.getRange("E1:E3");
rows.load(["left", "top", "height"]);
await context.sync();
const image = sheet.shapes.addImage(base64);
image.name = "CartaLogo";
image.load(["width", "height"]);
await context.sync();
const ratio = image.width / image.height;
image.lockAspectRatio = false;
image.height = rows.height;
image.width = rows.height * ratio;
image.left = rows.left;
image.top = rows.top;
image.lockAspectRatio = true;
await context.sync();
Brand-verification call (REQUIRED, observable). Run this as a separate execute_office_js call before proceeding to Gate 7. The check confirms not just that the shape exists but that it was sized to the E1:E3 row band:
const sheet = context.workbook.worksheets.getItem("<DETAIL_TAB_NAME>");
sheet.shapes.load("items/name,items/height,items/left");
const rows = sheet.getRange("E1:E3");
rows.load(["height", "left"]);
await context.sync();
const logo = sheet.shapes.items.find(s => s.name === "CartaLogo");
return {
found: !!logo,
heightMatchesBand: logo ? Math.abs(logo.height - rows.height) < 2 : false,
leftMatchesBand: logo ? Math.abs(logo.left - rows.left) < 2 : false,
shapeHeight: logo ? logo.height : null,
rowBandHeight: rows.height,
};
Pass criteria — ALL three must be true: found, heightMatchesBand, leftMatchesBand. If found is false, re-run the brand block. If heightMatchesBand is false, the block was paraphrased with a pixel literal — delete the shape and re-run the verbatim block above. If leftMatchesBand is false, the anchor was a single cell instead of E1:E3 — same fix. Do not proceed to Gate 7 until every check passes. Without this verification in your tool history with all-true checks, Gate 6 branding is not complete.
| Col | Month block (rows ≥ 6) | YTD block (mirror) |
|---|---|---|
| A | (blank, narrow margin) | — |
| B | Account label / section header / subtotal label | — |
| C | 5pt spacer — NO Acct # / GL Code column. Leave empty. | — |
| D | Month Actual (raw $) | — |
| E | Month Budget | — |
| F | (spacer) | — |
| G | =D{row}-E{row} (Variance) | — |
| H | =IF(E{row}>0, IF(G{row}/E{row}>1000,"1000+%",G{row}/E{row}), "n/a") | — |
| I | (spacer) | — |
| J | Month Comments — blank in data rows | — |
| K | (spacer) | — |
| L | — | (blank) |
| M | — | YTD Actual |
| N | — | YTD Budget |
| O | — | (spacer) |
| P | — | =M{row}-N{row} |
| Q | — | =IF(N{row}>0, IF(P{row}/N{row}>1000,"1000+%",P{row}/N{row}),"n/a") |
| S | — | YTD Comments — blank |
Header bands: D4:H4 merged + centered, content = <MMM-YY>. M4:Q4 merged + centered, content = YTD <MMM-YY>. Both bold, white-on-black.
Row 5 headers: D5/M5=Actual, E5/N5=Budget, G5/P5=Variance, H5/Q5=%. J4=<MMM-YY> Comments, S4=YTD Comments. Bold, centered.
Paste these EXACT strings; never rewrite them from memory. Excel number-format strings are easy to mangle.
| Use for | Format string |
|---|---|
| Currency cells (D, E, G, M, N, P + subtotals + totals) | locale token for resolved currency: [$$-en-US] USD, [$€-x-euro2] EUR, [$£-en-GB] GBP, [$CA$-en-CA] CAD — pattern: _-<CCY_TOKEN>* #,##0.00_-;_-<CCY_TOKEN>* (#,##0.00);_-<CCY_TOKEN>* "-"??_-;_-@_- |
| Variance cells if you want no $ symbol (optional) | _(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_) |
| Percent cells (H, Q) | 0.0%;(0.0%) (right-aligned) |
Use the locale-specific token for the resolved currency — never a bare $ or "$" (resolves to system currency on non-US locales, renders as R$ on pt-BR, £ on en-GB). Resolve from fund data: [$$-en-US] USD, [$€-x-euro2] EUR, [$£-en-GB] GBP, [$CA$-en-CA] CAD. The negatives section (after the first ;) MUST keep the parens form (#,##0.00) — leading-minus is wrong and will be flagged immediately.
Section order is fixed (Revenue → Human Capital → Contractor → Occupancy →
Professional Services → Travel & Marketing → Technology & Data → Other),
documented in references/section-map.md. One blank row between sections.
The row set is not "everything Gate 2 returned". It is the union of (a) accounts with non-zero actuals from Gate 2 and (b) accounts with non-zero budget from Gate 4b. Dropping budget-only rows forces a full rebuild the moment the user notices the gap.
If budget_source != "skip":
{(gl_code, account_name)}.budget_rows where Month or
YTD budget is non-zero: {(gl_code, account_name)}.month_actual, ytd_actual — from Gate 2's row, or null if budget-onlymonth_budget, ytd_budget — matched from Gate 4b per the GL-code → exact-name → prefix-name precedence in references/fill-budget-columns.md step 2ACCOUNT_TYPE / gl_code ascending.Render every account in the union. For budget-only rows, write 0
(hardcoded) — NOT a blank — into the Actual columns D and M. A literal
0 keeps the Variance formula meaningful (Budget - 0 = Budget gap)
and signals "we checked, no actuals" rather than "data missing".
If budget_source == "skip": render only the actuals row set from Gate 2.
This union step replaces the post-build "insert missing budget rows"
flow in fill-budget-columns.md step 4 for the Carta / file / tab cases.
That step now applies only as a fallback when budget data was unavailable
at Gate 6 time.
Every item below must be applied during Gate 6, not patched in afterward. A first build missing any of these triggers a "fix the format" follow-up turn that costs more tokens than getting it right once.
D4:H4 (Month) and M4:Q4
(YTD), both merged + horizontally centered.#0000FF.
Formulas (G, H, P, Q, subtotals, totals) stay default black.Total expenses (pre-tax)
and Net Income) — bold, top thin border on B–H and L–Q.Total expenses (pre-tax) — bold, top medium border on B–H
and L–Q.Net Income /(loss), pre tax — bold, top medium border + bottom
double border on B–H and L–Q. Plus numFmt="@" on the column-B
label so Excel doesn't reinterpret the slash.indentLevel = 1 so section headers stand out.B5:H5, L5:Q5,
J5, S5.references/formatting.md remains the source of truth for cell coordinates and number formats.
range.merge(true) discards values in trailing cells. Never merge
cells whose contents you still need. To add a header row above
existing sub-headers, insert a new row first
(sheet.getRange("4:4").insert(...)) and write the merged labels into
the inserted row — do not merge over a row that already holds sub-headers."Jan 2026" or "Mar-26" into a date serial (e.g. 46023), rendering
as a bare integer unless formatted. Either prefix with apostrophe
("'Mar-26") to force text, set numFmt = "@" on the cell before
writing the string, or write a real date with
numberFormat: "mmm yyyy" in the same write.references/fill-budget-columns.md). Write matched values into E and N inline during this build.=SUM(...); Variance is =Actual - Budget; Net Income is =Revenue subtotal - Total expenses.Capture cell references before moving on. Gate 7 needs these — record them in a small map you can address by name:
Total Human Capital)ACCOUNT_NAME
(case-insensitive)These all stay constant between the Month and YTD blocks — the same row
is D in Month and M in YTD.
Done when: the detail sheet exists with both period blocks, all sections, all subtotals, total expenses, and net income — all driven by formulas; Budget and Comments columns blank; the row-reference map is captured.
After the detail tab is written and read-back has confirmed the row map, call context_snip on the large execute_office_js write payloads from this gate — Gate 7 only needs the row-reference map you captured, not the full row arrays.
Skip this gate entirely if build_mode == "tag-view" or if Gate 4
chose "Build the detail tab only". Tag-view writes one tab; detail-only
writes one tab. Both jump straight to Gate 8.
Read references/summary-tab.md AND references/branding-and-header.md now and apply both verbatim. The Summary tab follows the same 4-row metadata band as the detail tab — rows 1–4 reserved for firm/title/source/context, and the Carta logo at column E anchored to E1 with height = rows 1–3. If summary-tab.md's legacy layout puts the Executive Summary title on B2 with a larger font, keep it on B2 but trim the font down so it still fits inside the 4-row band (or move auxiliary text to B3/B4).
The Summary tab is not "built" until it carries a CartaLogo shape sized to the E1:E3 row band. Paste the brand block from Gate 6 (the verbatim version inline there) — never hardcode image.height = <number>, never anchor to a single cell. Substitute <TAB_NAME> = <SUMMARY_TAB_NAME>.
Brand-verification call (REQUIRED, observable). Run this as a separate execute_office_js call before moving to Gate 8. Same shape-geometry check as the detail tab:
const sheet = context.workbook.worksheets.getItem("<SUMMARY_TAB_NAME>");
sheet.shapes.load("items/name,items/height,items/left");
const rows = sheet.getRange("E1:E3");
rows.load(["height", "left"]);
await context.sync();
const logo = sheet.shapes.items.find(s => s.name === "CartaLogo");
return {
found: !!logo,
heightMatchesBand: logo ? Math.abs(logo.height - rows.height) < 2 : false,
leftMatchesBand: logo ? Math.abs(logo.left - rows.left) < 2 : false,
};
Pass criteria — found, heightMatchesBand, leftMatchesBand all true. Same fix paths as Gate 6: pixel-literal height → delete shape + re-run verbatim block; single-cell anchor → delete shape + re-run with getRange("E1:E3"). If any check fails, re-run before moving to Gate 8.
summary-tab.md covers sheet name, position (index 0 — first tab), header rows, the
Month and YTD blocks, the keyword buckets for revenue, the cross-sheet
formula contract back to the detail, number formats, borders, and column
widths.
Use the row-reference map captured at the end of Gate 6 to resolve every formula on the Summary tab. Never hardcode a number — every Actual / Budget cell on the Summary is a cross-sheet formula pointing at the detail tab, so refreshing the detail updates the summary automatically.
Reminders from references/summary-tab.md:
='P&L - <FIRM-SHORT> <MMM-YY>'!D<row> (single quotes required
because the tab name contains spaces).0 so Investment Income's SUM still evaluates — and they
must be surfaced in Gate 8's report.[$$-en-US] USD, [$€-x-euro2] EUR, [$£-en-GB] GBP, [$CA$-en-CA] CAD) —
arguably more important on the Summary, which is the tab most likely
to be screenshotted.Done when: Summary P&L tab exists at position 0, every amount on it is a formula referencing the detail tab (no hardcoded values), Net Income reconciles to the detail for both Month and YTD.
Tag-view branch (if build_mode == "tag-view"): load
references/tag-view.md §"Gate 8 — verification
Standard branch (both tabs or detail-only): the rest of Gate 8 applies.
Gate 8 precondition (DO NOT SKIP). Before sending the report text below, scan your tool history. Three anchors MUST be present in that order:
AskUserQuestion whose answer included "Approve and write" (or the Gate 4 "Build it" approval) — approval gate.sheet.shapes.addImage(base64) call for the detail tab — and one for the Summary tab if Gate 4 included Summary — Gate 6/7 branding.execute_office_js whose result reported {found: true, heightMatchesBand: true, leftMatchesBand: true} for every tab — Gate 6/7 verification. (The verification call now returns a geometry object instead of a shape-name array; matching on "CartaLogo" alone is no longer sufficient.)If any anchor is missing, you have skipped a gate. Do NOT report tie-out success in the build summary when no shapes.addImage call appears in your tool history. STOP, go back, run the missing gate, then return here.
After writing the detail tab (and the Summary tab, if Gate 4 included it):
Net Income row Actual columns on the detail (D and
M). Verify each equals Revenue subtotal − Total expenses.Net Income row on the Summary
(C15 for Month, C28 for YTD). Verify each equals the detail's Net
Income — Month against detail D, YTD against detail M.budget_source = "skip": confirm Budget (E, N) are empty for
detail data rows. If budget was filled: verify at least one Budget
cell in column E is non-empty and no Budget value is written to a
Comments column (J, S).0 in columns D and M and their budget
value in E and N. If any are missing, you skipped the union step at
the start of Gate 6 — go back and fix it before reporting tie-out.Net Income row and verify it has: bold font, top
medium border, bottom double border. If either fails, the Gate 6
formatting checklist didn't land — re-apply before claiming done.Report structure:
Lead with a one-line confirmation, then a Key tie-outs block, then the detail. Status vocabulary: ✅ Match, ⚠ Mismatch ($X diff).
The P&L is ready in
<workbook>.xlsx— Summary P&L and [P&L - ](<citation:P&L - !B1:Q72>).
(Substitute <FIRM-SHORT> and <MMM-YY> with the resolved values before rendering the citation link — leaving the angle-bracket placeholders in the URL produces a broken link.)
Key tie-outs (Summary ties to detail): Net Income (Month) + Net Income (YTD) + Investment Income (Month) + Total expenses (Month). Render as the shared 5-column shape:
Line item | Detail | Summary | Difference | Status, totals bold,$0for matches.Follow with the standard "N items checked. M matched, X mismatched" line, then a one-paragraph build summary: account counts, sections populated, budget source label, "Comments columns are blank — fill them in as you go."
If the Summary tab was skipped (Gate 4 option 2), omit the Summary rows from the Key tie-outs and note "Summary tab not built (detail only)."
Flag negative-NOI months in the summary. If any monthly Net Income figure in the written sheet is negative, surface the count:
"⚠ N of 12 months show negative NOI in this projection — review the lumpy revenue/expense lines before locking the budget."
Don't bury this in a table. The user needs to see it in prose so they can decide whether to revise before sending the workbook downstream.
Surface unclassified items in a follow-up block (always shown, even when the lists are empty — empty is signal):
Accounts in
Other(review section mapping):
- Carried interest expense
- Foreign exchange
- Misc operating
Empty Summary buckets (no matching revenue accounts — extend the keyword list if you want these populated):
- Tax & Other Distributions
- Unrealized Gains or (Losses)
After reporting tie-outs and unclassified items, route into Gate 9 to run the budget merge (if budget data was pre-fetched) and close with the post-action menu. Do not render a final post-action menu here — Gate 9 owns the closing menu.
Done when: tab(s) exist, Key tie-outs reported with status, unclassified accounts and empty Summary buckets surfaced.
Skip the budget tie-out if build_mode == "tag-view" — Gate 4b was
skipped, no Budget data was fetched or written. In tag-view mode, jump
directly to the post-action menu at the end of this gate.
Budget data was fetched in Gate 4b and written during Gate 6. This gate finalises the budget merge (completing any steps Gate 6 couldn't do inline) and closes with the post-action menu.
budget_source is Carta / file / tab (budget data pre-fetched)Load references/fill-budget-columns.md
inline and run the steps that were not already handled during Gate 6:
=SUM(...) ranges (step 5) — only needed if
step 4 had to insert rows after the fact.0 so Variance/% resolve (step 6) —
applies to actuals-only rows where no budget match was found.budget_source = "skip"Tell the user in one sentence that Budget columns are blank and Variance
/ % will render n/a until they fill them in.
Surface the closing menu via AskUserQuestion:
The next-step menu MUST be a single AskUserQuestion call with the
options below as options entries. Never render them as a numbered
markdown list, a bulleted list, or inline prose — bare-text menus break
the chooser UI in Claude for Excel and force the user to type the
number. The ← recommended marker goes inside the description field
of one option, not as a suffix on the label.
Other accountsWhen the user selects an option, immediately invoke the corresponding skill via Skill('<skill-name>') BEFORE doing any work. Do not freelance the output — load the downstream skill's SKILL.md so its gates, layout spec, branding rules, and approval flow apply. Routing:
| Option | Skill to invoke |
|---|---|
| 1 — Build the Balance Sheet | Skill('carta-investors:carta-consolidating-balance-sheet') |
| 2 — Build the P&L for a different period | Skill('carta-investors:carta-consolidating-pnl') re-entry with the new period |
| 3 — Adjust the section mapping | Stay in this skill — re-run from Gate 5 with the user's revised mapping |
| 4 — I'm done | No invocation; close cleanly |
Done when: Budget tie-out reported (or skip noted), post-action menu rendered.
Source: the Carta DWH journal-entries table. If column names are needed, look up the table via the Carta MCP DWH schema command once at Gate 0.
Never auto-retry. Surface failures, let the user decide.
| Symptom | Likely cause | What to tell the user |
|---|---|---|
| No Carta MCP connected | Connector not enabled in Claude settings | "Open Settings → Connectors in Claude, enable Carta, then ask me again." List any failed Carta entries. |
contexts:list returns no firm | Firm name spelling mismatch or not in scope | Echo the name back and ask for the correct spelling. Never near-match silently. |
contexts:list returns multiple firms | Common short name matches several firms | Show the candidates via AskUserQuestion and let the user pick. |
| DWH query returns 0 rows | No P&L activity for the period, or books not posted | "No P&L activity found for this firm through <MMM YYYY>. Confirm the period or check whether books are posted." |
| DWH query times out | DWH load or unusually large date range | Tell the user the query is slow and offer to retry with the same parameters — never auto-retry. |
| Summary Net Income ≠ Detail Net Income | Formula error or row-reference map stale | Surface as ⚠ Mismatch ($X diff) in Gate 8's Key tie-outs; offer to rebuild the Summary tab. |
| Revenue accounts in unmatched Summary buckets | Keyword list doesn't cover the account name | Surface empty buckets; ask whether to extend the keyword list or accept zeros. |
| Auth / permission error from MCP | Carta session expired or lacks DWH access | "Reconnect Carta in Settings → Connectors." Do not retry automatically. |
ACCOUNT_NAME verbatim; section assignment is display-only.account_name only. Never "4160 Management fee income" or any variation. GL code is internal-only.CartaLogo on column E. See references/branding-and-header.md.npx claudepluginhub anthropics/claude-plugins-official --plugin carta-investorsPull a ManCo budget from Carta via MCP and write to an Excel workbook with monthly amounts and subtotals.
Generates income statements, balance sheets, cash flows with period-over-period comparisons and variance analysis for monthly/quarterly P&L, budget vs actuals, and GAAP-compliant reporting.
Builds profit & loss statements from Well workspace posted accounting ledger. Use for P&L, income statement, or net result queries.