From carta-investors
Inserts actuals from Carta MCP into an existing Excel budget workbook, interleaving Budget/Actual/Variance columns or adding tag/vendor view tabs.
How this skill is triggered — by the user, by Claude, or both
Slash command
/carta-investors:carta-budget-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]
assets/powered_by_carta.b64.txtassets/powered_by_carta.pngqueries/actuals-by-account-month.sqlqueries/actuals-by-account-tag-period.sqlqueries/actuals-by-account-vendor-period.sqlqueries/actuals-by-vendor-period.sqlreferences/add-actuals-columns.mdreferences/add-actuals-tab.mdreferences/add-period.mdreferences/branding-and-header.mdreferences/get-actuals.mdreferences/inline-vendor.mdreferences/refresh-existing.mdreferences/tag-view.mdreferences/vendor-actuals.mdreferences/vendor-only-view.mdreferences/vendor-view.md[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]
Entry point for updating actuals in an existing budget. Seven references:
references/add-actuals-columns.md — Layout A: interleave Budget / Actual / Variance per month on the Budget tab (recommended for active tracking).references/add-actuals-tab.md — Layout B: add a peer <year> Actuals tab alongside the Budget tab.references/refresh-existing.md — Layout C: overwrite stale actuals cells in columns that already exist.references/add-period.md — Layout D: append the single next month/quarter column.references/tag-view.md — Layout E: new tab with actuals sliced by reporting dimension (department, project code, class, etc.) and a three-row period / category / tag header. Only offered when the entity has tagged journal data.references/vendor-view.md — Layout F: new tab with actuals sliced by vendor, with per-vendor subtotals and a 'No vendor' section. Only offered when the entity has vendor-tagged journal data.references/inline-vendor.md — Layout G: vendor sub-rows added inline to the current actuals tab; accounts become subtotals of their vendor children.references/vendor-only-view.md — Layout H: new tab with one row per vendor across a timeline — no GL account sub-rows. The lightweight vendor summary view.references/get-actuals.md — internal helper, the canonical actuals-query routine.Audience is an accountant in Excel. Plain English only. Never surface MCP
identifiers, DWH column names, UUIDs, raw JSON, SQL, or gate labels.
Currency: $X,XXX positive, ($X,XXX) negative, totals bolded.
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.
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-fetch-budget.carta-budget-vs-actuals.carta-budget-scenarios.carta-consolidating-pnl.carta-consolidating-balance-sheet.Execute all gates silently. Do not narrate tool calls, intermediate results, or status updates. Only speak at explicit decision points: Gate 0.5 (if runtime is ambiguous), Gate 1 (destination chooser), Gate 2 (layout choice — always ask), Gate 3 (period/parameter gate), Gate 6 (approval), and Gate 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-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 Gate 0 below.
Before Gate 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 Gates 0 and 0.5 entirely. Call mcp__<SERVER>__set_context(firm_id=<ENTITY_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}) to re-anchor the session scope and record this skill invocation. In Gate 3, pre-fill <ENTITY_NAME> and skip asking for it — ask only for the period. Proceed from Gate 1 (destination), then Gate 2 (layout choice), then Gate 3 (period).
If any is missing (fresh session or cold invocation): run Gates 0 and 0.5 in order, then continue from Gate 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.mcp__claude_ai_Carta__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}) , mcp__carta_production__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}), and mcp__carta__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}). 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).
Resolve firm: if user named one → mcp__<SERVER>__list_contexts(firm_name="<entity>", _instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}) → disambiguate via AskUserQuestion if multiple → mcp__<SERVER>__set_context(firm_id=<FIRM_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-budget-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 Gate 3.
Detect whether this is Claude for Excel (workbook is open in the
add-in) or Claude Code / Cowork (working with a .xlsx file on
disk). See carta-create-budget/SKILL.md Gate 0.5 for the heuristic — same
rule applies here.
If unclear, ask the user via AskUserQuestion:
"How are you working with the budget — inside Excel via Claude for Excel, or as a local .xlsx file (Claude Code / Cowork)?"
Store <RUNTIME> (excel-addin or local-file) for Gates 1, 4, 7, 8.
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 <TARGET_TAB>." — then proceed. The chooser only exists to protect non-empty state; an empty workbook has none. The chooser still applies whenever there is data or more than one tab.
Where should I write the updates?
If user picks "update directly", confirm which tab explicitly. If multiple tabs look like budgets, ask which one.
If <RUNTIME> is local-file:
Where is the budget file, and where should the updated version land?
-updated suffix by default.If the user gave a path in the original prompt, skip the choice. Store
<DESTINATION> (open workbook + tab in add-in mode, or .xlsx path +
sheet name in local-file mode).
Four layouts are valid for putting actuals into a workbook, and the same prompt can plausibly mean any of them. Always ask the user how the actuals should appear — never assume from the prompt's phrasing alone.
Use AskUserQuestion:
How should the actuals appear in the workbook?
| # | Option | Reference loaded |
|---|---|---|
| 1 | Interleave Budget / Actual / Variance columns per month on the Budget tab ← recommended | add-actuals-columns.md |
| 2 | Add a separate <year> Actuals tab alongside the Budget tab | add-actuals-tab.md |
| 3 | Refresh existing Budget / Actual / Variance cells (the cells are there, just stale) | refresh-existing.md |
| 4 | Add only the next single period column | add-period.md |
| 5 | Build a tag-view tab — actuals sliced by reporting dimension (department, project code, class, etc.) | tag-view.md — only offered when the entity has tagged data; see Gate 2.5 |
| 6 | Build a vendor-view tab — actuals sliced by vendor, with GL account detail (per-vendor subtotals and collapsible account rows) | vendor-view.md — only offered when the entity has vendor-tagged data; see Gate 2.6 |
| 7 | Add vendor rows inline to the current actuals tab — insert vendor sub-rows under each account; accounts become subtotals of their vendor children | inline-vendor.md — only offered when the active tab is already an actuals tab; see Gate 2.7 |
| 8 | Build a vendor summary tab — one row per vendor across a timeline, no GL detail | vendor-only-view.md — only offered when the entity has vendor-tagged data; see Gate 2.8 |
Use the user's prompt only as a hint for which option to highlight — never as authority to skip the question:
| Phrase in the prompt | Hint |
|---|---|
"interleave", "Budget / Actual / Variance", "variance by month", "add <year> actuals" (no other clue) | Option 1 (also the default ← recommended) |
| "add a tab", "track on its own tab", "separate actuals tab" | Option 2 |
| "refresh", "the actuals are stale", "pull latest", "sync" | Option 3 |
"add next month", "extend through <month>", "next period" | Option 4 |
| "by department", "by tag", "by cost center", "split by", "broken down by", "by reporting tag", "by project code" | Option 5 |
| "by vendor", "broken down by vendor", "vendor view", "which vendors", "vendor breakdown" — and the active tab is already an actuals tab | Offer Options 6 and 7 together; 7 ← recommended |
| "by vendor", "broken down by vendor", "vendor view", "which vendors", "vendor breakdown" — no actuals tab open | Option 6 |
| "break by vendor on this tab", "add vendors here", "vendor breakdown on this tab", "enrich this tab with vendors" | Option 7 |
| "vendor summary", "vendor spend over time", "vendors across", "which vendors did we pay", "vendor totals", "vendor breakdown without accounts", "just vendors" | Option 8 |
Option 5 availability: always show Layout E in the chooser. If the user picks it and Gate 2.5 finds no tag data on the entity, tell them in one sentence and fall back to Layout A automatically. Do not pre-filter the chooser — the entity name needed for the probe is not available until Gate 3.
Option 6 availability: always show Layout F in the chooser. If the user picks it and Gate 2.6 finds no vendor data on the entity, tell them in one sentence and fall back to Layout A automatically. Do not pre-filter the chooser.
Option 7 availability: only show Layout G when the active sheet is already an actuals tab (its name contains "Actuals" or the user is explicitly on it). If the user picks it and Gate 2.7 finds no vendor data, tell them in one sentence and fall back to Option 6 (new vendor-view tab) instead.
Option 8 availability: always show Layout H in the chooser. If the user picks it and Gate 2.8 finds no vendor data on the entity, tell them in one sentence and fall back to Layout A automatically. Do not pre-filter the chooser.
The user's pick locks the reference to load for the rest of the
workflow. Immediately call read_skill for the chosen layout — do not reconstruct from memory:
| Layout chosen | Call |
|---|---|
| Option 1 — Interleave columns | read_skill(file_path="references/add-actuals-columns.md") |
| Option 2 — Add actuals tab | read_skill(file_path="references/add-actuals-tab.md") |
| Option 3 — Refresh existing | read_skill(file_path="references/refresh-existing.md") |
| Option 4 — Add single period | read_skill(file_path="references/add-period.md") |
| Option 5 — Tag-view tab | read_skill(file_path="references/tag-view.md") |
| Option 6 — Vendor view tab | read_skill(file_path="references/vendor-view.md") |
| Option 7 — Vendor inline | read_skill(file_path="references/inline-vendor.md") |
| Option 8 — Vendor summary tab | read_skill(file_path="references/vendor-only-view.md") |
Why we always ask: the same prompt — "add 2026 actuals by month" — can mean Option 1, 2, or 3 depending on the user's intent and the current state of their workbook. Guessing wrong and rebuilding costs the user a corrective prompt. Asking once costs one click. Choose the click.
Skip this gate entirely unless the user chose Layout E at Gate 2.
Silent probe — no user-facing output. Layout E shows all firm tag categories side by side under each period band — no "which dimension?" picker. The probe's job is to discover the firm's tag taxonomy from REPORTING_TAGS_JSON (or fall back to the flat REPORTING_TAGS column when only that's populated).
call_tool({"name": "dwh__execute__query", "arguments": {
"sql": "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 FUND_NAME = '<entity_name>'
AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
"format": "markdown",
"_instrumentation": {"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}
}})
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 <CATEGORIES> = ["Reporting Tag"] and continue to Probe 3 (cardinality).Run one query that returns the value count per category (used to drive the wide vs long decision):
JSON path:
call_tool({"name": "dwh__execute__query", "arguments": {
"sql": "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 FUND_NAME = '<entity_name>'
AND REPORTING_TAGS_JSON IS NOT NULL
AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)
GROUP BY 1
ORDER BY 1",
"format": "markdown",
"_instrumentation": {"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}
}})
Flat path:
call_tool({"name": "dwh__execute__query", "arguments": {
"sql": "SELECT 'Reporting Tag' AS category, COUNT(DISTINCT REPORTING_TAGS) AS n_values
FROM <journal_entries_table>
WHERE FUND_NAME = '<entity_name>'
AND REPORTING_TAGS IS NOT NULL
AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
"format": "markdown",
"_instrumentation": {"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}
}})
Store <CATEGORIES> as the sorted list of distinct category values returned, and store <CARDINALITY> as the map of category → n_values. (Probe 3 returns both in one pass — no separate Probe 2 needed.) Compute the total column count:
total_columns = sum(n_values for each category) + len(<CATEGORIES>)
The + len(<CATEGORIES>) term covers the per-category Total columns. If the run uses Quarter or Month aggregation across multiple period blocks, multiply by the number of period blocks for the layout decision.
The authoritative thresholds live in references/tag-view.md §"Cardinality guard" — ≤ 24 wide / 25–36 ask / > 36 long. Keep that file as the single source of truth; the inline cutoffs below mirror it for runtime convenience and must move together if the table changes.
If total_columns > 24 (single period) or total_columns × n_period_blocks > 24 (multi-period), ask via AskUserQuestion:
The tag-view tab would have
<N>columns across<C>categories. With that many, should I build a wide table (one column per tag per category per period) or a long table (one row per tag per account)?
Store <TAG_LAYOUT> (wide | long). Default wide for ≤ 24 (no question asked); default long for > 36 (no question asked).
Skip this gate entirely unless the user chose Layout F at Gate 2.
Silent probe — no user-facing output. Checks whether VENDOR_NAME is populated and counts distinct vendors.
call_tool({"name": "dwh__execute__query", "arguments": {
"sql": "SELECT
COUNT_IF(VENDOR_NAME IS NOT NULL) AS tagged_rows,
COUNT_IF(VENDOR_NAME IS NULL) AS untagged_rows,
COUNT(DISTINCT VENDOR_NAME) AS distinct_vendors
FROM <journal_entries_table>
WHERE FUND_NAME = '<entity_name>'
AND ACCOUNT_TYPE >= '4000'
AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
"format": "markdown",
"_instrumentation": {"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}
}})
tagged_rows > 0 → vendor data exists. Store <VENDOR_COUNT> = distinct_vendors and <HAS_UNTAGGED> = (untagged_rows > 0). Continue to Gate 3.tagged_rows == 0 → no vendor data on this entity. Tell the user in one plain-English sentence — "Your journal data doesn't have any vendor information, so I'll build a flat actuals view instead." — and fall back to Layout A.Skip this gate entirely unless the user chose Layout G at Gate 2.
Silent probe — no user-facing output. Same probe as Gate 2.6 — confirms vendor data exists before rebuilding the tab inline.
Skip this gate entirely unless the user chose Layout H at Gate 2.
Silent probe — no user-facing output. Same probe as Gate 2.6 — checks whether
VENDOR_NAME is populated and counts distinct vendors.
call_tool({"name": "dwh__execute__query", "arguments": {
"sql": "SELECT
COUNT_IF(VENDOR_NAME IS NOT NULL) AS tagged_rows,
COUNT_IF(VENDOR_NAME IS NULL) AS untagged_rows,
COUNT(DISTINCT VENDOR_NAME) AS distinct_vendors
FROM <journal_entries_table>
WHERE FUND_NAME = '<entity_name>'
AND ACCOUNT_TYPE >= '4000'
AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
"format": "markdown",
"_instrumentation": {"plugin": "carta-investors", "skills": ["carta-budget-actuals"]}
}})
tagged_rows > 0 → vendor data exists. Store <VENDOR_COUNT> = distinct_vendors
and <HAS_UNTAGGED> = (untagged_rows > 0). Continue to Gate 3.tagged_rows == 0 → no vendor data on this entity. Tell the user in one plain-English
sentence — "Your journal data doesn't have any vendor information, so I'll build a
flat actuals view instead." — and fall back to Layout A.In one AskUserQuestion, confirm every parameter the prompt didn't already specify.
Entity: confirm <ENTITY_NAME> — the exact FUND_NAME value used in DWH queries. If the user named one at Gate 0, pre-fill it and only ask if it's ambiguous.
Period: offer smart defaults based on today's date (currently May 2026):
What period should I pull actuals for?
| # | Label | Date range |
|---|---|---|
| 1 ← recommended | Full year 2026 | Jan 1 – Dec 31, 2026 |
| 2 | YTD 2026 (Jan – May) | Jan 1 – May 31, 2026 |
| 3 | Q2 2026 (Apr – Jun, in progress) | Apr 1 – Jun 30, 2026 |
| 4 | Full year 2025 | Jan 1 – Dec 31, 2025 |
| 5 | Custom range — I'll specify start / end month | — |
Adapt ← recommended and visible options to context: YTD if the user said
"latest"; full year if they said "2026 actuals"; prior year if they said "2025".
Always compute the current quarter's label dynamically from today's date — do
not hardcode Q2.
If the prompt already specified a period (e.g. "Q1 2026", "full year 2025"), store it directly and skip the period question.
Store <PERIOD_START> (first day, YYYY-MM-DD) and <PERIOD_END> (last day).
Match strategy (Layouts A–D only): name first then GL code (default) vs GL code only. Omit for Layouts E, F, G, or H (no existing-sheet matching needed — these layouts always write a new tab).
Store <ENTITY_NAME>, <PERIOD_START>, <PERIOD_END>, <MATCH_STRATEGY> (Layouts A–D).
Skip this sub-gate for Layouts A–D and G — aggregation is always monthly there. Set <AGGREGATION> = MONTH and continue.
For Layouts E, F, and H, this MUST be a separate AskUserQuestion call — do not bundle with the period question above, and do not infer the answer from the period range. A YTD period (e.g. Jan–May) does not mean "Quarter"; a full-year period does not mean "Year". The user picks the period independently from the aggregation.
Aggregate columns by:
| # | Label |
|---|---|
| 1 ← recommended | Year — one period block per year |
| 2 | Quarter — one block per quarter |
| 3 | Month — one block per month |
Store <AGGREGATION> (YEAR | QUARTER | MONTH).
Hard rule: if you find yourself building a tag-view or vendor-view with multiple period blocks (e.g. Q1+Q2) without an AskUserQuestion whose answer literally selected one of the three options above, you skipped this gate. Stop and ask before proceeding to Gate 4.
Skip this sub-gate for Layouts A–E and H, or when <RUNTIME> is local-file (row outline grouping is not supported in the local-file path). Layout H has no GL sub-rows to collapse.
Ask via AskUserQuestion — a separate call from Gate 3a above:
Should vendor detail rows be collapsible in Excel?
| # | Label | Description |
|---|---|---|
| 1 ← recommended | Yes — collapsed by default | Rows hidden on open; click + to expand a vendor section |
| 2 | Yes — expanded by default | Rows visible; click − to collapse a vendor section |
| 3 | No grouping | Flat tab, no outline controls |
Store <VENDOR_GROUPING> (collapsed | expanded | none).
If <RUNTIME> is excel-addin:
Use the Excel add-in's runtime read tools to inspect the budget tab — header row, line-item rows, actuals/budget columns, formula rows.
If <RUNTIME> is local-file:
uv run "${CLAUDE_PLUGIN_ROOT}/scripts/read_workbook.py" \
"<DESTINATION_PATH>" --sheet "<BUDGET_SHEET>"
Parse the resulting JSON to identify the same structure (headers,
line items, formula rows). Treat any cell where is_formula: true as
load-bearing — never overwrite it.
Layout E: use the category-grouped query from
references/tag-view.md §SQL. Pick the JSON path
when Gate 2.5 detected REPORTING_TAGS_JSON rows; the flat path when only
REPORTING_TAGS was populated. Substitute <period_trunc> from <AGGREGATION>,
<period_start> from <PERIOD_START>, <period_end> from <PERIOD_END>, and
<entity_name> from <ENTITY_NAME>. The JSON path discovers <CATEGORIES>
inside the query via LATERAL FLATTEN — do not parameterize the category list
into the SQL. All other rules below apply unchanged.
Layout F: use the vendor query from
references/vendor-view.md §SQL. Substitute <period_trunc>
from <AGGREGATION>, <period_start> from <PERIOD_START>, <period_end> from
<PERIOD_END>, and <entity_name> from <ENTITY_NAME>. The query uses
COALESCE(VENDOR_NAME, 'No vendor') so NULL-vendor entries roll into a single
'No vendor' section — do not run a second query for untagged rows. All other hard
rules (entity scoping, books date, sign convention, P&L scope) apply unchanged.
Layout H: use queries/actuals-by-vendor-period.sql directly. Substitute <period_trunc> from <AGGREGATION>, <period_start> from <PERIOD_START>, <period_end> from <PERIOD_END>, and <entity_name> from <ENTITY_NAME>. Same COALESCE(VENDOR_NAME, 'No vendor') convention; same hard rules as Layout F. The query returns (vendor_name, period, signed_amount) — no gl_code or account_name columns.
Layouts A–D: call references/get-actuals.md for the main actuals query. In parallel, call read_skill(file_path="references/vendor-actuals.md") and run the vendor actuals query — this loads <VENDOR_ACTUALS> into session context so vendor questions (e.g. "which vendor is driving Legal Fees?") are answerable for the rest of the session without a second round-trip. Never write inline SQL outside those files.
Preview table grouped by:
If any rows carry the low-confidence — sparse history flag (account
has < 6 months of activity in the lookback window), surface the count
above the table.
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 applying these updates?"header: "Approval"multiSelect: falseoptions:
description: "Writes the actuals to the destination chosen in Gate 1.")The ← recommended marker goes inside the description field of option 1, not as a suffix on the label.
Wait for explicit OK before writing.
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 apply the updates"? If NO, Gate 6 did not pass — send the Gate 6 approval menu now and wait for the explicit answer.
Do not interpret upstream answers as approval. A Gate 2 layout response, a Gate 3 period-range answer, or any prior AskUserQuestion whose answer is not literally "Approve and apply the updates" does NOT clear this gate.
execute_office_js calls (excel-addin runtime)The most common failure mode is bundling cell writes + formatting + logo + verification into one writeSheet(...) function — the model writes the cells, returns, hardcodes the logo height, and the user gets a misaligned logo they have to resize manually. Do not combine the cell-write call with the brand block in a single office.js block.
execute_office_js. Return.execute_office_js. See the combined verification block below.Returning from Call 1 does NOT finish Gate 7. The final combined verification call must appear in your tool history before Gate 8 summary.
The single most common logo regression is hardcoding shape.height = 48 (or any other pixel value) instead of using the actual E1:E3 row-band height. Excel's row heights depend on font sizes set during Call 1, so the band height can vary tab-to-tab. The model that hardcodes 48 produces a logo that either spills past row 3 or sits inside row 1 — the user then has to resize manually. Paste this block verbatim per tab; substitute only <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();
// De-dup: remove any prior CartaLogo so re-runs don't stack shapes.
for (const s of shapes.items) {
if (s.name === "CartaLogo") s.delete();
}
await context.sync();
// Anchor to the FULL row band E1:E3 — never a single cell.
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; // ← match actual row-band height, never a pixel literal
image.width = rows.height * ratio;
image.left = rows.left;
image.top = rows.top;
image.lockAspectRatio = true;
await context.sync();
Forbidden patterns (these reproduce the manual-resize bug):
image.height = 48 (or any number literal) — height MUST come from rows.height.sheet.getRange("E1") instead of sheet.getRange("E1:E3") — single-cell anchor loses the band height.CartaLogo shape on top of the first.image.lockAspectRatio = false before sizing — Excel resists width changes if locked.Only touch the cells the user approved. Do not edit formulas elsewhere in the sheet (subtotals are formula-driven and will auto-update).
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/<layout-from-gate-2>.md") — the layout file chosen in Gate 2 (e.g. add-actuals-columns.md for Layout A).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:
sheet.getRange("1:5").insert(...) in Excel add-in mode, or via prepended row writes in local-file mode).Read).If <RUNTIME> is excel-addin:
references/add-actuals-columns.md §5 ("Build the rebuild payload") and apply its header / column / format spec verbatim — especially the two-row header (row N = merged month labels, row N+1 = Budget / Actual / Variance sub-headers — spelled out in full, never abbreviated). Then use the add-in's cell-write tools to execute the payload.references/tag-view.md §"Writing the workbook (excel-addin runtime)" and follow it verbatim — 3-row period/category/tag header, range.merge(true) for period and category bands. Do NOT freeze panes — same rule as Layouts A–D and the rest of the Carta budgeting skills.references/vendor-view.md §"Writing the workbook (excel-addin runtime)" and follow it verbatim — 2-row period/vendor header, range.merge(true) for period bands, per-vendor subtotals, 'No vendor' section at the bottom. Do NOT freeze panes.references/inline-vendor.md §"Write sequence (excel-addin runtime)" and follow it verbatim — clear rows 7+, rebuild account rows (bold, SUM formula) + vendor sub-rows (indented four spaces, hardcoded amounts, locale-specific currency token), then group and collapse. Gate 7's three-call sequence (cell write → brand block → combined verification) applies here too.If <RUNTIME> is local-file: build an operations payload and apply it:
uv run "${CLAUDE_PLUGIN_ROOT}/scripts/write_workbook.py" --stdin <<'JSON'
{
"workbook_path": "<DESTINATION_PATH>",
"operations": [ ... ]
}
JSON
write_cell / write_formula / set_format operations. Avoid create_sheet and write_range here — those are for carta-create-budget.create_sheet, write_cell, write_range, merge_cells, set_bold, set_format, set_column_width (Account col), autofit_columns (data cols) per references/tag-view.md §"Writing the workbook (local-file runtime)". Always issue write_cell for a period label before the merge_cells op for that same range. Do NOT include freeze_panes — same rule as Layouts A–D and the rest of the Carta budgeting skills.create_sheet, write_cell, write_range, merge_cells, set_bold, set_format, set_column_width (Vendor/Account col), autofit_columns (data cols) per references/vendor-view.md §"Writing the workbook (local-file runtime)". Always issue write_cell for a period label before the merge_cells op for that same range. Do NOT include freeze_panes.If <VENDOR_GROUPING> is collapsed or expanded (set at Gate 3b), run a 4th execute_office_js call after all three required calls pass verification. This call is separate from the three required calls — do not bundle it with cell writes, branding, or verification.
See references/vendor-view.md §"Collapse/expand grouping" for the exact code block. Substitute <PERIOD_LABEL> and <VENDOR_GROUPING> before running.
Local-file runtime: skip this step. Row outline grouping is not supported via write_workbook.py.
After the brand block runs for every tab, execute one execute_office_js that checks both currency format and logo geometry in a single context.sync(). This replaces the two separate passes (currency readback → branding check) previously required.
Two regressions this catches:
[$$-en-US]#,##0.00_);([$$-en-US]#,##0.00);"-" (USD). Never use bare $, _($*, or a quoted literal "$" — Excel strips quotes from stored format strings, leaving a bare $ that renders as the system currency symbol.shape.height = 48 misaligns the logo when the E1:E3 row band is taller or shorter than 48pt. Height must come from rows.height.Range.getImage() is forbidden here. The shape geometry (heightMatchesBand, leftMatchesBand) is the complete, sufficient logo verification. Never output "I cannot visually verify the logo placement" — the geometry check IS the verification. If you find yourself reaching for Range.getImage(), stop and use the geometry check instead.
This block is NOT paste-verbatim — substitute its placeholders before running: the tabs array (the tab names touched this run) and <sample_amount_cell>. The currencyOk check uses "[$" verbatim — no substitution needed.
const tabs = [/* "Budget 2026", "2026 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,items/height,items/left,items/top");
const rows = sheet.getRange("E1:E3");
rows.load(["height", "left"]);
// Pick one amount cell — typically C7 (Layout E) or B8 (Layouts A–D). Substitute from your payload.
const cell = sheet.getRange("<sample_amount_cell>");
cell.load("numberFormat");
await context.sync();
const logo = sheet.shapes.items.find(s => s.name === "CartaLogo");
result[tabName] = {
// Currency check
numberFormat: cell.numberFormat[0][0],
currencyOk: cell.numberFormat[0][0].includes("[$"), // locale-specific currency token, e.g. [$$-en-US]
// Branding checks
found: !!logo,
shapeHeight: logo ? logo.height : null,
rowBandHeight: rows.height,
heightMatchesBand: logo ? Math.abs(logo.height - rows.height) < 2 : false,
shapeLeft: logo ? logo.left : null,
rowBandLeft: rows.left,
leftMatchesBand: logo ? Math.abs(logo.left - rows.left) < 2 : false,
};
}
return result;
Per-tab pass criteria — ALL must be true:
currencyOk === true — sample cell format contains [$ (locale-specific currency token)found === true — CartaLogo shape existsheightMatchesBand === true — logo height equals E1:E3 row-band height ±2ptleftMatchesBand === true — logo anchors at column E's left edge ±2ptRecovery actions:
currencyOk: false → re-apply the locale-specific token for the resolved currency — pick the matching line, substitute <full_amount_range>, then re-run this combined verification:
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);\"-\""]];found: false → brand block was skipped — re-run the verbatim brand block, then re-verify.heightMatchesBand: false or leftMatchesBand: false → brand block used a hardcoded pixel or wrong anchor — delete the CartaLogo shape and re-run the verbatim brand block, then re-verify.Do not start Gate 8 summary text until every tab passes all four criteria. The verification call is observable evidence; without it in your tool history with passing checks, Gate 7 is not complete.
Gate 8 precondition (DO NOT SKIP). Before sending the summary text below, scan your tool history. Three anchors MUST be present in this order (excel-addin runtime):
AskUserQuestion whose answer included "Approve and apply the updates" — Gate 6 approval.sheet.shapes.addImage(base64) call for each tab the skill touched (one per tab) — Gate 7 branding.execute_office_js whose result showed currencyOk: true, found: true, heightMatchesBand: true, and leftMatchesBand: true for every tab — Gate 7 combined verification.If any anchor is missing, 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 — that's hallucinating completion. STOP, go back, run the missing gate, then return here.
Layouts A–D — If <RUNTIME> is excel-addin:
Refreshed 23 lines on Budget 2026 (Example MgmtCo). 2 lines zeroed (Audit, Tax Prep — no Q1 activity). 1 new account inserted under Operating Expenses (AI Tooling). 2 suspicious-zero flags — Salaries and Leased-employee payments dropped to $0; could be posting lag.
Layouts A–D — If <RUNTIME> is local-file:
Refreshed 23 lines on
Budget 2026infile:///path/to/<budget-workbook>.xlsx(Example MgmtCo). 2 lines zeroed (Audit, Tax Prep — no Q1 activity). 1 new account inserted (AI Tooling, Operating Expenses). 2 suspicious-zero flags — Salaries and Leased-employee payments.
Layout E — If <RUNTIME> is excel-addin:
Created 2026 Actuals by Department (Example MgmtCo) — 23 accounts × 4 department values (Engineering, Marketing, G&A, Untagged), annual aggregation. 1 account flagged low-confidence (sparse history). Carta logo placed at E1.
Substitute the period block phrasing to match
<AGGREGATION>from Gate 3a: "annual aggregation" forYEAR, "across 4 quarters" / "across Q1+Q2" forQUARTER, "across 12 months" / "across Jan–May" forMONTH.
Layout E — If <RUNTIME> is local-file:
Created
2026 Actuals by Departmenttab infile:///path/to/<budget-workbook>.xlsx(Example MgmtCo) — 23 accounts × 4 department values, annual aggregation. 1 account flagged low-confidence (sparse history). Adjust the period phrasing to match<AGGREGATION>(see excel-addin example above).
Layout F — If <RUNTIME> is excel-addin:
Created 2026 Actuals by Vendor (Example MgmtCo) — 23 accounts across 8 vendors (A2Z, Rippling, Alcatraz LLP, … No vendor), annual aggregation. 1 account flagged low-confidence (sparse history). Carta logo placed at E1.
Substitute the period block phrasing to match
<AGGREGATION>from Gate 3a: "annual aggregation" forYEAR, "across 4 quarters" / "across Q1+Q2" forQUARTER, "across 12 months" / "across Jan–May" forMONTH. List the top 3–5 vendor names; collapse the rest as "… No vendor".If
<VENDOR_GROUPING>iscollapsedorexpanded, append: "Vendor detail rows are grouped — use the +/− toggles on the left margin or the 1/2 outline buttons in the top-left corner to expand or collapse all vendor sections at once."
Layout F — If <RUNTIME> is local-file:
Created
2026 Actuals by Vendortab infile:///path/to/<budget-workbook>.xlsx(Example MgmtCo) — 23 accounts across 8 vendors, annual aggregation. 1 account flagged low-confidence (sparse history). Adjust the period phrasing to match<AGGREGATION>(see excel-addin example above).
Layout H — If <RUNTIME> is excel-addin:
Created 2026 Vendors (Example MgmtCo) — 8 vendors (A2Z, Rippling, Alcatraz LLP, … No vendor), annual aggregation. 1 vendor flagged low-confidence (sparse history). Carta logo placed at E1.
Substitute the period block phrasing to match
<AGGREGATION>from Gate 3a: "annual aggregation" forYEAR, "across 4 quarters" / "across Q1+Q2" forQUARTER, "across 12 months" / "across Jan–May" forMONTH. List the top 3–5 vendor names; collapse the rest as "… No vendor".
Layout H — If <RUNTIME> is local-file:
Created
2026 Vendorstab infile:///path/to/<budget-workbook>.xlsx(Example MgmtCo) — 8 vendors, annual aggregation. 1 vendor flagged low-confidence (sparse history). Adjust the period phrasing to match<AGGREGATION>(see excel-addin example above).
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 ← recommended based on context — option 1 by default after a refresh; option 2 if the user previously asked about a specific line.
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 — Run a pacing analysis | Skill('carta-investors:carta-budget-vs-actuals') |
| 2 — Drill into a specific line item | Skill('carta-investors:carta-budget-vs-actuals') with the drill-down-line reference |
| 3 — Model a what-if scenario | Skill('carta-investors:carta-budget-scenarios') |
| 4 — I'm done | No invocation; close cleanly |
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.
carta-create-budget — Carta DWH journal-entries table only, no external-DWH fallback, FUND_NAME scoping, AMOUNT (not the base-currency variant), sign flip, preserve reversals.read_workbook.py output. Subtotals / NOI keep their =SUM(...) semantics.Budget/Actual/Variance triplet. Row N+1 = sub-headers spelled out in full (Budget, Actual, Variance). Never abbreviate to B/A/V. Never write both into the same row — subsequent merges destroy sub-headers.range.merge(true) discards trailing cell values. Insert a new row first.numberFormat = [["@"]] (text format) to the entire header range first, then write the values. Without this, Excel auto-coerces "Jan 2026" → date serial 46023. This applies to row 6 column headers and any period-band rows (rows 6/7 in Layouts E and F). Applies to both execute_office_js (set range.numberFormat before range.values) and write_workbook.py (emit a set_format op with "@" before the write_cell ops for the header row).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 the currency from the data — never default to USD. Do not use a bare $ or _($* format — Excel substitutes the system currency symbol for those. Do not use a quoted literal like "$" — Excel strips the quotes when storing the format, leaving a bare $ that renders as the system currency. Apply to all data ranges after the data write.style = "Continuous", then weight = "Thin". Never style: "Thin".execute_office_js block (Call 1), in this order — never a separate call: restore automatic calc → context.workbook.application.calculate(Excel.CalculationType.full) → sheet.getRange("A:AN").format.autofitColumns() (widen the range to the last amount column) → context.sync(). Recalc before autofit: without the forced recalc the =SUM(...) / variance / NOI cells stay at 0 and the accounting format shows - (forcing the user to edit+Enter each one); autofitting before the recalc sizes the amount columns to the dash so real figures overflow as ####. Never autofit a header-only range.references/branding-and-header.md for every tab. Per-skill overrides: metadata band in column A (not B), logo at column E. Asset access via blobs.getText("assets/...").The skill queries 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 — production schema is canonical. Don't embed column listings inline; the DWH contract can drift.
| Symptom | Likely cause | What to tell the user |
|---|---|---|
| No Carta MCP server found | The Carta connector isn't enabled in this session | "I can't see your Carta connector. Open Settings → Connectors in Claude, enable Carta, then ask me again." |
| Sheet has no recognisable header row | The budget layout uses non-date column headers | Surface what the headers look like and ask the user which row is the header and which columns are actuals. |
low-confidence — sparse history flagged on many rows | Entity is new or sparsely posted | Surface the count in the preview and let the user decide whether to proceed. Don't auto-suppress. |
| Multiple budget tabs in the workbook | Ambiguous "the budget" | Ask the user which tab to update; do not silently pick one. |
| Cell the skill wants to write is a formula | Subtotal / NOI row | Surface the row and confirm; never silently overwrite a formula. |
| Local-file mode: file path is missing or unreadable | Wrong path supplied | Echo the path back and ask for the correct one. |
| Query times out | DWH load | Tell the user it's slow and offer to retry — never auto-retry. |
| Auth / permission error from the MCP | Carta session expired or lacks DWH access | Ask the user to reconnect Carta in Settings → Connectors. |
Connector shows as connected, but tool calls fail with 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 — see Gate 0 troubleshooting note below the table. | "I'm reconnecting to your Carta workspace — one moment." |
Connector-mismatch troubleshooting (operator-facing, not user-facing). Re-run refresh_mcp_connectors to confirm which Carta connector is actually connected, then probe the matching prefix's welcome per the Gate 0 mapping. Never tell the user to re-auth without verifying the prefix mismatch first.
Never auto-retry a failed query.
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.
Processes bookkeeper Excel/CSV exports into categorized spending, budget comparisons, variance analysis, runway calculations, and burn rate trends. Useful for month-end financial reviews.