From carta-investors
Compares YTD actuals (from Carta MCP) against a budget workbook for pacing and variance analysis. Presents sheet-wide overviews and drill-downs by line item with month-by-month detail.
How this skill is triggered — by the user, by Claude, or both
Slash command
/carta-investors:carta-budget-vs-actualsopusThis 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 base v0.1.0]
Pacing and variance analysis on top of an existing budget. Two references:
references/pacing-overview.md — sheet-wide pacing & variance.references/drill-down-line.md — month-by-month + top journal entries for one line.Audience is an accountant in Excel. Plain English only. Never surface MCP
identifiers, DWH column names, UUIDs, raw JSON, SQL, or step labels.
Currency: <CCY>X,XXX positive, (<CCY>X,XXX) negative, totals bolded — use the resolved currency symbol, never a bare $.
Differences are absolute. Status: ✅ Match | ⚠ Mismatch ($X diff) | ❌ Missing in Carta | ❌ Missing in Client Doc.
Closing summary link is a workbook citation (<citation:Sheet!Range>) in
Claude for Excel mode, and a file:// path in Claude Code / Cowork mode.
Chat-only mode produces neither — just the analysis.
Every numbered choice in this skill — including the closing
next-step menu — MUST be presented via AskUserQuestion. Never
render options as a bare code-fenced markdown list. The
AskUserQuestion tool is in allowed-tools; use it. Bare-text menus
break the chooser UI in Claude for Excel and force the user to type
the number.
carta-create-budget.carta-budget-actuals.carta-budget-scenarios.carta-consolidating-pnl.Execute all steps silently. Do not narrate tool calls, intermediate results, or status updates. Only speak at explicit decision points: Step 0.5 (if runtime is ambiguous), Step 1 (destination chooser), Step 2 (intent routing), Step 3 (period/parameter gate), Step 6 (approval), and Step 8 (next-step menu).
Mandatory telemetry beacon — run this before any skip-gate check: If <SERVER> and <ENTITY_UUID> are already in context, call mcp__<SERVER>__set_context(firm_id=<ENTITY_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-vs-actuals"]}) 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 Step 0 below.
Before Step 0, check whether these context variables are already set from an earlier budgeting skill call in the same session:
<SERVER> — connected Carta MCP server prefix<ENTITY_NAME> and <ENTITY_UUID> — the resolved entity<RUNTIME> — excel-addin or local-fileIf all four are in context: skip Steps 0 and 0.5 entirely. Call mcp__<SERVER>__set_context(firm_id=<ENTITY_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-vs-actuals"]}) to re-anchor the session scope and record this skill invocation. Proceed from Step 1 (destination chooser).
If any is missing (fresh session or cold invocation): run Steps 0 and 0.5 in order, then continue from Step 1.
Do not ask "which firm?" or "which runtime?" when those are 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.connected, probe all three prefix forms in parallel: mcp__claude_ai_Carta__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-vs-actuals"]}) , mcp__carta_production__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-vs-actuals"]}), and mcp__carta__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-vs-actuals"]}). First success = <SERVER>.mcp__<SERVER>__* tool before welcome — every other command is gated and will return a reminder.If no Carta connected, tell the user and stop. If multiple, default to Carta (production).
Resolve firm: if user named one → mcp__<SERVER>__list_contexts(firm_name="<entity>", _instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-vs-actuals"]}) → disambiguate via AskUserQuestion if multiple → mcp__<SERVER>__set_context(firm_id=<FIRM_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-vs-actuals"]}). Do not use call_tool for list_contexts or set_context — call the granular tools 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".
If no firm was named, defer to Step 1.
Set <RUNTIME> to excel-addin (open workbook references) or local-file (user-supplied path). If unclear, ask via AskUserQuestion.
Branches by <RUNTIME>.
If <RUNTIME> is excel-addin:
Empty-workbook shortcut: if the active workbook has one sheet, maxRows == 0, no other tabs (typically a fresh Book1.xlsx/Sheet1), skip the chooser. Announce the rename in one sentence — "I'll use the empty workbook you have open and rename Sheet1 to Budget vs Actuals." — then proceed. (Skip this shortcut if the user asked for chat-only output.) The chooser only exists to protect non-empty state.
Where should I write the analysis?
Budget vs Actuals" (recommended).If <RUNTIME> is local-file:
Where should the analysis go?
Budget vs Actuals sheet to the same file" (recommended).<budget>-vs-actuals.xlsx file alongside the original".The "chat-only" option matters for the drill-down case where the user
just wants a quick answer. In chat-only mode, skip the read-helper step
in local-file mode if no file is open.
Call read_skill for the matched reference immediately — do not reconstruct the analysis spec from memory:
| Phrase | Call |
|---|---|
| "compare", "pacing", "on track", "variance", "how are we doing" | read_skill(file_path="references/pacing-overview.md") |
| "why are we over on ", "drill into ", "what drove ", "largest entries", "which months", "what's behind" | read_skill(file_path="references/drill-down-line.md") |
If <RUNTIME> is excel-addin: use the add-in's read tools.
If <RUNTIME> is local-file:
uv run "${CLAUDE_PLUGIN_ROOT}/scripts/read_workbook.py" \
"<BUDGET_PATH>" --sheet "<BUDGET_SHEET>"
In both modes:
Use references/get-actuals.md as the canonical
source. <period_start> = first day of budget year, <period_end> = today
(or last completed month — ask).
In parallel, call read_skill(file_path="references/vendor-actuals.md") and
run the vendor actuals query with the same period bounds — this loads
<VENDOR_ACTUALS> into session context so vendor questions (e.g. "which vendor
is driving the Travel overage?") are answerable for the rest of the session
without a second round-trip.
For each line:
actual_ytd = sum of monthly actuals to date.budget_ytd = sum of monthly budget through same period.% of annual consumed = actual_ytd / annual_budget.% of year elapsed = months_elapsed / 12 — single source of
truth, used in both the SKILL.md and pacing-overview.md.projected run-rate = actual_ytd / months_elapsed * 12.pacing flag =
OK if within ±10% of expected pace,Over if >10% above pace,Under if >10% below pace,New activity, no budget if actual_ytd > 0 and annual_budget = 0.Render two preview tables — overview (≤6 cols) and pacing detail. Splitting keeps each table scannable; squeezing 9 columns into one breaks the chooser UI in Claude for Excel.
Overview:
| Section | Line Item | Annual Budget | YTD Actual | % Consumed | Flag |
|---|
Pacing detail (one row per flagged line — drop the OK rows):
| Line Item | YTD Budget | Variance | Run-Rate | Projected Year-End |
|---|
Output the preview tables above as a normal conversation message. Then call AskUserQuestion immediately after — the question field must be a single short sentence; never include preview content inside it.
question: "Approve writing this analysis?"header: "Approval"multiSelect: falseoptions:
description: "Writes the pacing analysis to a new tab.")The ← recommended marker goes inside the description field of option 1, not as a suffix on the label.
Wait for OK before writing. Skipped entirely in chat-only mode.
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 catch yourself about to call a workbook-write tool without that approval recorded, stop and run this gate first.
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 literally include "Approve and write the analysis"? If NO, Step 6 did not pass — send the Step 6 approval menu now and wait for the explicit answer.
Do not interpret upstream answers as approval. A period-end answer from Step 1, a destination answer, or any prior AskUserQuestion whose answer is not literally "Approve and write the analysis" 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.branding-and-header.md (sheet.shapes.addImage(...)).CartaLogo exists.Returning from Call 1 does NOT finish Step 7. The verification call must appear in your tool history before Step 8 summary.
Before any write, call both of these in the same message (parallel reads):
read_skill(file_path="references/branding-and-header.md") — 4-row metadata band, logo placement, blobs.getText asset pattern, cell-comment API.read_skill(file_path="references/<reference-from-step-2>.md") — the analysis file matched in Step 2 (pacing-overview.md or drill-down-line.md).Do not reconstruct either spec from memory. Both files must be in your context before generating any execute_office_js or write_workbook.py code. The branding-and-header.md file defines the reserved 4-row metadata band (A1 firm / A2 descriptive title like "Q1 2026 Budget vs Actuals (YTD through March)" / A3 source / A4 other context), the Carta logo placement (column E, rows 1–3 height), the blobs.getText("assets/...") asset-loading pattern for Excel add-in (NOT Read), and the cell-comment pattern for any "pacing off plan" flag.
If <RUNTIME> is excel-addin: use the add-in's cell-write tools, then brand the new Budget vs Actuals tab (and the underlying Budget tab if a header band was inserted into it).
If <RUNTIME> is local-file:
uv run "${CLAUDE_PLUGIN_ROOT}/scripts/write_workbook.py" --stdin <<'JSON'
{
"workbook_path": "<DESTINATION>",
"operations": [ ... ]
}
JSON
Include add_image (one per new tab) and set_comment (one per pacing-flagged row) ops in the same payload as the cell writes.
All numerical columns should be live formulas where possible (so the user can rerun by editing source data).
After running the brand block for every tab this skill touched, run this verification as a separate execute_office_js call before proceeding to Step 8. Substitute <sample_amount_cell> with one amount cell from the written range (e.g. C7):
const tabs = [/* "Budget vs Actuals", ... — substitute the actual tab names touched this run */];
const result = {};
for (const tabName of tabs) {
const sheet = context.workbook.worksheets.getItem(tabName);
sheet.shapes.load("items/name");
const cell = sheet.getRange("<sample_amount_cell>");
cell.load("numberFormat");
await context.sync();
result[tabName] = {
shapes: sheet.shapes.items.map(s => s.name),
logoFound: sheet.shapes.items.some(s => s.name === "CartaLogo"),
numberFormat: cell.numberFormat[0][0],
currencyOk: cell.numberFormat[0][0].includes("[$"), // locale-specific currency token, e.g. [$$-en-US]
};
}
return result;
Per-tab pass criteria — ALL must be true:
logoFound === true — CartaLogo shape existscurrencyOk === true — amount cell format contains [$ (locale-specific currency token)Recovery actions:
logoFound: false → re-run the verbatim brand block for that tab, then re-verify.currencyOk: false → re-apply the locale-specific token for the resolved currency — pick the matching line, substitute <full_amount_range>, then re-verify:
sheet.getRange("<full_amount_range>").numberFormat = [["[$$-en-US]#,##0.00_);([$$-en-US]#,##0.00);\"-\""]];sheet.getRange("<full_amount_range>").numberFormat = [["[$€-x-euro2]#,##0.00_);([$€-x-euro2]#,##0.00);\"-\""]];sheet.getRange("<full_amount_range>").numberFormat = [["[$£-en-GB]#,##0.00_);([$£-en-GB]#,##0.00);\"-\""]];sheet.getRange("<full_amount_range>").numberFormat = [["[$CA$-en-CA]#,##0.00_);([$CA$-en-CA]#,##0.00);\"-\""]];Do not start Step 8 summary text until every tab passes both criteria.
Range.getImage() is forbidden here. The shape name check is the complete, sufficient logo verification. Never output "I cannot visually verify the logo placement" — the shape check IS the verification. If you find yourself reaching for Range.getImage(), stop and use the shape check instead.
Step 8 precondition (DO NOT SKIP, non-chat-only modes). Before sending the summary text below, scan your tool history. Three anchors MUST be present in that order:
AskUserQuestion whose answer included "Approve and write the analysis" — Step 6 approval.sheet.shapes.addImage(base64) call for each tab the skill touched — Step 7 branding.execute_office_js whose result showed logoFound: true and currencyOk: true on every tab — Step 7 verification.If any anchor is missing in a write mode (not chat-only), you have skipped a gate. Do NOT write "Carta logo placed at..." in the summary when no shapes.addImage call appears in your tool history. STOP, go back, run the missing gate, then return here. (Chat-only mode skips all three — the summary IS the deliverable.)
If <RUNTIME> is excel-addin:
Pacing summary: 12 lines on plan, 3 over (Travel +28%, Legal +14%, AI Tooling new activity not budgeted), 2 under (Audit −22% — Q4-loaded, expected; Tax Prep −18%). Run-rate forecast lands 42,000 over annual operating budget. Full table on Budget vs Actuals.
If <RUNTIME> is local-file:
Pacing summary: 12 lines on plan, 3 over, 2 under. Run-rate forecast lands 42,000 over annual operating budget. Full table written to
Budget vs Actualsinfile:///path/to/<budget-workbook>.xlsx.
If chat-only: the summary IS the deliverable — render the full pacing table inline. No file or citation.
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.
Call AskUserQuestion with these exact parameters:
question: "What would you like to do next?"header: "Next step"multiSelect: falseoptions: the four label + description pairs above (place ← recommended in the description field of the recommended option, NOT in the label)DO NOT render the menu as inline markdown text, a numbered list, a bulleted list, or closing prose. If your response is about to contain 1. ..., 2. ..., 3. ..., 4. ... as a list at the end of the summary instead of an AskUserQuestion tool call, you have failed this gate — back up and invoke the tool.
Mark option 1 ← recommended when at least one line is flagged Over.
Mark option 2 ← recommended when the user's prompt mentions cash or
preserving spend. Mark option 3 if the latest actuals are clearly stale.
When 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 — Drill into a specific line item | Skill('carta-investors:carta-budget-vs-actuals') re-entry with the drill-down-line reference |
| 2 — Model a what-if scenario | Skill('carta-investors:carta-budget-scenarios') |
| 3 — Refresh the underlying actuals | Skill('carta-investors:carta-budget-actuals') |
| 4 — I'm done | No invocation; close cleanly |
Queries > 50 rows: request format: "ndjson" and bucket into a blob. Pasting large results triggers context_snip and compresses earlier gate checks. Use "markdown" only for ≤50-row previews.
range.merge(true) discards trailing cell values. Insert a new row first; don't merge over an existing sub-header row.numberFormat = [["@"]] (text format) to the entire header range first, then write the values. Without this, Excel auto-coerces "Jan 2026" → date serial 46023.welcome, or the currency on the budget data); if it can't be resolved, ask the user. State the resolved currency in cell A4: Amounts in <resolved_currency>.[$$-en-US]#,##0.00_);([$$-en-US]#,##0.00);"-" for USD, [$€-x-euro2]#,##0.00_);([$€-x-euro2]#,##0.00);"-" for EUR, [$£-en-GB]#,##0.00_);([$£-en-GB]#,##0.00);"-" for GBP. Resolve from the data — never default to USD. Do not use bare $, _($*, or quoted "$" — Excel strips quotes from stored format strings, leaving a bare $ that renders as the system currency symbol.execute_office_js block, in this order — never a separate call: restore automatic calc → context.workbook.application.calculate(Excel.CalculationType.full) → set fixed widths on the text label columns → autofit numeric columns only → context.sync(). Pattern:
context.application.calculationMode = Excel.CalculationMode.automatic;
context.workbook.application.calculate(Excel.CalculationType.full);
sheet.getRange("A:A").format.columnWidth = 160; // Section — fixed, ~22 chars (points, not char-width); fits "Operating Expenses" without clipping
sheet.getRange("B:B").format.columnWidth = 220; // Line Item — fixed, ~31 chars; fits long items like "Investment Management Fees"
sheet.getRange("C:<last_col>").format.autofitColumns(); // numeric columns only
await context.sync();
Why fixed widths for label columns: calling autofitColumns() on the full range sizes column A to the widest section name (e.g. "Operating Expenses"), making it excessively wide and pushing numeric columns off screen. Always set A and B explicitly, autofit only C onwards. Recalc before autofit: without it variance / NOI cells stay at 0 and accounting format shows - (#### after autofit). Never autofit a header-only range.style = "Continuous" then weight = "Thin". Never style: "Thin".references/branding-and-header.md for every tab. Rows 1–4 reserved for metadata band, Carta logo at column E (logo + band col-overrides documented in the reference). Asset access via blobs.getText("assets/...") — NOT Read. Pacing-flagged rows use cell comments only.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 Step 0.
Never auto-retry. Always surface the failure and let the user decide.
McpAuthError or "tool not available" | The MCP server's tool prefix doesn't match what this skill's allowed-tools enumerates. Re-auth is not the fix. | Re-run refresh_mcp_connectors to confirm which Carta name (Carta, Carta (Sandbox), carta, etc.) is actually connected, then probe the matching prefix's welcome per the Step 0 mapping. Never tell the user to re-auth without verifying the prefix mismatch first. |npx claudepluginhub anthropics/claude-plugins-official --plugin carta-investorsBuilds or restructures a fund/ManCo budget workbook in Excel from Carta prior-year actuals. Groups line items into sections with subtotals and applies inflation/contingency buffers.
Analyzes budget vs actual variances in Excel spreadsheets with automated drill-down, root cause identification, materiality thresholds, and waterfall charts for executive reporting.
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.